diff --git a/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileOnboardingStore.swift b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileOnboardingStore.swift new file mode 100644 index 000000000000..05d9801f3306 --- /dev/null +++ b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileOnboardingStore.swift @@ -0,0 +1,67 @@ +public import Foundation + +/// Tracks whether the user has seen the first-run onboarding, persisted in an +/// injected `UserDefaults`. +/// +/// The onboarding explains what cmux is and how the phone pairs to a Mac. It is +/// presented post-authentication, in front of the never-paired add-device state, +/// and must show once per install and never reappear. The seen flag is read +/// synchronously at construction time (mirroring `MobileClientIDRepository` / +/// `MobileDisplaySettings`): the root view reads it before deciding what to +/// mount, which avoids a flash of the add-device screen ahead of onboarding on +/// first launch. +/// +/// The backing `UserDefaults` is injected so the store is testable without +/// touching `.standard`; the app constructs it at the composition root with +/// `UserDefaults.standard`. +/// +/// `forceSeen` lets the caller treat onboarding as already seen regardless of +/// what is persisted. The mobile app passes the UI-test / dogfood bypass through +/// this so the XCUITest harness and the dev-launch auto-pair path are not wedged +/// behind a manual tap-through (the bypass decision itself lives in the UI layer, +/// which can read `UITestConfig`; this type stays dependency-light). +/// +/// ```swift +/// let store = MobileOnboardingStore(defaults: .standard, forceSeen: false) +/// if !store.hasSeenOnboarding { /* present onboarding */ } +/// store.markSeen() +/// ``` +public struct MobileOnboardingStore: Sendable { + /// The defaults key under which the seen flag is stored. + public static let defaultsKey = "dev.cmux.mobile.onboarding.seen.v1" + + // UserDefaults is Apple-documented thread-safe; OK to hold nonisolated. + private nonisolated(unsafe) let defaults: UserDefaults + private let forceSeen: Bool + + /// Create a store backed by the given defaults. + /// - Parameters: + /// - defaults: The persistence store for the seen flag. Inject a + /// suite-scoped `UserDefaults` in tests. + /// - forceSeen: When `true`, ``hasSeenOnboarding`` always returns `true` + /// and ``markSeen()`` is a no-op, so onboarding never presents. The app + /// passes the UI-test / dogfood bypass here. + public init(defaults: UserDefaults, forceSeen: Bool = false) { + self.defaults = defaults + self.forceSeen = forceSeen + } + + /// Whether the first-run onboarding has already been shown on this install. + /// + /// Returns `true` when `forceSeen` is set (UI-test / dogfood bypass) or when + /// the seen flag is persisted. Read synchronously so the root view never + /// flashes a later screen before deciding to present onboarding. + public var hasSeenOnboarding: Bool { + if forceSeen { return true } + return defaults.bool(forKey: Self.defaultsKey) + } + + /// Persist that the user has finished (or skipped) onboarding. + /// + /// A no-op when `forceSeen` is set, so the bypass never writes through to the + /// real install's defaults. + public func markSeen() { + guard !forceSeen else { return } + defaults.set(true, forKey: Self.defaultsKey) + } +} diff --git a/Packages/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileOnboardingStoreTests.swift b/Packages/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileOnboardingStoreTests.swift new file mode 100644 index 000000000000..659d4736118b --- /dev/null +++ b/Packages/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileOnboardingStoreTests.swift @@ -0,0 +1,44 @@ +import Foundation +import Testing + +@testable import CmuxMobileShellModel + +/// Behavior tests for ``MobileOnboardingStore`` using a suite-scoped +/// `UserDefaults` so they never touch `UserDefaults.standard`. +@Suite struct MobileOnboardingStoreTests { + private func makeDefaults() -> UserDefaults { + let suite = "MobileOnboardingStoreTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return defaults + } + + @Test func startsUnseenAndPersistsSeen() { + let defaults = makeDefaults() + let store = MobileOnboardingStore(defaults: defaults) + #expect(!store.hasSeenOnboarding) + + store.markSeen() + #expect(store.hasSeenOnboarding) + #expect(defaults.bool(forKey: MobileOnboardingStore.defaultsKey)) + } + + @Test func readsAPreviouslyPersistedSeenFlag() { + let defaults = makeDefaults() + defaults.set(true, forKey: MobileOnboardingStore.defaultsKey) + let store = MobileOnboardingStore(defaults: defaults) + #expect(store.hasSeenOnboarding) + } + + /// `forceSeen` reports seen without reading or writing the backing defaults, + /// so the UI-test / dogfood bypass never wedges behind onboarding and never + /// pollutes the real install's persisted flag. + @Test func forceSeenReportsSeenWithoutPersisting() { + let defaults = makeDefaults() + let store = MobileOnboardingStore(defaults: defaults, forceSeen: true) + #expect(store.hasSeenOnboarding) + + store.markSeen() + #expect(!defaults.bool(forKey: MobileOnboardingStore.defaultsKey)) + } +} diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileAppView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileAppView.swift index ed1811519a85..6a1fd0eb3c30 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileAppView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileAppView.swift @@ -1,6 +1,7 @@ import CmuxMobileShell import SwiftUI #if os(iOS) +import CmuxMobileShellModel @preconcurrency import UIKit #elseif os(macOS) import AppKit @@ -8,12 +9,35 @@ import AppKit public struct CMUXMobileAppView: View { @State private var store: CMUXMobileShellStore + #if os(iOS) + private let onboardingStore: MobileOnboardingStore + #endif + #if os(iOS) + /// Creates the app view. + /// - Parameters: + /// - store: The shell store backing the workspace UI. + /// - onboardingStore: The first-run onboarding "seen" flag store. Defaults + /// to a `.standard`-backed store marked already-seen, so SwiftUI previews + /// and ad-hoc construction never present onboarding. + public init( + store: CMUXMobileShellStore = .preview(), + onboardingStore: MobileOnboardingStore = MobileOnboardingStore(defaults: .standard, forceSeen: true) + ) { + _store = State(initialValue: store) + self.onboardingStore = onboardingStore + } + #else public init(store: CMUXMobileShellStore = .preview()) { _store = State(initialValue: store) } + #endif public var body: some View { + #if os(iOS) + CMUXMobileRootView(store: store, onboardingStore: onboardingStore) + #else CMUXMobileRootView(store: store) + #endif } } diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift index e7de352f1fe8..f55d277b8587 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift @@ -1,6 +1,7 @@ import Foundation import CmuxAuthRuntime import CmuxMobileShell +import CmuxMobileShellModel import CmuxMobileSupport import CmuxMobileWorkspace import SwiftUI @@ -16,6 +17,15 @@ struct CMUXMobileRootView: View { @Environment(AuthCoordinator.self) private var authManager #if os(iOS) @Environment(MobilePushCoordinator.self) private var pushCoordinator + /// The persisted first-run onboarding "seen" flag store. The one-time + /// onboarding screen gates ahead of the never-paired add-device state. + private let onboardingStore: MobileOnboardingStore + /// Mirrors ``MobileOnboardingStore/hasSeenOnboarding`` so completing + /// onboarding (which calls `markSeen()` in the button action) re-renders the + /// root and falls through to the pairing flow. Seeded synchronously from the + /// store so the very first frame already reflects a prior install's state and + /// never flashes onboarding for a returning user. + @State private var hasSeenOnboarding: Bool #endif @State private var pendingAttachURL: String? @State private var didConsumeUITestAttachURL = false @@ -32,6 +42,18 @@ struct CMUXMobileRootView: View { /// Tailscale guidance. @Environment(\.tailscaleStatusMonitor) private var tailscaleStatusMonitor + #if os(iOS) + init(store: CMUXMobileShellStore, onboardingStore: MobileOnboardingStore) { + self.store = store + self.onboardingStore = onboardingStore + _hasSeenOnboarding = State(initialValue: onboardingStore.hasSeenOnboarding) + } + #else + init(store: CMUXMobileShellStore) { + self.store = store + } + #endif + private var shouldShowTerminalLayoutPreview: Bool { #if os(iOS) && DEBUG return UITestConfig.terminalLayoutPreviewEnabled @@ -139,6 +161,13 @@ struct CMUXMobileRootView: View { // yet know if there is a session to restore. MobilePairedMacDeterminingView() } + } else if shouldShowOnboarding { + // Placed after the reconnect-determining branch so `hasKnownPairedMac` + // has resolved: a genuine first run (never onboarded, never paired) + // sees the one-time explainer before the add-device flow; a returning + // paired-but-offline user (who can reach here after a failed + // reconnect) is excluded by the gate and falls through to pairing. + onboardingFlow } else if store.connectionState != .connected { DisconnectedWorkspaceShellView( hasKnownPairedMac: store.hasKnownPairedMac, @@ -173,6 +202,38 @@ struct CMUXMobileRootView: View { } } + /// Whether the one-time first-run onboarding should be presented. Always + /// `false` off iOS (onboarding is iOS-only). + private var shouldShowOnboarding: Bool { + #if os(iOS) + return MobileOnboardingGate.shouldShowOnboarding( + hasSeenOnboarding: hasSeenOnboarding, + hasKnownPairedMac: store.hasKnownPairedMac + ) + #else + return false + #endif + } + + @ViewBuilder + private var onboardingFlow: some View { + #if os(iOS) + OnboardingFlowView(onComplete: completeOnboarding) + #else + EmptyView() + #endif + } + + #if os(iOS) + /// Persists the onboarding "seen" flag and re-renders so the root falls + /// through to the pairing flow. Called from the onboarding button actions + /// (Skip / Get started), not a view-lifecycle callback. + private func completeOnboarding() { + onboardingStore.markSeen() + hasSeenOnboarding = true + } + #endif + private var isAuthenticated: Bool { MobileRootAuthGate.isAuthenticated( stackAuthenticated: authManager.isAuthenticated, diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift index f54c8a5db8cd..0c885b1b63e9 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift @@ -27,6 +27,7 @@ struct MobileSettingsView: View { /// directly in `body` would not re-render when it flips. @State private var notificationsEnabled = false @State private var showingHostPicker = false + @State private var showingOnboarding = false var body: some View { @Bindable var displaySettings = displaySettings @@ -98,6 +99,15 @@ struct MobileSettingsView: View { .accessibilityIdentifier("MobileSettingsRescanQR") } } + Button { + showingOnboarding = true + } label: { + Label( + L10n.string("mobile.settings.howPairingWorks", defaultValue: "How Pairing Works"), + systemImage: "questionmark.circle" + ) + } + .accessibilityIdentifier("MobileSettingsHowPairingWorks") } Section(L10n.string("mobile.settings.terminal", defaultValue: "Terminal")) { @@ -173,6 +183,11 @@ struct MobileSettingsView: View { MobileHostPickerView(store: store) } } + .sheet(isPresented: $showingOnboarding) { + // Re-entry from Settings: walk the explainer again. `onComplete` + // only dismisses; it never touches the persisted seen flag. + OnboardingFlowView { showingOnboarding = false } + } } .accessibilityIdentifier("MobileSettingsView") } diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingFlowView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingFlowView.swift new file mode 100644 index 000000000000..c486ece050ad --- /dev/null +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingFlowView.swift @@ -0,0 +1,107 @@ +#if os(iOS) +import CmuxMobileSupport +import SwiftUI + +/// First-run onboarding that explains what cmux is and how the phone connects to +/// a Mac, then hands off to the existing pairing flow. +/// +/// This view is deliberately pairing-ignorant. It owns no auth, store, or +/// pairing state: it walks the user through three explanatory pages and then +/// calls ``onComplete``. The caller decides what "complete" means: +/// +/// - First launch: presented post-authentication, in front of the never-paired +/// add-device state. The root view marks onboarding seen and falls through to +/// `DisconnectedWorkspaceShellView`, which already auto-presents `PairingView`, +/// so the "pair now" handoff is automatic and nothing here is duplicated. +/// - Settings ("How pairing works"): the entry just dismisses. +/// +/// The `onComplete` closure is the extensibility seam. A future "add your own +/// Linux/Mac servers" (Hive) path can branch the final CTA without changing this +/// view's body. +struct OnboardingFlowView: View { + /// Called when the user finishes the last page or skips. The caller marks the + /// flow seen (first launch) and/or dismisses the presentation. + let onComplete: () -> Void + + @State private var pageIndex = 0 + @Environment(\.analytics) private var analytics + + private let pages = OnboardingPage.allPages + + var body: some View { + VStack(spacing: 0) { + header + + TabView(selection: $pageIndex) { + ForEach(Array(pages.enumerated()), id: \.offset) { index, page in + OnboardingPageView(page: page) + .tag(index) + } + } + .tabViewStyle(.page(indexDisplayMode: .always)) + .indexViewStyle(.page(backgroundDisplayMode: .always)) + + footer + } + .background(PlatformPalette.systemBackground.ignoresSafeArea()) + .interactiveDismissDisabled() + .accessibilityIdentifier("MobileOnboardingFlow") + .onAppear { + analytics.capture("ios_onboarding_viewed", ["page": .int(0)]) + } + .onChange(of: pageIndex) { _, newValue in + analytics.capture("ios_onboarding_viewed", ["page": .int(newValue)]) + } + } + + private var header: some View { + HStack { + Spacer() + Button { + analytics.capture("ios_onboarding_skipped", ["page": .int(pageIndex)]) + onComplete() + } label: { + Text(L10n.string("mobile.onboarding.skip", defaultValue: "Skip")) + .font(.subheadline) + } + .accessibilityIdentifier("MobileOnboardingSkipButton") + } + .padding(.horizontal, 20) + .padding(.top, 12) + } + + private var footer: some View { + VStack(spacing: 12) { + Button { + advance() + } label: { + Text(isLastPage + ? L10n.string("mobile.onboarding.getStarted", defaultValue: "Get started") + : L10n.string("mobile.onboarding.next", defaultValue: "Next")) + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .contentShape(.capsule) + } + .mobileGlassProminentButton() + .accessibilityIdentifier("MobileOnboardingPrimaryButton") + } + .padding(.horizontal, 24) + .padding(.bottom, 16) + } + + private var isLastPage: Bool { + pageIndex >= pages.count - 1 + } + + private func advance() { + if isLastPage { + analytics.capture("ios_onboarding_completed", [:]) + onComplete() + return + } + withAnimation(.snappy(duration: 0.2)) { + pageIndex += 1 + } + } +} +#endif diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingPage.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingPage.swift new file mode 100644 index 000000000000..666425dade21 --- /dev/null +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingPage.swift @@ -0,0 +1,75 @@ +#if os(iOS) +import CmuxMobileSupport +import Foundation + +/// Value model for an onboarding page: an SF Symbol, a title, a body, and an +/// optional inline link (used by the Tailscale page to point at the install +/// page). Pure data so the page list is trivial to extend (e.g. a future Hive +/// "add your own servers" page). +struct OnboardingPage: Sendable { + let systemImage: String + let title: String + let body: String + let link: OnboardingPageLink? + + /// The ordered first-run pages: what cmux is, how it connects (Tailscale), + /// and how to pair. + static var allPages: [OnboardingPage] { + [whatItIs, howItConnects, pairNow] + } + + private static var whatItIs: OnboardingPage { + OnboardingPage( + systemImage: "terminal", + title: L10n.string( + "mobile.onboarding.whatTitle", + defaultValue: "Your Mac's terminals, on your phone" + ), + body: L10n.string( + "mobile.onboarding.whatBody", + defaultValue: "cmux runs your terminals and AI coding agents on your Mac. This app lets you watch them, type, and get notified when an agent needs you, right from your phone." + ), + link: nil + ) + } + + private static var howItConnects: OnboardingPage { + OnboardingPage( + systemImage: "lock.laptopcomputer", + title: L10n.string( + "mobile.onboarding.connectTitle", + defaultValue: "A private link to your Mac" + ), + body: L10n.string( + "mobile.onboarding.connectBody", + defaultValue: "Your phone connects straight to your Mac over Tailscale, with both signed in to the same cmux account. It is a direct, private connection to a computer you own, not a cloud relay. Put your Mac and phone on the same tailnet to pair." + ), + link: OnboardingPageLink( + title: L10n.string( + "mobile.onboarding.tailscaleLink", + defaultValue: "Set up Tailscale" + ), + // Tailscale download/install page. Pairing also works over a + // trusted LAN host, but Tailscale is the recommended private path + // (see PairingView's manual-host trust warning). + url: URL(string: "https://tailscale.com/download")! + ) + ) + } + + private static var pairNow: OnboardingPage { + OnboardingPage( + systemImage: "qrcode.viewfinder", + title: L10n.string( + "mobile.onboarding.pairTitle", + defaultValue: "Pair your Mac" + ), + body: L10n.string( + "mobile.onboarding.pairBody", + defaultValue: "Make sure cmux on your Mac is signed in to the same account, then scan the pairing QR code it shows (or enter its address by hand). You only do this once per Mac." + ), + link: nil + ) + } +} +#endif diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingPageLink.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingPageLink.swift new file mode 100644 index 000000000000..42c681ec1159 --- /dev/null +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingPageLink.swift @@ -0,0 +1,10 @@ +#if os(iOS) +import Foundation + +/// An optional inline link shown below an ``OnboardingPage`` body (used by the +/// Tailscale page to point at the install page). +struct OnboardingPageLink: Sendable { + let title: String + let url: URL +} +#endif diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingPageView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingPageView.swift new file mode 100644 index 000000000000..3229237dfaac --- /dev/null +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingPageView.swift @@ -0,0 +1,52 @@ +#if os(iOS) +import SwiftUI + +/// Renders a single ``OnboardingPage``: a centered SF Symbol, a title, a body, +/// and an optional inline link (used by the Tailscale page to point at the +/// install page). +struct OnboardingPageView: View { + let page: OnboardingPage + + var body: some View { + ScrollView { + VStack(spacing: 24) { + Spacer(minLength: 24) + + Image(systemName: page.systemImage) + .font(.system(size: 64, weight: .light)) + .foregroundStyle(.tint) + .symbolRenderingMode(.hierarchical) + .accessibilityHidden(true) + .padding(.bottom, 4) + + VStack(spacing: 14) { + Text(page.title) + .font(.title) + .fontWeight(.bold) + .multilineTextAlignment(.center) + + Text(page.body) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + + if let link = page.link { + Link(destination: link.url) { + Label(link.title, systemImage: "arrow.up.right.square") + .font(.callout.weight(.medium)) + } + .accessibilityIdentifier("MobileOnboardingLink") + } + + Spacer(minLength: 24) + } + .padding(.horizontal, 28) + .frame(maxWidth: 480) + .frame(maxWidth: .infinity) + } + .accessibilityElement(children: .contain) + } +} +#endif diff --git a/Packages/CmuxMobileWorkspace/Sources/CmuxMobileWorkspace/MobileOnboardingGate.swift b/Packages/CmuxMobileWorkspace/Sources/CmuxMobileWorkspace/MobileOnboardingGate.swift new file mode 100644 index 000000000000..9ec6e0a5901a --- /dev/null +++ b/Packages/CmuxMobileWorkspace/Sources/CmuxMobileWorkspace/MobileOnboardingGate.swift @@ -0,0 +1,37 @@ +/// Pure gating policy for the first-run onboarding screen in the mobile root scene. +/// +/// Onboarding is a one-time explainer (what cmux is, what it needs, how to pair) +/// shown post-authentication and *in front of* the never-paired add-device state. +/// The single decision — show it or fall through to pairing — combines the +/// persisted "seen" flag with whether this install has a known paired Mac, so the +/// gate is a pure function the root scene can branch on and tests can cover +/// without a store. It mirrors ``MobileRootAuthGate``. +public struct MobileOnboardingGate { + private init() {} + + /// Whether the first-run onboarding should be presented. + /// + /// Onboarding shows only for a genuine first run: the user has not seen it + /// *and* this install has never paired a Mac. The paired check excludes a + /// returning, paired-but-offline user — who can reach this branch after a + /// failed stored-Mac reconnect — from being interrupted by onboarding even + /// when their seen flag is still `false` (an older install updating into the + /// build that introduced the flag). + /// + /// The caller places this branch *after* the stored-Mac reconnect-determining + /// branch, so ``hasKnownPairedMac`` has resolved (the determining spinner + /// absorbs the resolution window) and is authoritative here. + /// + /// - Parameters: + /// - hasSeenOnboarding: Whether onboarding has already been shown (or + /// bypassed for UI tests / dogfood) on this install. + /// - hasKnownPairedMac: Whether this install has a known paired Mac. + /// - Returns: `true` only when onboarding has not been seen and no paired Mac + /// is known. + public static func shouldShowOnboarding( + hasSeenOnboarding: Bool, + hasKnownPairedMac: Bool + ) -> Bool { + !hasSeenOnboarding && !hasKnownPairedMac + } +} diff --git a/Packages/CmuxMobileWorkspace/Tests/CmuxMobileWorkspaceTests/MobileOnboardingGateTests.swift b/Packages/CmuxMobileWorkspace/Tests/CmuxMobileWorkspaceTests/MobileOnboardingGateTests.swift new file mode 100644 index 000000000000..435bdbb3be6b --- /dev/null +++ b/Packages/CmuxMobileWorkspace/Tests/CmuxMobileWorkspaceTests/MobileOnboardingGateTests.swift @@ -0,0 +1,40 @@ +import Testing + +@testable import CmuxMobileWorkspace + +@Suite struct MobileOnboardingGateTests { + /// The genuine first run: never onboarded and no paired Mac. Onboarding shows. + @Test func showsOnboardingForNeverOnboardedNeverPaired() { + #expect(MobileOnboardingGate.shouldShowOnboarding( + hasSeenOnboarding: false, + hasKnownPairedMac: false + )) + } + + /// A returning, paired-but-offline user (reachable after a failed stored-Mac + /// reconnect) must not be interrupted by onboarding, even if the seen flag is + /// still `false` because they updated from a build that predates the flag. + @Test func skipsOnboardingForNeverOnboardedButPaired() { + #expect(!MobileOnboardingGate.shouldShowOnboarding( + hasSeenOnboarding: false, + hasKnownPairedMac: true + )) + } + + /// Already onboarded and not yet paired: onboarding was seen, fall through to + /// the add-device / pairing flow without showing it again. + @Test func skipsOnboardingForOnboardedNeverPaired() { + #expect(!MobileOnboardingGate.shouldShowOnboarding( + hasSeenOnboarding: true, + hasKnownPairedMac: false + )) + } + + /// Onboarded and paired: never show onboarding. + @Test func skipsOnboardingForOnboardedAndPaired() { + #expect(!MobileOnboardingGate.shouldShowOnboarding( + hasSeenOnboarding: true, + hasKnownPairedMac: true + )) + } +} diff --git a/ios/cmux/AppCompositionRoot.swift b/ios/cmux/AppCompositionRoot.swift index 8fdc2f937da1..488f1e13bdef 100644 --- a/ios/cmux/AppCompositionRoot.swift +++ b/ios/cmux/AppCompositionRoot.swift @@ -1,5 +1,7 @@ import CMUXMobileCore import CmuxMobileAnalytics +import CmuxMobileShellModel +import CmuxMobileSupport import CmuxMobileTransport import Foundation import SwiftUI @@ -23,6 +25,11 @@ final class AppCompositionRoot { let pushCoordinator: MobilePushCoordinator let analytics: MobileAnalyticsComposition let displaySettings: MobileDisplaySettings + /// First-run onboarding "seen" flag, persisted to `UserDefaults.standard`. + /// Built with `forceSeen` set when a UI-test mock harness or a dogfood + /// auto-pair attach URL is active, so neither path is wedged behind the + /// one-time onboarding screen. + let onboardingStore: MobileOnboardingStore /// The process-wide tailnet detector behind the shell UI's read-only /// observing port, injected down so pairing and disconnected surfaces can /// explain a Tailscale-off phone. @@ -54,6 +61,18 @@ final class AppCompositionRoot { analytics: analytics.emitter ) self.displaySettings = MobileDisplaySettings() + // Skip the one-time onboarding when a UI-test mock harness + // (`CMUX_UITEST_MOCK_DATA`/XCUITest) or a dogfood auto-pair attach URL is + // active: those launches expect to land on sign-in / add-device / a live + // workspace, not behind a manual tap-through. `forceSeen` never writes the + // real install's persisted flag. + let bypassOnboarding = UITestConfig.mockDataEnabled + || UITestConfig.dogfoodAttachURL != nil + || UITestConfig.attachURL != nil + self.onboardingStore = MobileOnboardingStore( + defaults: .standard, + forceSeen: bypassOnboarding + ) self.tailscaleStatusMonitor = TailscaleStatusMonitorAdapter(monitor: TailscaleStatusMonitor()) #if DEBUG self.diagnosticLog = DiagnosticLog(buildStamp: MobileDebugLog.buildStamp) diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index f32a9acd8a31..ad505183c01d 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -3944,6 +3944,193 @@ } } } + }, + "mobile.onboarding.skip": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Skip" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキップ" + } + } + } + }, + "mobile.onboarding.next": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Next" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "次へ" + } + } + } + }, + "mobile.onboarding.getStarted": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Get started" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "始める" + } + } + } + }, + "mobile.onboarding.whatTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your Mac's terminals, on your phone" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Macのターミナルを、iPhoneで" + } + } + } + }, + "mobile.onboarding.whatBody": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux runs your terminals and AI coding agents on your Mac. This app lets you watch them, type, and get notified when an agent needs you, right from your phone." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmuxはMac上でターミナルとAIコーディングエージェントを動かします。このアプリを使えば、iPhoneからその様子を見たり、入力したり、エージェントから呼ばれたときに通知を受け取れます。" + } + } + } + }, + "mobile.onboarding.connectTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A private link to your Mac" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Macへのプライベートな接続" + } + } + } + }, + "mobile.onboarding.connectBody": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your phone connects straight to your Mac over Tailscale, with both signed in to the same cmux account. It is a direct, private connection to a computer you own, not a cloud relay. Put your Mac and phone on the same tailnet to pair." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "iPhoneはTailscale経由でMacに直接つながります。両方を同じcmuxアカウントでサインインしてください。クラウド中継ではなく、あなた自身のコンピュータへの直接でプライベートな接続です。ペアリングするには、Macとこの端末を同じtailnetに参加させてください。" + } + } + } + }, + "mobile.onboarding.tailscaleLink": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Set up Tailscale" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Tailscaleを設定" + } + } + } + }, + "mobile.onboarding.pairTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pair your Mac" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Macとペアリング" + } + } + } + }, + "mobile.onboarding.pairBody": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Make sure cmux on your Mac is signed in to the same account, then scan the pairing QR code it shows (or enter its address by hand). You only do this once per Mac." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Mac版cmuxが同じアカウントでサインインしていることを確認し、表示されるペアリングQRコードを読み取ってください(またはアドレスを手入力します)。これはMacごとに一度だけ行います。" + } + } + } + }, + "mobile.settings.howPairingWorks": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "How Pairing Works" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ペアリングの仕組み" + } + } + } } }, "version": "1.0" diff --git a/ios/cmux/cmuxApp.swift b/ios/cmux/cmuxApp.swift index 3ac195d5f003..e3adbf40adfd 100644 --- a/ios/cmux/cmuxApp.swift +++ b/ios/cmux/cmuxApp.swift @@ -74,6 +74,7 @@ struct cmuxApp: App { analytics: Self.root.analytics.emitter, pushCoordinator: Self.root.pushCoordinator, displaySettings: Self.root.displaySettings, + onboardingStore: Self.root.onboardingStore, tailscaleStatusMonitor: Self.root.tailscaleStatusMonitor, diagnosticLog: Self.root.diagnosticLog ) @@ -85,6 +86,7 @@ struct cmuxApp: App { analytics: Self.root.analytics.emitter, pushCoordinator: Self.root.pushCoordinator, displaySettings: Self.root.displaySettings, + onboardingStore: Self.root.onboardingStore, tailscaleStatusMonitor: Self.root.tailscaleStatusMonitor ) #endif diff --git a/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift b/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift index dd78f23f798d..2df7a781ab82 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift @@ -3,6 +3,7 @@ import CmuxAuthRuntime import CmuxMobileAnalytics import CmuxMobilePairedMac import CmuxMobileShell +import CmuxMobileShellModel @_exported import CmuxMobileShellUI import CmuxMobileTransport import Foundation @@ -34,6 +35,10 @@ public struct CMUXMobileRootScene: View { #if os(iOS) private let pushCoordinator: MobilePushCoordinator private let displaySettings: MobileDisplaySettings + /// The first-run onboarding "seen" flag store, injected into the root view so + /// it gates the one-time onboarding screen ahead of the never-paired + /// add-device state. + private let onboardingStore: MobileOnboardingStore #endif /// The app-root tailnet detector (behind the shell UI's read-only /// observing port), injected into the environment so pairing and @@ -60,6 +65,8 @@ public struct CMUXMobileRootScene: View { /// delegate) injected into the environment. /// - displaySettings: The app-root mobile display settings injected into /// the environment (drives workspace-title wrapping). + /// - onboardingStore: The app-root first-run onboarding "seen" flag store, + /// injected into the root view to gate the one-time onboarding screen. /// - tailscaleStatusMonitor: The app-root tailnet detector, injected into /// the environment for the pairing and disconnected surfaces. /// - diagnosticLog: The structured diagnostic log (DEBUG builds only), @@ -71,6 +78,7 @@ public struct CMUXMobileRootScene: View { analytics: any AnalyticsEmitting, pushCoordinator: MobilePushCoordinator, displaySettings: MobileDisplaySettings, + onboardingStore: MobileOnboardingStore, tailscaleStatusMonitor: any TailscaleStatusObserving, diagnosticLog: DiagnosticLog? = nil ) { @@ -80,6 +88,7 @@ public struct CMUXMobileRootScene: View { self.analytics = analytics self.pushCoordinator = pushCoordinator self.displaySettings = displaySettings + self.onboardingStore = onboardingStore self.tailscaleStatusMonitor = tailscaleStatusMonitor self.pairedMacStore = Self.openPairedMacStore() #if DEBUG @@ -152,13 +161,17 @@ public struct CMUXMobileRootScene: View { @ViewBuilder private var content: some View { - #if canImport(UIKit) && DEBUG + #if os(iOS) + #if DEBUG if ProcessInfo.processInfo.environment["CMUX_ZOOM_STRESS"] == "1" { MobileZoomStressView() } else { - CMUXMobileAppView(store: makeStore()) + CMUXMobileAppView(store: makeStore(), onboardingStore: onboardingStore) } #else + CMUXMobileAppView(store: makeStore(), onboardingStore: onboardingStore) + #endif + #else CMUXMobileAppView(store: makeStore()) #endif }