Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ private let reconnectRouteLog = Logger(
category: "MobileReconnectRoutes"
)

/// Readiness of the selected Tailscale connection method.
///
/// Keeping the load phase explicit prevents presentation code from treating a
/// not-yet-loaded authorization as either confirmed or missing.
public enum MobileTailscaleSetupStatus: Equatable, Sendable {
case notSelected
case loadingAuthorization
case pairingRequired
case authorized
}

/// Canonical identity for one locally authorized legacy Tailscale endpoint.
private nonisolated struct MobileTailscaleAuthorizationEndpoint:
Hashable, Sendable
Expand Down Expand Up @@ -178,11 +189,23 @@ extension MobileShellComposite {
return hasStoredUsableTailscaleAuthorization
}

/// Readiness of the selected Tailscale method and its local endpoint grant.
public var tailscaleSetupStatus: MobileTailscaleSetupStatus {
guard connectionMethodStore?.method == .tailscale else {
return .notSelected
}
if hasUsableTailscaleAuthorization {
return .authorized
}
if pairedMacLoadState == .notLoaded, hasKnownPairedMac {
return .loadingAuthorization
}
return .pairingRequired
}

/// Whether the selected Tailscale method still needs its one-time pairing grant.
public var tailscalePairingRequired: Bool {
connectionMethodStore?.method == .tailscale
&& (pairedMacLoadState != .notLoaded || !hasKnownPairedMac)
&& !hasUsableTailscaleAuthorization
tailscaleSetupStatus == .pairingRequired
}

/// The strict Tailscale policy for one paired Mac: only exact grant routes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,7 @@ import Testing

#expect(store.pairedMacLoadState == .notLoaded)
#expect(!store.hasKnownPairedMac)
#expect(store.tailscaleSetupStatus == .pairingRequired)
#expect(store.tailscalePairingRequired)
}

Expand All @@ -689,8 +690,10 @@ import Testing
pairingHintDefaults: pairingDefaults
)

