diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift index a971bacdcaf7..b7e5bf4a39cf 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift @@ -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 @@ -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 diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ReconnectRouteSelectionTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ReconnectRouteSelectionTests.swift index e7bb2e8fc4c5..be1bcd751a9c 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ReconnectRouteSelectionTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ReconnectRouteSelectionTests.swift @@ -666,6 +666,7 @@ import Testing #expect(store.pairedMacLoadState == .notLoaded) #expect(!store.hasKnownPairedMac) + #expect(store.tailscaleSetupStatus == .pairingRequired) #expect(store.tailscalePairingRequired) } @@ -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) } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift index 3c2929fe26d1..9c0cc6b924c1 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift @@ -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 @@ -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 @@ -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() @@ -479,6 +485,8 @@ struct CMUXMobileRootView: View { signOut: signOut, setupHelpHighlight: disconnectedSetupHelpHighlight, store: store, + showsTailscalePairingBanner: tailscaleSetupPrompt.showsBanner, + dismissTailscalePairingBanner: dismissTailscalePairingBanner, showSettings: showSettings, setupHelpPresentation: childSheetPresentation( for: .disconnectedSetupHelp @@ -499,6 +507,8 @@ struct CMUXMobileRootView: View { signOut: signOut, showAddDevice: addComputerAction, showPairingScanner: pairingScannerAction, + showsTailscalePairingBanner: tailscaleSetupPrompt.showsBanner, + dismissTailscalePairingBanner: dismissTailscalePairingBanner, showSettings: showSettings, showComputers: showComputers, taskComposerPresentation: childSheetPresentation( @@ -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 { @@ -582,7 +596,7 @@ struct CMUXMobileRootView: View { }, setUpTailscale: { handleRootPresentation(.setUpTailscale( - hasUsableAuthorization: store.hasUsableTailscaleAuthorization + status: store.tailscaleSetupStatus )) }, showsLayoutProbe: showsAutoConnectMigrationLayoutProbe @@ -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() @@ -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 } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DisconnectedWorkspaceShellView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DisconnectedWorkspaceShellView.swift index a5512e219e1d..758a5596ac10 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DisconnectedWorkspaceShellView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DisconnectedWorkspaceShellView.swift @@ -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() @@ -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 ) } } @@ -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 } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileRootPresentationState.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileRootPresentationState.swift index 4f3d289ec57e..42d773f5f359 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileRootPresentationState.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileRootPresentationState.swift @@ -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 @@ -73,7 +73,7 @@ struct MobileRootPresentationState: Equatable { case none case acknowledgeAutoConnectMigration case useAutoConnect - case setUpTailscale + case setUpTailscale(requiresPairing: Bool) case finishPairing case retryAutoConnectMigration } @@ -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 } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileTailscalePairingRequiredBanner.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileTailscalePairingRequiredBanner.swift index 5f69bf1f257c..bfc043fcd514 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileTailscalePairingRequiredBanner.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileTailscalePairingRequiredBanner.swift @@ -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) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileTailscaleSetupPromptState.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileTailscaleSetupPromptState.swift new file mode 100644 index 000000000000..0c882906e840 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileTailscaleSetupPromptState.swift @@ -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 + } + } + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift index 37fdc215f29b..98fcce5edc98 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift @@ -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)? @@ -507,7 +510,8 @@ struct WorkspaceListView: View { if connectionChrome == .tailscalePairingRequired, let showPairingScanner { MobileTailscalePairingRequiredBanner( - scanPairingCode: showPairingScanner + scanPairingCode: showPairingScanner, + dismiss: dismissTailscalePairingBanner ) } } @@ -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) @@ -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 ) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellHost.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellHost.swift index dd716648d294..faf8834d9136 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellHost.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellHost.swift @@ -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() @@ -39,6 +41,8 @@ struct WorkspaceShellHost: View { retryInitialConnection: retry, showAddDevice: showAddDevice, showPairingScanner: showPairingScanner, + showsTailscalePairingBanner: showsTailscalePairingBanner, + dismissTailscalePairingBanner: dismissTailscalePairingBanner, showSettings: showSettings, showComputers: showComputers, taskComposerPresentation: taskComposerPresentation diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift index 868da95b6ac8..6033cf186253 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift @@ -160,6 +160,9 @@ struct WorkspaceShellView: View { /// hides the add affordance. var showAddDevice: (() -> Void)? var showPairingScanner: (() -> Void)? + /// Whether the root setup-prompt coordinator currently presents its banner. + var showsTailscalePairingBanner = false + var dismissTailscalePairingBanner: () -> Void = {} var showSettings: () -> Void = {} var showComputers: () -> Void = {} var taskComposerPresentation = MobileChildSheetPresentation() @@ -663,7 +666,9 @@ struct WorkspaceShellView: View { cancelMacSwitch: cancelMacSwitchFromWorkspacePicker, refresh: refreshWorkspacesClosure, signOut: signOut, - reconnect: store.tailscalePairingRequired ? nil : reconnectClosure, + reconnect: showsTailscalePairingBanner ? nil : reconnectClosure, + showsTailscalePairingBanner: showsTailscalePairingBanner, + dismissTailscalePairingBanner: dismissTailscalePairingBanner, showAddDevice: showAddDevice, showComputers: showComputers, showPairingScanner: showPairingScanner, @@ -700,7 +705,7 @@ struct WorkspaceShellView: View { pendingSelection: rootToolbarPendingSelection, select: handleRootToolbarSelection, showAddDevice: showAddDevice, - reconnect: store.tailscalePairingRequired ? nil : reconnectClosure + reconnect: showsTailscalePairingBanner ? nil : reconnectClosure ) } @@ -715,7 +720,7 @@ struct WorkspaceShellView: View { connectionRecoveryFailed: store.connectionRecoveryFailed, isRecoveringConnection: store.isRecoveringConnection, connectionStatus: listConnectionStatus, - tailscalePairingRequired: store.tailscalePairingRequired, + tailscalePairingRequired: showsTailscalePairingBanner, isInitialConnectionLoading: isInitialConnectionLoading, initialConnectionTimedOut: initialConnectionTimedOut ).statusLine diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileRootPresentationStateTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileRootPresentationStateTests.swift index c9d5f866d499..694ede34bfb5 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileRootPresentationStateTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileRootPresentationStateTests.swift @@ -1,4 +1,5 @@ import Testing +import CmuxMobileShell @testable import CmuxMobileShellUI @@ -21,7 +22,8 @@ struct MobileRootPresentationStateTests { let scanner = PairingPresentation.scanner(entry: .autoConnectMigration) #expect( - state.apply(.setUpTailscale(hasUsableAuthorization: false)) == .setUpTailscale + state.apply(.setUpTailscale(status: .pairingRequired)) + == .setUpTailscale(requiresPairing: true) ) #expect(state.presentation == .pairing(scanner)) #expect(state.isRootSheetPresented) @@ -32,11 +34,55 @@ struct MobileRootPresentationStateTests { state.apply(.presentAutoConnectMigrationIfIdle) #expect( - state.apply(.setUpTailscale(hasUsableAuthorization: true)) == .setUpTailscale + state.apply(.setUpTailscale(status: .authorized)) + == .setUpTailscale(requiresPairing: false) ) #expect(state.isIdle) } + @Test func introductionWaitsForLoadingTailscaleAuthorizationBeforePairing() { + var state = MobileRootPresentationState() + state.apply(.presentAutoConnectMigrationIfIdle) + + #expect( + state.apply(.setUpTailscale(status: .loadingAuthorization)) + == .setUpTailscale(requiresPairing: false) + ) + #expect(state.isIdle) + } + + @Test func tailscalePromptLatchesMigrationRequirementAcrossShellLoading() { + var state = MobileTailscaleSetupPromptState() + + state.apply(.selectedTailscale(requiresPairing: true)) + #expect(state.showsBanner) + + state.apply(.shellStatusChanged(.loadingAuthorization)) + #expect(state.showsBanner) + + state.apply(.dismiss) + #expect(!state.showsBanner) + #expect(state.presentation == .dismissed) + + state.apply(.shellStatusChanged(.pairingRequired)) + #expect(!state.showsBanner) + #expect(state.presentation == .dismissed) + } + + @Test func tailscalePromptFollowsDurableReadinessAcrossLaunches() { + var state = MobileTailscaleSetupPromptState() + + state.apply(.shellStatusChanged(.loadingAuthorization)) + #expect(!state.showsBanner) + + state.apply(.shellStatusChanged(.pairingRequired)) + #expect(state.showsBanner) + + state.apply(.shellStatusChanged(.authorized)) + #expect(!state.showsBanner) + #expect(state.presentation == .followsShell) + } + @Test func interactiveIntroductionDismissalRequestsAcknowledgement() { var state = MobileRootPresentationState() state.apply(.presentAutoConnectMigrationIfIdle) diff --git a/ios/cmuxUITests/cmuxUITests.swift b/ios/cmuxUITests/cmuxUITests.swift index 0b7159f34565..9bad11374f09 100644 --- a/ios/cmuxUITests/cmuxUITests.swift +++ b/ios/cmuxUITests/cmuxUITests.swift @@ -580,6 +580,13 @@ final class cmuxUITests: XCTestCase { ] XCTAssertTrue(pairingRequiredBanner.waitForExistence(timeout: 4)) XCTAssertTrue(app.buttons["MobileTailscalePairingRequiredScan"].isHittable) + let dismissPairingRequiredBanner = app.buttons[ + "MobileTailscalePairingRequiredDismiss" + ] + XCTAssertTrue(dismissPairingRequiredBanner.waitForExistence(timeout: 4)) + XCTAssertTrue(dismissPairingRequiredBanner.isHittable) + dismissPairingRequiredBanner.tap() + XCTAssertTrue(pairingRequiredBanner.waitForNonExistence(timeout: 4)) app.terminate() let relaunched = launchApp(mockData: true, environment: environment)