#expect(store.tailscaleSetupStatus == .loadingAuthorization)
#expect(!store.tailscalePairingRequired)
store.pairedMacLoadState = .failed
#expect(store.tailscaleSetupStatus == .pairingRequired)
#expect(store.tailscalePairingRequired)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ struct CMUXMobileRootView: View {
/// Optional environment models do not reliably invalidate this root when a
/// child sheet mutates them. Mirror the store's existing change stream so
/// capability closures are rebuilt for the newly selected method.
@State private var observedConnectionMethod: MobileConnectionMethod?
@State private var connectionMethodObservationToken: MobileConnectionMethod?
@Environment(\.dogfoodAttachPreparation) private var dogfoodAttachPreparation
private let signOutHook: MobileSignOutHook
private let startupConnectionCoordinator: MobileStartupConnectionCoordinator
Expand All @@ -46,6 +46,9 @@ struct CMUXMobileRootView: View {
@State private var pendingAttachURL: String?
@State private var didAuthenticateWithAttachTicket = false
@State private var didExceedStartupRestoringGate = false
/// One owner for the setup reminder's loading, required, and dismissed
/// presentation phases. Durable readiness remains in the shell store.
@State private var tailscaleSetupPrompt = MobileTailscaleSetupPromptState()
#if os(macOS)
@State private var isShowingAddDeviceSheet = false
@State private var pairingPresentation: PairingPresentation = .manual
Expand Down Expand Up @@ -267,13 +270,16 @@ struct CMUXMobileRootView: View {
}
.task(id: connectionMethodStore.map(ObjectIdentifier.init)) {
guard let connectionMethodStore else {
observedConnectionMethod = nil
connectionMethodObservationToken = nil
return
}
for await method in connectionMethodStore.changes() {
observedConnectionMethod = method
connectionMethodObservationToken = method
}
}
.onChange(of: store.tailscaleSetupStatus, initial: true) { _, status in
tailscaleSetupPrompt.apply(.shellStatusChanged(status))
}
.onDisappear {
cancelInjectedAttachTask(retryLaunchRoute: true)
authRevalidationTask?.cancel()
Expand Down Expand Up @@ -479,6 +485,8 @@ struct CMUXMobileRootView: View {
signOut: signOut,
setupHelpHighlight: disconnectedSetupHelpHighlight,
store: store,
showsTailscalePairingBanner: tailscaleSetupPrompt.showsBanner,
dismissTailscalePairingBanner: dismissTailscalePairingBanner,
showSettings: showSettings,
setupHelpPresentation: childSheetPresentation(
for: .disconnectedSetupHelp
Expand All @@ -499,6 +507,8 @@ struct CMUXMobileRootView: View {
signOut: signOut,
showAddDevice: addComputerAction,
showPairingScanner: pairingScannerAction,
showsTailscalePairingBanner: tailscaleSetupPrompt.showsBanner,
dismissTailscalePairingBanner: dismissTailscalePairingBanner,
showSettings: showSettings,
showComputers: showComputers,
taskComposerPresentation: childSheetPresentation(
Expand All @@ -513,6 +523,10 @@ struct CMUXMobileRootView: View {
}
}

private func dismissTailscalePairingBanner() {
tailscaleSetupPrompt.apply(.dismiss)
}

#if os(macOS)
/// Preserves the existing macOS pairing presenter independently of iOS routing.
private var addDeviceSheetBinding: Binding<Bool> {
Expand Down Expand Up @@ -582,7 +596,7 @@ struct CMUXMobileRootView: View {
},
setUpTailscale: {
handleRootPresentation(.setUpTailscale(
hasUsableAuthorization: store.hasUsableTailscaleAuthorization
status: store.tailscaleSetupStatus
))
},
showsLayoutProbe: showsAutoConnectMigrationLayoutProbe
Expand Down Expand Up @@ -731,8 +745,11 @@ struct CMUXMobileRootView: View {
case .useAutoConnect:
connectionMethodStore?.method = .automatic
autoConnectMigrationStore?.acknowledge()
case .setUpTailscale:
case let .setUpTailscale(requiresPairing):
connectionMethodStore?.method = .tailscale
tailscaleSetupPrompt.apply(
.selectedTailscale(requiresPairing: requiresPairing)
)
autoConnectMigrationStore?.acknowledge()
case .finishPairing:
finishPairingPresentation()
Expand Down Expand Up @@ -1024,9 +1041,13 @@ struct CMUXMobileRootView: View {

private var allowsManualPairing: Bool {
#if os(iOS)
(observedConnectionMethod ?? connectionMethodStore?.method) == .tailscale
// The stream value is only an invalidation token. Read the authoritative
// store synchronously so the migration transition can expose pairing in
// the same render that selects Tailscale.
_ = connectionMethodObservationToken
return connectionMethodStore?.method == .tailscale
#else
true
return true
#endif
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ struct DisconnectedWorkspaceShellView: View {
/// (this screen is the terminal not-connected state, reached after a stored
/// Mac reconnect fails). `nil` in previews.
var store: CMUXMobileShellStore?
/// Whether the root setup-prompt coordinator currently presents its banner.
var showsTailscalePairingBanner = false
var dismissTailscalePairingBanner: () -> Void = {}
var showSettings: () -> Void = {}
var setupHelpPresentation = MobileChildSheetPresentation()

Expand All @@ -44,10 +47,10 @@ struct DisconnectedWorkspaceShellView: View {
NavigationStack {
content
.safeAreaInset(edge: .top, spacing: 0) {
if store?.tailscalePairingRequired == true,
let showPairingScanner {
if showsTailscalePairingBanner, let showPairingScanner {
MobileTailscalePairingRequiredBanner(
scanPairingCode: showPairingScanner
scanPairingCode: showPairingScanner,
dismiss: dismissTailscalePairingBanner
)
}
}
Expand Down Expand Up @@ -234,7 +237,7 @@ struct DisconnectedWorkspaceShellView: View {
/// in that case the newer attempt is still in flight or has already
/// connected, and alerting "couldn't connect" would be wrong — skip it.
private func connect(to computer: MacComputerSnapshot) {
if store?.tailscalePairingRequired == true {
if showsTailscalePairingBanner {
showPairingScanner?()
return
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ struct MobileRootPresentationState: Equatable {
enum Action: Equatable {
case presentAutoConnectMigrationIfIdle
case useAutoConnect
case setUpTailscale(hasUsableAuthorization: Bool)
case setUpTailscale(status: MobileTailscaleSetupStatus)
case presentSettings
case dismissSettings(presentAutoConnectMigration: Bool)
case presentComputers
Expand All @@ -73,7 +73,7 @@ struct MobileRootPresentationState: Equatable {
case none
case acknowledgeAutoConnectMigration
case useAutoConnect
case setUpTailscale
case setUpTailscale(requiresPairing: Bool)
case finishPairing
case retryAutoConnectMigration
}
Expand Down Expand Up @@ -122,12 +122,20 @@ struct MobileRootPresentationState: Equatable {
presentation = nil
return .useAutoConnect

case let .setUpTailscale(hasUsableAuthorization):
case let .setUpTailscale(status):
guard presentation == .autoConnectMigrationIntroduction else { return .none }
presentation = hasUsableAuthorization
? nil
: .pairing(.scanner(entry: .autoConnectMigration))
return .setUpTailscale
switch status {
case .pairingRequired:
presentation = .pairing(.scanner(entry: .autoConnectMigration))
return .setUpTailscale(requiresPairing: true)
case .authorized, .loadingAuthorization, .notSelected:
// Selecting Tailscale while authorization is still being
// resolved must not open a scanner based on a stale false
// authorization flag. The shell will promote the setup banner
// if the authoritative result later requires pairing.
presentation = nil
return .setUpTailscale(requiresPairing: false)
}

case .presentSettings:
guard presentation == nil else { return .none }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,35 @@
import CmuxMobileSupport
import SwiftUI

/// Persistent recovery chrome for a Tailscale selection with no local endpoint grant.
/// Recovery chrome for a Tailscale selection with no local endpoint grant.
struct MobileTailscalePairingRequiredBanner: View {
let scanPairingCode: () -> Void
let dismiss: () -> Void

var body: some View {
VStack(alignment: .leading, spacing: 10) {
Label(
L10n.string(
"mobile.tailscalePairingRequired.title",
defaultValue: "Finish Tailscale setup"
),
systemImage: "qrcode.viewfinder"
)
.font(.headline)
HStack(alignment: .top, spacing: 10) {
Label(
L10n.string(
"mobile.tailscalePairingRequired.title",
defaultValue: "Finish Tailscale setup"
),
systemImage: "qrcode.viewfinder"
)
.font(.headline)

Spacer(minLength: 8)

Button(action: dismiss) {
Image(systemName: "xmark")
.font(.body.weight(.semibold))
.frame(width: 44, height: 44)
}
.buttonStyle(.plain)
.contentShape(Rectangle())
.accessibilityLabel(L10n.string("mobile.common.dismiss", defaultValue: "Dismiss"))
.accessibilityIdentifier("MobileTailscalePairingRequiredDismiss")
}

Text(MobilePairingScannerSheet.guidanceText)
.font(.subheadline)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import CmuxMobileShell

/// Session presentation state for the Tailscale setup reminder.
///
/// The shell owns durable setup readiness. This state only latches a requirement
/// already known by the migration route and remembers a dismissal until setup
/// readiness changes away from requiring pairing.
struct MobileTailscaleSetupPromptState: Equatable {
enum Presentation: Equatable {
case followsShell
case required
case dismissed
}

enum Action: Equatable {
case selectedTailscale(requiresPairing: Bool)
case shellStatusChanged(MobileTailscaleSetupStatus)
case dismiss
}

private(set) var presentation: Presentation = .followsShell

var showsBanner: Bool {
presentation == .required
}

mutating func apply(_ action: Action) {
switch action {
case let .selectedTailscale(requiresPairing):
presentation = requiresPairing ? .required : .followsShell

case let .shellStatusChanged(status):
switch status {
case .notSelected, .authorized:
presentation = .followsShell
case .loadingAuthorization:
break
case .pairingRequired:
if presentation == .followsShell {
presentation = .required
}
}

case .dismiss:
if presentation == .required {
presentation = .dismissed
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ struct WorkspaceListView: View {
var signOut: (() -> Void)?
/// Manual reconnect for the offline status row. `nil` in previews.
var reconnect: (() -> Void)?
/// Whether the root setup-prompt coordinator currently presents its banner.
var showsTailscalePairingBanner = false
var dismissTailscalePairingBanner: () -> Void = {}
/// Present the add-device (pairing) flow from the Computers screen. `nil`
/// hides the add affordance there.
var showAddDevice: (() -> Void)?
Expand Down Expand Up @@ -507,7 +510,8 @@ struct WorkspaceListView: View {
if connectionChrome == .tailscalePairingRequired,
let showPairingScanner {
MobileTailscalePairingRequiredBanner(
scanPairingCode: showPairingScanner
scanPairingCode: showPairingScanner,
dismiss: dismissTailscalePairingBanner
)
}
}
Expand All @@ -531,7 +535,8 @@ struct WorkspaceListView: View {
if let showPairingScanner {
Section {
MobileTailscalePairingRequiredBanner(
scanPairingCode: showPairingScanner
scanPairingCode: showPairingScanner,
dismiss: dismissTailscalePairingBanner
)
.listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12))
.listRowSeparator(.hidden)
Expand Down Expand Up @@ -911,7 +916,7 @@ struct WorkspaceListView: View {
connectionRecoveryFailed: store?.connectionRecoveryFailed ?? false,
isRecoveringConnection: store?.isRecoveringConnection ?? false,
connectionStatus: connectionStatus,
tailscalePairingRequired: store?.tailscalePairingRequired ?? false,
tailscalePairingRequired: showsTailscalePairingBanner,
isInitialConnectionLoading: isInitialConnectionLoading,
initialConnectionTimedOut: initialConnectionTimedOut
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ struct WorkspaceShellHost: View {
let signOut: @MainActor @Sendable () -> Void
let showAddDevice: (() -> Void)?
let showPairingScanner: (() -> Void)?
var showsTailscalePairingBanner = false
var dismissTailscalePairingBanner: () -> Void = {}
var showSettings: () -> Void = {}
var showComputers: () -> Void = {}
var taskComposerPresentation = MobileChildSheetPresentation()
Expand All @@ -39,6 +41,8 @@ struct WorkspaceShellHost: View {
retryInitialConnection: retry,
showAddDevice: showAddDevice,
showPairingScanner: showPairingScanner,
showsTailscalePairingBanner: showsTailscalePairingBanner,
dismissTailscalePairingBanner: dismissTailscalePairingBanner,
showSettings: showSettings,
showComputers: showComputers,
taskComposerPresentation: taskComposerPresentation
Expand Down
Loading