diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 7e5df3fe21db..70cde9f43f64 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -582,6 +582,15 @@ jobs: if-no-files-found: warn retention-days: 30 + - name: Upload test results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: test-results + path: ${{ env.CMUX_DERIVED_DATA_PATH }}/Logs/Test/*.xcresult + if-no-files-found: warn + retention-days: 7 + - name: Publish test summary if: always() env: diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticTaxonomy.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticTaxonomy.swift index 1c614be9a671..f1729b9f275b 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticTaxonomy.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticTaxonomy.swift @@ -846,6 +846,14 @@ public enum DiagnosticAppEventKind: Int, Sendable, Codable, CaseIterable { /// The transport that actually carries the foreground connection, recorded /// on connect and on every active-route change. `c`: ``DiagnosticTransportKind``. case foregroundTransportSelected = 662 + /// Whether a DEBUG launch supplied an injected dogfood attach route. + /// `c`: boolean. + case dogfoodAttachEnvironmentObserved = 663 + /// Whether authentication bootstrap completed with an authenticated user. + /// `c`: boolean. + case authBootstrapCompleted = 664 + /// A DEBUG launch attach route was admitted by the startup coordinator. + case dogfoodAttachStarted = 665 } /// The user's configured connection method, mirrored from the settings picker diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift index 2817eb1f6c90..fd99d28d6b24 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift @@ -171,6 +171,14 @@ struct CMUXMobileRootView: View { #endif } + private var shouldShowWhatsNewPreview: Bool { + #if os(iOS) && DEBUG + return UITestConfig.whatsNewPreviewEnabled + #else + return false + #endif + } + private var shouldShowOnboardingPreview: Bool { #if os(iOS) && DEBUG return UITestConfig.onboardingPreviewEnabled @@ -249,6 +257,14 @@ struct CMUXMobileRootView: View { #endif } + @ViewBuilder private var whatsNewPreview: some View { + #if os(iOS) && DEBUG + MobileWhatsNewPreviewView() + #else + EmptyView() + #endif + } + var body: some View { rootContent #if os(iOS) @@ -276,6 +292,12 @@ struct CMUXMobileRootView: View { .animation(.snappy(duration: 0.18), value: store.phase) .onAppear { syncShellAuthentication(isAuthenticated) + #if os(iOS) + diagnosticLog?.recordAppEvent( + .dogfoodAttachEnvironmentObserved, + count: hasInjectedAttachLaunchRoute ? 1 : 0 + ) + #endif store.resumeForegroundRefresh() #if os(iOS) pushCoordinator.bind(store: store) @@ -495,6 +517,8 @@ struct CMUXMobileRootView: View { macSurfaceGalleryPreview } else if shouldShowHiddenComputersPreview { hiddenComputersPreview + } else if shouldShowWhatsNewPreview { + whatsNewPreview } else if shouldShowOnboardingPreview { onboardingPreview } else if shouldShowOnboarding { @@ -1105,6 +1129,10 @@ struct CMUXMobileRootView: View { #endif await authManager.awaitBootstrapped() guard !Task.isCancelled else { return } + diagnosticLog?.recordAppEvent( + .authBootstrapCompleted, + count: authManager.isAuthenticated ? 1 : 0 + ) if authManager.isAuthenticated { guard prepareResolvedAccountScope() != nil else { return } } @@ -1420,6 +1448,7 @@ struct CMUXMobileRootView: View { let attachURL = UITestConfig.dogfoodAttachURL ?? UITestConfig.attachURL else { return false } + diagnosticLog?.recordAppEvent(.dogfoodAttachStarted) return startupConnectionCoordinator.startInjectedAttach( attachURL: attachURL, prepare: { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobileWhatsNewDebugView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobileWhatsNewDebugView.swift new file mode 100644 index 000000000000..4ea084a62c6d --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobileWhatsNewDebugView.swift @@ -0,0 +1,101 @@ +#if os(iOS) && DEBUG +import CmuxMobileSupport +import Foundation +import SwiftUI + +/// A frozen range keeps a catalog refresh from changing an open replay. +struct MobileWhatsNewReplay: Identifiable { + let id = UUID() + let pages: [MobileWhatsNewPage] + + init?(pages: [MobileWhatsNewPage], firstID: String, lastID: String) { + guard let first = pages.firstIndex(where: { $0.listID == firstID }), + let last = pages.firstIndex(where: { $0.listID == lastID }) else { return nil } + self.pages = Array(pages[min(first, last)...max(first, last)]) + } +} + +struct MobileWhatsNewDebugView: View { + let pages: [MobileWhatsNewPage] + let allowedWebHosts: Set + @State private var firstID: String + @State private var lastID: String + @State private var replay: MobileWhatsNewReplay? + + init(pages: [MobileWhatsNewPage], allowedWebHosts: Set) { + self.pages = pages + self.allowedWebHosts = allowedWebHosts + _firstID = State(initialValue: pages.first?.listID ?? "") + _lastID = State(initialValue: pages.last?.listID ?? "") + } + + private var selection: MobileWhatsNewReplay? { + MobileWhatsNewReplay(pages: pages, firstID: firstID, lastID: lastID) + } + + var body: some View { + Form { + if pages.isEmpty { + Text(L10n.string("mobile.whatsNew.debug.empty", defaultValue: "No updates available")) + } else { + Section { + Picker( + L10n.string("mobile.whatsNew.debug.first", defaultValue: "First Update"), + selection: $firstID + ) { + ForEach(pages, id: \.listID) { page in + Text(page.id).tag(page.listID) + } + } + .accessibilityIdentifier("MobileWhatsNewReplayFirst") + + Picker( + L10n.string("mobile.whatsNew.debug.last", defaultValue: "Last Update"), + selection: $lastID + ) { + ForEach(pages, id: \.listID) { page in + Text(page.id).tag(page.listID) + } + } + .accessibilityIdentifier("MobileWhatsNewReplayLast") + } + .pickerStyle(.menu) + + Section { + ForEach(selection?.pages ?? [], id: \.listID) { page in + VStack(alignment: .leading, spacing: 4) { + Text(page.title) + Text(page.id) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + Section { + Button { + replay = selection + } label: { + Label( + L10n.string("mobile.whatsNew.debug.show", defaultValue: "Show Sheets"), + systemImage: "play.rectangle" + ) + } + .disabled(selection == nil) + .accessibilityIdentifier("MobileWhatsNewReplayShow") + } + } + } + .navigationTitle(L10n.string("mobile.whatsNew.debug.title", defaultValue: "Replay What's New")) + .navigationBarTitleDisplayMode(.inline) + .accessibilityIdentifier("MobileWhatsNewDebugView") + .sheet(item: $replay) { replay in + MobileWhatsNewSheet( + pages: replay.pages, + allowedWebHosts: allowedWebHosts, + dismiss: { self.replay = nil } + ) + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobileWhatsNewPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobileWhatsNewPreviewView.swift new file mode 100644 index 000000000000..1b4b68133567 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobileWhatsNewPreviewView.swift @@ -0,0 +1,37 @@ +#if os(iOS) && DEBUG +import SwiftUI +import Foundation + +/// Deterministic What's New host for screenshot checks. It renders the same +/// native sheet view the app presents after update, but does not require a +/// signed-in or paired app session. +public struct MobileWhatsNewPreviewView: View { + @State private var showsSheet = true + @State private var center = MobileWhatsNewCenter( + apiBaseURL: "https://cmux.test", + buildType: .beta, + defaults: UserDefaults(suiteName: "MobileWhatsNewPreview-\(UUID().uuidString)")!, + loader: { _ in throw URLError(.notConnectedToInternet) } + ) + + public init() {} + + public var body: some View { + NavigationStack { + MobileWhatsNewListView() + } + .environment(center) + .sheet(isPresented: $showsSheet) { + MobileWhatsNewSheet( + pages: MobileWhatsNewCatalog.entries, + allowedWebHosts: [], + dismiss: { showsSheet = false } + ) + .preferredColorScheme( + ProcessInfo.processInfo.environment["CMUX_UITEST_WHATS_NEW_APPEARANCE"] == "dark" + ? .dark : .light + ) + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift index a0156c735918..c315e0242e50 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift @@ -203,7 +203,7 @@ struct DeviceTreeView: View { if connectionMethodStore?.method == .tailscale { return MobilePairingScannerSheet.emptyStateGuidanceText } - return showAddDevice != nil + let description = showAddDevice != nil ? L10n.string( "mobile.v2.connections.empty", defaultValue: "On your Mac, turn on Enable iOS pairing in cmux Settings. Select the same team on both devices and keep cmux running. Only Macs you own or have permission to connect to appear here." @@ -212,6 +212,7 @@ struct DeviceTreeView: View { "mobile.v2.devices.emptyDescription", defaultValue: "On your Mac, turn on Enable iOS pairing in cmux Settings. Select the same team on both devices and keep cmux running. Only Macs you own or have permission to connect to appear here." ) + return "\(description) \(MobilePairingCopy().emptyWorkspaceMessage)" } private func hideComputer(_ computer: MacComputerSnapshot) { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DisconnectedWorkspaceShellView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DisconnectedWorkspaceShellView.swift index 5fb849e2fb4f..69010d200435 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DisconnectedWorkspaceShellView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DisconnectedWorkspaceShellView.swift @@ -79,8 +79,8 @@ struct DisconnectedWorkspaceShellView: View { ToolbarItem(placement: .topBarLeading) { Button(action: showComputers) { MobileDevicesToolbarLabel( - gateWarningPairingIDs: store?.macVersionUpdateRequiredPairingIDs ?? [], - computerPairingIDs: savedComputerPairingIDs + gateWarningDeviceIDs: store?.macVersionUpdateRequiredDeviceIDs ?? [], + computerDeviceIDs: savedComputerDeviceIDs ) } .accessibilityLabel(L10n.string( @@ -164,8 +164,11 @@ struct DisconnectedWorkspaceShellView: View { /// The Computers sheet includes both shown and hidden rows, so both sets /// participate in the toolbar warning scope while this screen is open. - private var savedComputerPairingIDs: Set { - Set(savedComputers.map(\.id) + (store?.hiddenComputers.map(\.id) ?? [])) + private var savedComputerDeviceIDs: Set { + Set( + savedComputers.map(\.deviceId) + + (store?.hiddenComputers.map(\.macDeviceID) ?? []) + ) } @ViewBuilder @@ -290,9 +293,9 @@ struct DisconnectedWorkspaceShellView: View { } #endif return L10n.string( - "mobile.v2.devices.emptyDescription", - defaultValue: "On your Mac, turn on Enable iOS pairing in cmux Settings. Select the same team on both devices and keep cmux running. Only Macs you own or have permission to connect to appear here." - ) + "mobile.devices.emptyDescription", + defaultValue: "For Iroh to find a Mac, run cmux 0.64.20 or later on the Mac, sign in to cmux on both devices with the same account, and keep cmux running on the Mac while both devices are online. If any requirement is missing, the Mac will not appear automatically. To use Tailscale instead, open Settings, tap Connection Method, and choose Tailscale Only." + ) + " " + MobilePairingCopy().emptyWorkspaceMessage } /// Reconnect this row's computer. `switchToMac` promotes a live secondary @@ -390,8 +393,8 @@ struct DisconnectedWorkspaceShellView: View { Text( savedMacs.isEmpty ? L10n.string( - "mobile.v2.devices.emptyDescription", - defaultValue: "On your Mac, turn on Enable iOS pairing in cmux Settings. Select the same team on both devices and keep cmux running. Only Macs you own or have permission to connect to appear here." + "mobile.devices.emptyDescription", + defaultValue: "For Iroh to find a Mac, run cmux 0.64.20 or later on the Mac, sign in to cmux on both devices with the same account, and keep cmux running on the Mac while both devices are online. If any requirement is missing, the Mac will not appear automatically. To use Tailscale instead, open Settings, tap Connection Method, and choose Tailscale Only." ) : savedMacDescription ) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePairingCopy.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePairingCopy.swift new file mode 100644 index 000000000000..460455e34a91 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePairingCopy.swift @@ -0,0 +1,26 @@ +#if os(iOS) +import CmuxMobileSupport + +struct MobilePairingCopy { + var enableOnMacShort: String { + L10n.string( + "mobile.pairing.enableOnMac.short", + defaultValue: "Enable iOS pairing in cmux Settings > Mobile on your Mac." + ) + } + + var enableOnMac: String { + L10n.string( + "mobile.pairing.enableOnMac", + defaultValue: "Before pairing, open cmux Settings > Mobile on the Mac and turn on Enable iOS pairing. This Mac stays hidden from iOS while it is off." + ) + } + + var emptyWorkspaceMessage: String { + L10n.string( + "mobile.workspaces.empty.message", + defaultValue: "Enable iOS pairing in cmux Settings > Mobile on your Mac, sign in to the same cmux account on both devices, and keep cmux running. Your Mac and its workspaces will appear here after pairing is enabled." + ) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePairingScannerSheet.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePairingScannerSheet.swift index e54750f92afc..400088a9a1c2 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePairingScannerSheet.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePairingScannerSheet.swift @@ -230,7 +230,7 @@ extension MobilePairingScannerSheet { /// Tailscale setup guidance for an empty computer list, including the route back to Auto-Connect. static var emptyStateGuidanceText: String { - L10n.string( + let guidance = L10n.string( "mobile.tailscalePairing.emptyDescription", defaultValue: """ Install Tailscale on both devices and use the same Tailscale network. Open Tailscale \ @@ -238,5 +238,6 @@ extension MobilePairingScannerSheet { To use Auto-Connect instead, open Settings, tap Connection Method, and choose Auto-Connect. """ ) + return "\(guidance) \(MobilePairingCopy().emptyWorkspaceMessage)" } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift index 558536a74d0c..5b880661387a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift @@ -75,6 +75,10 @@ struct MobileSettingsView: View { var body: some View { @Bindable var displaySettings = displaySettings + #if DEBUG + let whatsNewPages = whatsNewCenter?.archivePages ?? MobileWhatsNewCatalog.channelVisibleEntries() + let whatsNewHosts = whatsNewCenter?.allowedWebHosts ?? [] + #endif return NavigationStack { Form { MobileSettingsAccountSection(signOut: signOut) @@ -273,6 +277,15 @@ struct MobileSettingsView: View { #if DEBUG Section(L10n.string("mobile.settings.developer", defaultValue: "Developer")) { + NavigationLink { + MobileWhatsNewDebugView(pages: whatsNewPages, allowedWebHosts: whatsNewHosts) + } label: { + Label( + L10n.string("mobile.whatsNew.debug.title", defaultValue: "Replay What's New"), + systemImage: "rectangle.stack" + ) + } + .accessibilityIdentifier("MobileSettingsReplayWhatsNew") Button { showingToastGallery = true } label: { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCatalog.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCatalog.swift index 63edabd54be1..eab74d379126 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCatalog.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCatalog.swift @@ -17,9 +17,15 @@ struct MobileWhatsNewFeature { /// binary, or a cmux-owned webpage for content pushed after release. enum MobileWhatsNewPageBody { case features([MobileWhatsNewFeature]) + case pairingSetup([MobileWhatsNewFeature]) case web(URL) } +struct MobileWhatsNewMacCompatibility: Equatable { + let stableVersion: String? + let nightlyVersion: String? +} + /// One What's New page: a binary catalog entry or a resolved remote /// announcement. `id` is the acknowledgement unit; for announcements it is /// the announcement id even when the body is borrowed from a referenced @@ -34,7 +40,7 @@ struct MobileWhatsNewPage: Identifiable { /// Remote announcements are visually marked to distinguish service news /// from binary release notes. let isAnnouncement: Bool -/// Build channels this catalog entry may render on + /// Build channels this catalog entry may render on /// (``MobileBuildType/token`` values). `nil` (the norm) means the /// ``MobileWhatsNewChannelPolicy`` default: team lanes only, never the /// official App Store app. The remote list can override per entry @@ -65,7 +71,7 @@ enum MobileWhatsNewCatalog { /// Newest first. The one-time sheet shows every visible entry newer than /// the acknowledgement marker. static var entries: [MobileWhatsNewPage] { - [connectionsUpdate] + [pairingOptInUpdate, connectionsUpdate] } static func entry(withID id: String) -> MobileWhatsNewPage? { @@ -92,7 +98,33 @@ enum MobileWhatsNewCatalog { /// positions in the FULL catalog so remotely hiding one entry cannot /// shift how other entries compare against the marker. static func index(ofID id: String) -> Int? { - entries.firstIndex { $0.id == id } + if let index = entries.firstIndex(where: { $0.id == id }) { + return index + } + // The first pairing announcement preceded connections.v2. Its marker + // sits between the current pairing page and the older connection page. + switch id { + case "pairing-opt-in.v1": + return 1 + default: + return nil + } + } + + static var pairingOptInUpdate: MobileWhatsNewPage { + MobileWhatsNewPage( + id: "connections.v2", + releaseLabel: L10n.string( + "mobile.pairingOptInUpdate.releaseLabel", + defaultValue: "1.0.4 · September 2026" + ), + title: L10n.string( + "mobile.whatsNew.pairing.pageTitle", + defaultValue: "Action Required: Enable iOS pairing on your Mac" + ), + body: .pairingSetup([]), + isAnnouncement: false + ) } static var connectionsUpdate: MobileWhatsNewPage { @@ -156,59 +188,60 @@ enum MobileWhatsNewCatalog { // The compat requirement is one compact notice under the feature // rows (owner feedback: the old full-width warning row read as // clutter, and BETA users need the revert path). - footnote: macUpdateFootnote() + footnote: macUpdateDetail( + buildType: .current(), + requiredVersion: macCompatibility( + policy: .baked, + iosVersion: AppVersionInfo.current().marketingVersion, + buildType: .current() + ).stableVersion + ) + ) + } + + static func macCompatibility( + policy: MobileMacCompatPolicy, + iosVersion: String, + buildType: MobileBuildType + ) -> MobileWhatsNewMacCompatibility { + guard let tier = policy.tier(forIOSVersion: iosVersion) else { + return .init(stableVersion: nil, nightlyVersion: nil) + } + let requirement = tier.buildKinds[buildType.token] + ?? .init(stableMinVersion: tier.stableMinVersion, nightly: tier.nightly) + let nightlyVersion = requirement.nightly.map { + "\($0.minBaseVersion.description)-nightly.\($0.minBuild)" + } + return .init( + stableVersion: requirement.stableMinVersion.description, + nightlyVersion: nightlyVersion ) } - /// The compat-notice footnote, gated per distribution channel. - /// - /// Team builds include the BETA TestFlight rollback recipe. The public - /// App Store app has no older protocol version to revert to, so it gets - /// the update requirement only; App Review's Guideline 2.2 rejection also - /// bars beta-lane vocabulary from its UI. - static func macUpdateFootnote( - buildType: MobileBuildType = .current(), - iosVersion: String? = nil, - policy: MobileMacCompatPolicy = .baked + static func macUpdateDetail( + buildType: MobileBuildType, + requiredVersion: String? ) -> String { - let version = iosVersion - ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String - ?? "0" - let tier = policy.tier(forIOSVersion: version) - let requirement = tier?.buildKinds[buildType.token] - ?? tier.map { MobileMacCompatPolicy.Requirement(stableMinVersion: $0.stableMinVersion, nightly: $0.nightly) } - let stableVersion = requirement?.stableMinVersion.description - ?? L10n.string( - "mobile.macUpdate.unknownStableMinimum", - defaultValue: "the current supported stable version" - ) - let nightlyVersion: String - if let nightly = requirement?.nightly { - nightlyVersion = "\(nightly.minBaseVersion)-nightly.\(nightly.minBuild)" - } else { - nightlyVersion = L10n.string( - "mobile.macUpdate.noNightlyMinimum", - defaultValue: "no minimum for this iOS build" + let version = requiredVersion ?? L10n.string( + "mobile.connectionsUpdate.macUpdate.requiredVersion", + defaultValue: "the latest cmux NIGHTLY or cmux RELEASE" + ) + if buildType.usesInternalBuildVocabulary { + return String( + format: L10n.string( + "mobile.connectionsUpdate.macUpdate.detail", + defaultValue: "Use cmux %@ or later. Older Macs: use BETA 1.0.4 (20260817224846)." + ), + version ) } - let requirementText = String( + return String( format: L10n.string( - "mobile.macUpdate.requiredStableAndNightlyFormat", - defaultValue: "Requires cmux %@ or later on stable Macs. cmux NIGHTLY minimum: %@." + "mobile.connectionsUpdate.macUpdate.detail.official", + defaultValue: "Use cmux %@ or later on your Mac before connecting." ), - stableVersion, - nightlyVersion + version ) - guard buildType.usesInternalBuildVocabulary else { - return requirementText - } - return [ - requirementText, - L10n.string( - "mobile.macUpdate.revertShort", - defaultValue: "Not ready? Stay on (or revert to) cmux BETA 1.0.4 (20260817224846)." - ), - ].joined(separator: " ") } } #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCenter.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCenter.swift index e40df68ad05c..13309d40b7ef 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCenter.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewCenter.swift @@ -1,5 +1,4 @@ #if os(iOS) -import CmuxMobileShell import CmuxMobileShellModel import Foundation import Observation @@ -12,7 +11,10 @@ import Observation /// Visibility policy (user-approved): the remote list is truth; the last /// fetched list is cached on device and wins while offline; a device that /// has NEVER fetched the list shows the binary entries (fail-open to binary -/// truth, because remote hiding is the exceptional operation). +/// truth, because remote hiding is the exceptional operation). During a +/// rollout, a nonempty list containing only retired ids is treated as stale +/// and falls back to current native entries; an explicit empty list still +/// hides binary pages. /// /// Acknowledgement: binary pages advance a single "newest acknowledged entry /// id" marker over the ordered catalog, so a user who skipped several @@ -28,6 +30,9 @@ public final class MobileWhatsNewCenter { static let acknowledgedAnnouncementsKey = "dev.cmux.mobile.whatsNew.acknowledgedAnnouncementIds" static let cacheKey = "dev.cmux.mobile.whatsNew.remoteList.v1" static let requestPath = "/api/whats-new" + /// The pairing requirement is part of the client contract, so an older + /// cached visibility list must not hide it from team builds. + private static let requiredBinaryEntryIDs: Set = ["connections.v2"] private let requestURL: URL? private let appVersion: String @@ -46,10 +51,6 @@ public final class MobileWhatsNewCenter { /// that gates web-content pages into the one-time sheet, so an offline /// launch skips them instead of presenting an unloadable webview. private(set) var lastRefreshSucceeded = false - /// The policy currently enforced by the shell. Root view pushes the - /// cached/baked policy before the first refresh and the refreshed policy - /// after it succeeds, keeping What's New copy in lockstep with admission. - private(set) var macCompatibilityPolicy: MobileMacCompatPolicy = .baked public init( apiBaseURL: String?, @@ -105,12 +106,6 @@ public final class MobileWhatsNewCenter { } } - /// Keeps the What's New compatibility footnote synchronized with the - /// policy used by the connection store. - public func applyMacCompatibilityPolicy(_ policy: MobileMacCompatPolicy) { - macCompatibilityPolicy = policy - } - /// Drops acknowledged announcement ids the authoritative list no longer /// carries. Announcements expire remotely and their ids never return, so /// without pruning the UserDefaults-backed set would grow without bound. @@ -144,6 +139,12 @@ public final class MobileWhatsNewCenter { /// explicitly lists "prod". Never-fetched devices show the full catalog /// (fail-open to binary truth) still under the compiled-in channel gate, /// so a never-fetched official build shows nothing. + /// + /// During a catalog rollout, an older API deployment can return only + /// retired entry ids that this binary no longer carries. Treat that + /// nonempty, wholly-unrecognized list like a never-fetched cache so a + /// current native page does not disappear from Settings until the API + /// catches up. An explicit empty list remains a deliberate retraction. var visibleBinaryEntries: [MobileWhatsNewPage] { let channelAllowed = MobileWhatsNewCatalog.entries.filter { page in MobileWhatsNewChannelPolicy.isVisible( @@ -151,19 +152,16 @@ public final class MobileWhatsNewCenter { buildType: buildType ) } - let withCompatibilityCopy = channelAllowed.map { page -> MobileWhatsNewPage in - guard page.id == "connections.v1" else { return page } - var updated = page - updated.footnote = MobileWhatsNewCatalog.macUpdateFootnote( - buildType: buildType, - iosVersion: appVersion, - policy: macCompatibilityPolicy - ) - return updated - } - guard let remoteList else { return withCompatibilityCopy } + guard let remoteList else { return channelAllowed } let visible = Set(remoteList.visibleEntryIds) - return withCompatibilityCopy.filter { visible.contains($0.id) } + guard !visible.isEmpty else { return [] } + let recognized = visible.intersection(Set(channelAllowed.map(\.id))) + guard !recognized.isEmpty else { + return channelAllowed + } + return channelAllowed.filter { + visible.contains($0.id) || Self.requiredBinaryEntryIDs.contains($0.id) + } } /// Cached announcements targeted at this app version, resolved to diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewContent.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewContent.swift index d0edbfcc551b..f95258bd3c06 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewContent.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewContent.swift @@ -1,6 +1,9 @@ #if os(iOS) +import CmuxMobileShell +import CmuxMobileShellModel import CmuxMobileSupport import SwiftUI +import UIKit /// Density for the What's New page. `regular` is the HIG template look; /// `compact` tightens fonts and spacing so the whole page still fits without @@ -13,7 +16,7 @@ enum MobileWhatsNewPageLayout { var topPadding: CGFloat { switch self { case .regular: 40 - case .compact: 12 + case .compact: 32 } } @@ -118,6 +121,20 @@ struct MobileWhatsNewContent: View { var layout: MobileWhatsNewPageLayout = .regular var body: some View { + switch page.body { + case .pairingSetup: + MobileWhatsNewPairingSetupContent( + page: page, + layout: layout + ) + case .features: + featureRowsContent + case .web: + EmptyView() + } + } + + private var featureRowsContent: some View { VStack(spacing: layout.headerSpacing) { VStack(spacing: 8) { if page.isAnnouncement { @@ -182,6 +199,199 @@ struct MobileWhatsNewContent: View { } } +/// A purpose-built release page for the Mac-side pairing gate. The screenshot +/// makes the required switch recognizable, while the compatibility block reads +/// the same policy that connection admission uses. +struct MobileWhatsNewPairingSetupContent: View { + let page: MobileWhatsNewPage + let layout: MobileWhatsNewPageLayout + @Environment(MobileMacCompatCenter.self) private var macCompatCenter: MobileMacCompatCenter? + @Environment(\.colorScheme) private var colorScheme + + private var compatibility: MobileWhatsNewMacCompatibility { + MobileWhatsNewCatalog.macCompatibility( + policy: macCompatCenter?.policy ?? .baked, + iosVersion: AppVersionInfo.current().marketingVersion, + buildType: MobileBuildType.current() + ) + } + + var body: some View { + VStack(spacing: layout.headerSpacing) { + VStack(spacing: 10) { + OnboardingBalancedText( + page.title, + role: layout == .regular ? .title : .title2, + alignment: .center, + maximumNumberOfLines: 2, + reservesMaximumLines: true + ) + + Text(L10n.string( + "mobile.pairingOptInUpdate.requirement", + defaultValue: "Open cmux Settings > Mobile on your Mac and turn on Enable iOS pairing before connecting." + )) + .font(layout.detailFont) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.top, layout.topPadding) + .padding(.horizontal, 28) + + settingsScreenshot + accountRequirement + compatibilitySection + } + .padding(.bottom, layout.bottomPadding) + .accessibilityIdentifier("MobileWhatsNewPairingSetup") + } + + private var settingsScreenshot: some View { + screenshotImage + .padding(.horizontal, 24) + } + + private var screenshotImage: some View { + Group { + if let image = Self.settingsImage(darkMode: colorScheme == .dark) { + Image(uiImage: image) + .resizable() + .interpolation(.high) + .aspectRatio(contentMode: .fit) + } else { + Color.clear + .aspectRatio(1030.0 / 285.0, contentMode: .fit) + } + } + .accessibilityLabel(L10n.string( + "mobile.whatsNew.pairing.screenshotLabel", + defaultValue: "cmux Mac Settings, Mobile section, showing Enable iOS pairing." + )) + .accessibilityIdentifier("MobileWhatsNewMacSettingsScreenshot") + } + + private static func settingsImage(darkMode: Bool) -> UIImage? { + let resourceName = darkMode ? "MacSettingsMobilePairing-dark" : "MacSettingsMobilePairing-light" + guard let url = Bundle.module.url(forResource: resourceName, withExtension: "png"), + let data = try? Data(contentsOf: url) else { + return nil + } + return UIImage(data: data, scale: 1) + } + + private var accountRequirement: some View { + VStack(alignment: .leading, spacing: 2) { + Text(L10n.string( + "mobile.onboarding.pairing.phoneLabel", + defaultValue: "On this iPhone" + )) + .font(layout.featureTitleFont) + .accessibilityIdentifier("MobileWhatsNewPhoneTitle") + Text(L10n.string( + "mobile.onboarding.pairing.phoneDetail", + defaultValue: "Sign in to the same cmux account" + )) + .font(layout.detailFont) + .foregroundStyle(.secondary) + .accessibilityIdentifier("MobileWhatsNewPhoneDetail") + } + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) + .accessibilityIdentifier("MobileWhatsNewPairingRequirement") + } + + private var compatibilitySection: some View { + VStack(alignment: .leading, spacing: 12) { + Text( + L10n.string( + "mobile.whatsNew.pairing.compatibilityTitle", + defaultValue: "Mac version required" + ) + ) + .font(layout.featureTitleFont) + + VStack(spacing: 8) { + compatibilityRow( + title: L10n.string( + "mobile.whatsNew.pairing.stableLabel", + defaultValue: "Stable Mac" + ), + value: stableRequirement + ) + compatibilityRow( + title: L10n.string( + "mobile.whatsNew.pairing.nightlyLabel", + defaultValue: "Nightly Mac" + ), + value: nightlyRequirement + ) + } + + if MobileBuildType.current().usesInternalBuildVocabulary { + Text(MobileWhatsNewCatalog.macUpdateDetail( + buildType: MobileBuildType.current(), + requiredVersion: compatibility.stableVersion + )) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(16) + .background( + Color.accentColor.opacity(0.10), + in: RoundedRectangle(cornerRadius: 14, style: .continuous) + ) + .padding(.horizontal, 24) + .accessibilityIdentifier("MobileWhatsNewCompatibility") + } + + private func compatibilityRow(title: String, value: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(title) + .font(.subheadline.weight(.semibold)) + Spacer(minLength: 8) + Text(value) + .font(.subheadline.monospaced()) + .multilineTextAlignment(.trailing) + } + } + + private var stableRequirement: String { + guard let version = compatibility.stableVersion else { + return L10n.string( + "mobile.whatsNew.pairing.noStableMinimum", + defaultValue: "No stable minimum listed" + ) + } + return String( + format: L10n.string( + "mobile.whatsNew.pairing.macVersionFormat", + defaultValue: "cmux %@ or later" + ), + version + ) + } + + private var nightlyRequirement: String { + guard let version = compatibility.nightlyVersion else { + return L10n.string( + "mobile.whatsNew.pairing.noNightlyMinimum", + defaultValue: "No separate minimum" + ) + } + return String( + format: L10n.string( + "mobile.whatsNew.pairing.nightlyVersionFormat", + defaultValue: "cmux NIGHTLY %@ or later" + ), + version + ) + } +} + /// Small tinted marker distinguishing remote announcements. struct MobileWhatsNewAnnouncementBadge: View { var body: some View { @@ -195,4 +405,5 @@ struct MobileWhatsNewAnnouncementBadge: View { .accessibilityIdentifier("MobileWhatsNewAnnouncementBadge") } } + #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewListView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewListView.swift index c948232bd522..5e353c71cccd 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewListView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewListView.swift @@ -63,7 +63,7 @@ private struct MobileWhatsNewArchiveRow: View { @ViewBuilder private var destination: some View { switch page.body { - case .features: + case .features, .pairingSetup: MobileWhatsNewFittingPage(page: page) .frame(maxHeight: .infinity, alignment: .top) .background(PlatformPalette.systemBackground) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewSheet.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewSheet.swift index 36e6c2fb6cb0..85de07e429e5 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewSheet.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWhatsNewSheet.swift @@ -8,12 +8,8 @@ import SwiftUI /// several updates gets one sheet covering all of them. Every page stays /// readable later in Settings > What's New. /// -/// The common single-page case renders as a compact, content-fitted card -/// (owner contract: no scrolling at standard type sizes on any iPhone and no -/// trailing white space); the Auto-Connect migration sheet pioneered the -/// measurement mechanics. Accessibility type sizes, web pages, and the -/// multi-page catch-up keep a full-height sheet, where scrolling is the -/// correct behavior. +/// Native pages report their natural height independently of the viewport. +/// The selected page owns the sheet height, including during catch-up swipes. struct MobileWhatsNewSheet: View { let pages: [MobileWhatsNewPage] let allowedWebHosts: Set @@ -24,83 +20,108 @@ struct MobileWhatsNewSheet: View { let dismiss: () -> Void @State private var pageIndex = 0 @Environment(\.dynamicTypeSize) private var dynamicTypeSize - @State private var contentHeight: CGFloat = 1 + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var pageHeights: [String: CGFloat] = [:] + @State private var footerHeight: CGFloat = 0 + @State private var detents: Set = [.large] + @State private var selectedDetent: PresentationDetent = .large - /// Fixed-viewport presentations: the TabView catch-up and web pages need - /// full height, and accessibility sizes legitimately scroll. private var usesFullHeight: Bool { if dynamicTypeSize.isAccessibilitySize { return true } - if pages.count > 1 { return true } - if case .web = pages.first?.body { return true } + switch selectedPage?.body { + case .web: + return true + default: + break + } return false } + private var selectedPage: MobileWhatsNewPage? { + pages.indices.contains(pageIndex) ? pages[pageIndex] : nil + } + + private var pageHeight: CGFloat? { + guard !usesFullHeight, let selectedPage else { return nil } + return pageHeights[selectedPage.listID] + } + + private var contentHeight: CGFloat? { + pageHeight.map { $0 + footerHeight } + } + + private var selection: Binding { + Binding(get: { pageIndex }, set: { index in + withAnimation(reduceMotion ? nil : .smooth(duration: 0.3)) { + pageIndex = index + } + }) + } + var body: some View { - Group { + VStack(spacing: 0) { if pages.count > 1 { - VStack(spacing: 0) { - TabView(selection: $pageIndex) { - ForEach(Array(pages.enumerated()), id: \.element.listID) { index, page in - fullHeightPage(page) - .tag(index) - } + TabView(selection: selection) { + ForEach(Array(pages.enumerated()), id: \.element.listID) { index, page in + measuredPage(page) + .tag(index) } - .tabViewStyle(.page(indexDisplayMode: .always)) - .indexViewStyle(.page(backgroundDisplayMode: .always)) - continueButton } + .tabViewStyle(.page(indexDisplayMode: .always)) + .indexViewStyle(.page(backgroundDisplayMode: .always)) + .frame(idealHeight: pageHeight, maxHeight: pageHeight, alignment: .top) } else if let page = pages.first { - switch page.body { - case .features where !dynamicTypeSize.isAccessibilitySize: - // Content-fitted card: compact density measured at its - // natural height; the scroll tier only takes over when - // the screen caps the sheet below that height (short - // landscape phones), never in portrait at standard type. - ViewThatFits(in: .vertical) { - measuredSinglePage(page) - ScrollView { - measuredSinglePage(page) - } - .scrollBounceBehavior(.basedOnSize) - } - default: - VStack(spacing: 0) { - fullHeightPage(page) - continueButton - } - } + measuredPage(page) + .frame(idealHeight: pageHeight, maxHeight: pageHeight, alignment: .top) } + continueButton + .fixedSize(horizontal: false, vertical: true) + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height in + footerHeight = height + } } + .frame(idealWidth: 608, maxWidth: 608) .background(PlatformPalette.systemBackground) .accessibilityIdentifier("MobileWhatsNewSheet") - .modifier(MobileWhatsNewPresentationSizing( - contentHeight: contentHeight, - usesFullHeight: usesFullHeight - )) + .presentationSizing(.fitted) + .presentationDetents(detents, selection: $selectedDetent) + .onChange(of: contentHeight, initial: true) { _, height in + resizeSheet(to: height) + } + .presentationContentInteraction(.scrolls) + .presentationDragIndicator(.visible) } - /// The measured single-page body: content at natural height (fixedSize) - /// plus the Continue button, reported to drive the fitted detent. The - /// report is proposal-independent, so the detent cannot oscillate. - private func measuredSinglePage(_ page: MobileWhatsNewPage) -> some View { - VStack(spacing: 0) { - MobileWhatsNewContent(page: page, layout: .compact) - .fixedSize(horizontal: false, vertical: true) - continueButton - } - .onGeometryChange(for: CGFloat.self) { proxy in - proxy.size.height - } action: { newHeight in - guard newHeight.isFinite, newHeight > 0 else { return } - contentHeight = newHeight + private func resizeSheet(to height: CGFloat?) { + let target = height.map { PresentationDetent.height($0) } ?? .large + guard target != selectedDetent else { return } + // Both endpoints must exist while the system animates its selection. + detents.insert(target) + withAnimation(reduceMotion ? nil : .smooth(duration: 0.3), completionCriteria: .removed) { + selectedDetent = target + } completion: { + guard selectedDetent == target else { return } + detents = [target] } } @ViewBuilder - private func fullHeightPage(_ page: MobileWhatsNewPage) -> some View { + private func measuredPage(_ page: MobileWhatsNewPage) -> some View { switch page.body { - case .features: - MobileWhatsNewFittingPage(page: page) + case .features, .pairingSetup: + ScrollView { + MobileWhatsNewContent(page: page, layout: .compact) + .fixedSize(horizontal: false, vertical: true) + // Leave space for the system page control inside TabView. + .padding(.bottom, pages.count > 1 ? 36 : 0) + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height in + guard height.isFinite, height > 0 else { return } + withAnimation(reduceMotion ? nil : .smooth(duration: 0.3)) { + pageHeights[page.listID] = height + } + } + } + .scrollBounceBehavior(.basedOnSize) case .web(let url): MobileWhatsNewWebView( url: url, @@ -132,33 +153,10 @@ struct MobileWhatsNewSheet: View { /// dismissing early (swipe) skips content but never re-shows it. private func advance() { if pageIndex < pages.count - 1 { - withAnimation { - pageIndex += 1 - } + selection.wrappedValue += 1 } else { dismiss() } } } - -/// Fits the sheet to its measured content height for the common single-page -/// standard-type case; full height only where a fixed viewport is required. -private struct MobileWhatsNewPresentationSizing: ViewModifier { - let contentHeight: CGFloat - let usesFullHeight: Bool - - @ViewBuilder - func body(content: Content) -> some View { - if usesFullHeight { - content - .presentationDetents([.large]) - } else if #available(iOS 18.0, *) { - content - .presentationSizing(.fitted) - .presentationDetents([.height(contentHeight)]) - } else { - content.presentationDetents([.height(contentHeight)]) - } - } -} #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWorkspaceListEmptyRow.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWorkspaceListEmptyRow.swift new file mode 100644 index 000000000000..5149ede94242 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileWorkspaceListEmptyRow.swift @@ -0,0 +1,37 @@ +#if os(iOS) +import CmuxMobileSupport +import SwiftUI + +struct MobileWorkspaceListEmptyRow: View { + var body: some View { + VStack(spacing: 14) { + Image(systemName: "macbook.and.iphone") + .font(.system(size: 38, weight: .light)) + .foregroundStyle(.tint) + .symbolRenderingMode(.hierarchical) + .accessibilityHidden(true) + VStack(spacing: 7) { + Text( + L10n.string( + "mobile.workspaces.empty.title", + defaultValue: "No workspaces yet" + ) + ) + .font(.title3.weight(.semibold)) + .multilineTextAlignment(.center) + Text(MobilePairingCopy().emptyWorkspaceMessage) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: 420) + .frame(maxWidth: .infinity) + .padding(.horizontal, 24) + .padding(.vertical, 56) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("MobileWorkspaceEmptyState") + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingBalancedText.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingBalancedText.swift index 0b0bf1b6933d..e547a13b8edc 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingBalancedText.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingBalancedText.swift @@ -7,25 +7,27 @@ import UIKit struct OnboardingBalancedText: UIViewRepresentable { enum Role: Equatable { case title + case title2 case body var textStyle: UIFont.TextStyle { switch self { case .title: .largeTitle + case .title2: .title2 case .body: .body } } var weight: UIFont.Weight { switch self { - case .title: .bold + case .title, .title2: .bold case .body: .regular } } var color: UIColor { switch self { - case .title: .label + case .title, .title2: .label case .body: .secondaryLabel } } @@ -104,7 +106,7 @@ struct OnboardingBalancedText: UIViewRepresentable { .scaledFont(for: baseFont) label.textColor = role.color label.textAlignment = alignment == .center ? .center : .natural - label.accessibilityTraits = role == .title ? .header : .staticText + label.accessibilityTraits = role == .body ? .staticText : .header } func sizeThatFits( diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingConnectionView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingConnectionView.swift index e5964b6ebea1..c9758d3cb7e4 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingConnectionView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingConnectionView.swift @@ -119,41 +119,46 @@ struct OnboardingConnectionView: View { private var message: String { if phase == .ready { - return L10n.string( + let connectedCopy = L10n.string( "mobile.onboarding.ready.body", defaultValue: "Open any workspace and respond when an agent needs you." ) + return "\(connectedCopy) \(MobilePairingCopy().enableOnMacShort)" } if connectionMethod == .tailscale { if let requiredMacVersion { - return String( + let connectionCopy = String( format: L10n.string( "mobile.onboarding.connect.tailscaleBodyWithMinVersionFormat", defaultValue: "Requires cmux %1$@ or newer on your Mac. Install Tailscale on both devices and join the same network, then scan the pairing code once." ), requiredMacVersion ) + return "\(connectionCopy) \(MobilePairingCopy().enableOnMacShort)" } // Versionless fallback (below-tier app versions, previews): no // stale hardcoded floor; the policy-driven branch above names one. - return L10n.string( + let connectionCopy = L10n.string( "mobile.onboarding.connect.tailscaleBody", defaultValue: "Install Tailscale on both devices and join the same network, then scan the pairing code once." ) + return "\(connectionCopy) \(MobilePairingCopy().enableOnMacShort)" } if let requiredMacVersion { - return String( + let connectionCopy = String( format: L10n.string( - "mobile.onboarding.v2.connect.bodyWithMinVersionFormat", - defaultValue: "On your Mac, turn on Enable iOS pairing in cmux Settings. Select the same team on both devices. Requires cmux %1$@ or newer." + "mobile.onboarding.connect.bodyWithMinVersionFormat", + defaultValue: "Use the same cmux account on both devices. Requires cmux %1$@ or newer on your Mac." ), requiredMacVersion ) + return "\(connectionCopy) \(MobilePairingCopy().enableOnMacShort)" } - return L10n.string( - "mobile.onboarding.v2.connect.body", - defaultValue: "On your Mac, turn on Enable iOS pairing in cmux Settings. Select the same team on both devices, then keep cmux running." + let connectionCopy = L10n.string( + "mobile.onboarding.connect.body", + defaultValue: "Use the same cmux account on both devices. Your Mac connects automatically." ) + return "\(connectionCopy) \(MobilePairingCopy().enableOnMacShort)" } /// The minimum stable-channel Mac version this app version accepts, from diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingFlowView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingFlowView.swift index 44920f498772..c512beec3670 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingFlowView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingFlowView.swift @@ -5,7 +5,7 @@ import CmuxMobileSupport import SwiftUI /// A short product tour presented after sign-in, ending in same-account -/// computer discovery, with pairing available for Tailscale. +/// computer discovery with explicit Mac-side pairing opt-in. struct OnboardingFlowView: View { let context: OnboardingContext let isAuthenticated: Bool @@ -115,6 +115,8 @@ struct OnboardingFlowView: View { OnboardingNotificationsView() case .push: OnboardingPushView() + case .pairing: + OnboardingPairingView() case .connect: OnboardingConnectionView( phase: connectionPhase, @@ -134,8 +136,10 @@ struct OnboardingFlowView: View { showAgents() case .push: showNotifications() - case .connect: + case .pairing: showPush() + case .connect: + showPairing() } } @@ -147,6 +151,8 @@ struct OnboardingFlowView: View { showPush() case .push: enablePush() + case .pairing: + showConnection() case .connect: if isAuthenticated { finishOrRetry() @@ -168,12 +174,16 @@ struct OnboardingFlowView: View { navigate(to: .push) } + private func showPairing() { + navigate(to: .pairing) + } + private func showConnection() { navigate(to: .connect) } /// The one place the app first asks the OS for notification permission. - /// Advances to Connect after the system alert resolves either way; the + /// Advances to Pairing after the system alert resolves either way; the /// grant/deny outcome is recorded by the push coordinator. private func enablePush() { guard !isPushEnableInFlight else { return } @@ -185,14 +195,14 @@ struct OnboardingFlowView: View { _ = await onEnablePush() isPushEnableInFlight = false if stage == .push { - showConnection() + showPairing() } } } private func declinePush() { analytics.capture("ios_onboarding_push_declined", eventProperties) - showConnection() + showPairing() } private func reachConnectionIfNeeded() { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingPageViewport.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingPageViewport.swift index 82c1ac97c00f..6cb5115eee5a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingPageViewport.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingPageViewport.swift @@ -4,8 +4,8 @@ import SwiftUI /// Horizontal pager for the onboarding tour. Swipes and the chrome buttons /// drive the same committed `stage`, so scene analytics and connection /// side effects fire identically for both. The track ends at the connect -/// stage; completing onboarding (sign-in, permissions, pairing) stays on the -/// footer buttons, so a swipe can never skip a gated step. +/// stage; completion stays on the footer buttons while required setup guidance +/// stays visible in the page sequence. struct OnboardingPageViewport: View { let stage: OnboardingStage let onNavigate: (OnboardingStage) -> Void diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingPairingView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingPairingView.swift new file mode 100644 index 000000000000..f8a153b40bc2 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingPairingView.swift @@ -0,0 +1,111 @@ +#if os(iOS) +import CmuxMobileSupport +import SwiftUI + +/// Explains the Mac-side opt-in before onboarding starts discovery. +struct OnboardingPairingView: View { + var body: some View { + ZStack { + Color.clear + .frame(width: 1, height: 1) + .accessibilityElement(children: .ignore) + .accessibilityLabel(title) + .accessibilityIdentifier("MobileOnboardingPairingScene") + + OnboardingSceneContent( + title: title, + message: L10n.string( + "mobile.onboarding.pairing.body", + defaultValue: "This step is required before any Mac can appear on your iPhone. In cmux Settings > Mobile on your Mac, turn on Enable iOS pairing. Until you do, cmux keeps the Mac hidden and does not start iOS pairing networking." + ), + visual: pairingVisual, + bodyLineReservation: 6 + ) + } + } + + private var title: String { + L10n.string( + "mobile.onboarding.pairing.title", + defaultValue: "Enable iOS pairing on your Mac" + ) + } + + private var pairingVisual: some View { + VStack(spacing: 22) { + Image(systemName: "macbook.and.iphone") + .font(.system(size: 72, weight: .medium)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.tint) + .accessibilityHidden(true) + + Label { + Text( + L10n.string( + "mobile.onboarding.pairing.required", + defaultValue: "Required for Mac discovery" + ) + ) + .font(.headline) + } icon: { + Image(systemName: "exclamationmark.circle.fill") + .foregroundStyle(.orange) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + Color.orange.opacity(0.12), + in: RoundedRectangle(cornerRadius: 8, style: .continuous) + ) + .accessibilityIdentifier("MobileOnboardingPairingRequirement") + + HStack(alignment: .top, spacing: 16) { + pairingStep( + systemImage: "macbook", + title: L10n.string( + "mobile.onboarding.pairing.macLabel", + defaultValue: "On your Mac" + ), + detail: L10n.string( + "mobile.onboarding.pairing.macDetail", + defaultValue: "Settings > Mobile > turn on Enable iOS pairing" + ) + ) + + pairingStep( + systemImage: "iphone", + title: L10n.string( + "mobile.onboarding.pairing.phoneLabel", + defaultValue: "On this iPhone" + ), + detail: L10n.string( + "mobile.onboarding.pairing.phoneDetail", + defaultValue: "Sign in to the same cmux account" + ) + ) + } + .frame(maxWidth: 520) + } + .padding(.horizontal, 12) + .padding(.top, 12) + } + + private func pairingStep(systemImage: String, title: String, detail: String) -> some View { + VStack(spacing: 8) { + Image(systemName: systemImage) + .font(.title2.weight(.semibold)) + .foregroundStyle(.tint) + .accessibilityHidden(true) + Text(title) + .font(.headline) + .multilineTextAlignment(.center) + Text(detail) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .top) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingSceneChrome.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingSceneChrome.swift index 6815766d28b3..61a65ad8b704 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingSceneChrome.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingSceneChrome.swift @@ -15,7 +15,9 @@ struct OnboardingSceneChrome: Equatable { connectionMethod: MobileConnectionMethod = .automatic ) { showsBack = stage != .agents - showsSkip = stage != .connect + // Pairing opt-in is the required handoff between the tour and Mac + // discovery. Keep it from reading like an incidental permission. + showsSkip = stage != .connect && stage != .pairing switch stage { case .agents: @@ -39,6 +41,12 @@ struct OnboardingSceneChrome: Equatable { "mobile.onboarding.push.notNow", defaultValue: "Not Now" ) + case .pairing: + primaryTitle = L10n.string( + "mobile.onboarding.pairing.primary", + defaultValue: "I've enabled iOS pairing" + ) + secondaryTitle = nil case .connect: guard isAuthenticated else { primaryTitle = L10n.string( diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingStage.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingStage.swift index 68918372cd53..ad5722eb1c34 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingStage.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingStage.swift @@ -3,6 +3,7 @@ enum OnboardingStage: Int, CaseIterable, Hashable, Sendable { case agents case notifications case push + case pairing case connect var position: Int { rawValue + 1 } @@ -12,6 +13,7 @@ enum OnboardingStage: Int, CaseIterable, Hashable, Sendable { case .agents: "agents" case .notifications: "notifications" case .push: "push" + case .pairing: "pairing" case .connect: "connect" } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Images.xcassets/Contents.json b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Images.xcassets/Contents.json new file mode 100644 index 000000000000..319a86bd082c --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Images.xcassets/Contents.json @@ -0,0 +1,3 @@ +{ + "info": { "author": "xcode", "version": 1 } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings index c0d17e88b234..7370dc066b3b 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings @@ -13388,6 +13388,504 @@ } } } + }, + "mobile.pairing.enableOnMac": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Before pairing, open cmux Settings > Mobile on the Mac and turn on Enable iOS pairing. This Mac stays hidden from iOS while it is off." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ペアリングする前に、Mac の cmux で「設定」>「Mobile」を開き、「iOS ペアリングを有効にする」をオンにしてください。オフの間、この Mac は iOS から非表示になります。" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Öffnen Sie vor dem Koppeln auf dem Mac in cmux „Einstellungen“ > „Mobile“ und aktivieren Sie „iOS-Kopplung aktivieren“. Solange die Option deaktiviert ist, bleibt dieser Mac für iOS unsichtbar." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Avant le jumelage, ouvrez « Réglages » > « Mobile » dans cmux sur le Mac et activez « Activer le jumelage iOS ». Ce Mac reste masqué pour iOS tant que l’option est désactivée." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "قبل الاقتران، افتح في cmux على الـ Mac «الإعدادات» > «الهاتف المحمول» وفعّل «تمكين اقتران iOS». يظل هذا الـ Mac مخفيًا عن iOS أثناء إيقاف الخيار." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Antes de enlazar, abre en cmux del Mac «Ajustes» > «Móvil» y activa «Activar el enlace con iOS». Este Mac permanece oculto para iOS mientras la opción esté desactivada." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "配對前,請在 Mac 上的 cmux 開啟「設定」>「行動裝置」,並啟用「啟用 iOS 配對」。關閉此選項時,iOS 不會顯示這部 Mac。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "配对前,请在 Mac 上的 cmux 打开“设置”>“移动”,并启用“启用 iOS 配对”。关闭此选项时,iOS 不会显示这台 Mac。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페어링하기 전에 Mac의 cmux에서 “설정” > “모바일”을 열고 “iOS 페어링 활성화”를 켜세요. 이 옵션이 꺼져 있으면 이 Mac은 iOS에 표시되지 않습니다." + } + } + } + }, + "mobile.pairing.enableOnMac.short": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Enable iOS pairing in cmux Settings > Mobile on your Mac."}}, + "ja": {"stringUnit": {"state": "translated", "value": "Mac の cmux「設定」>「Mobile」で iOS ペアリングを有効にしてください。"}}, + "de": {"stringUnit": {"state": "translated", "value": "Aktivieren Sie auf dem Mac in cmux „Einstellungen“ > „Mobile“ die iOS-Kopplung."}}, + "fr": {"stringUnit": {"state": "translated", "value": "Activez le jumelage iOS dans cmux, sous « Réglages » > « Mobile » sur le Mac."}}, + "ar": {"stringUnit": {"state": "translated", "value": "فعّل اقتران iOS في cmux على الـ Mac من «الإعدادات» > «الهاتف المحمول»."}}, + "es": {"stringUnit": {"state": "translated", "value": "Activa el enlace con iOS en cmux, en «Ajustes» > «Móvil» del Mac."}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "請在 Mac 上的 cmux「設定」>「行動裝置」中啟用 iOS 配對。"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "请在 Mac 上的 cmux“设置”>“移动”中启用 iOS 配对。"}}, + "ko": {"stringUnit": {"state": "translated", "value": "Mac의 cmux “설정” > “모바일”에서 iOS 페어링을 활성화하세요."}} + } + }, + "mobile.onboarding.pairing.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable iOS pairing on your Mac" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Mac で iOS ペアリングを有効にする" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "iOS-Kopplung auf Ihrem Mac aktivieren" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activez le jumelage iOS sur votre Mac" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فعّل اقتران iOS على جهاز Mac" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Activa el enlace con iOS en tu Mac" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在 Mac 上啟用 iOS 配對" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在 Mac 上启用 iOS 配对" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Mac에서 iOS 페어링 활성화" + } + } + } + }, + "mobile.onboarding.pairing.body": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This step is required before any Mac can appear on your iPhone. In cmux Settings > Mobile on your Mac, turn on Enable iOS pairing. Until you do, cmux keeps the Mac hidden and does not start iOS pairing networking." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この手順は、iPhone に Mac を表示するために必要です。Mac の cmux で「設定」>「Mobile」を開き、「iOS ペアリングを有効にする」をオンにしてください。オンにするまで Mac は非表示になり、iOS ペアリングのネットワーク通信は開始されません。" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dieser Schritt ist erforderlich, damit Ihr Mac auf dem iPhone angezeigt wird. Aktivieren Sie auf dem Mac in cmux „Einstellungen“ > „Mobile“ die iOS-Kopplung. Bis dahin bleibt der Mac verborgen und cmux startet keine iOS-Kopplungsnetzwerke." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cette étape est requise pour que votre Mac apparaisse sur l’iPhone. Dans cmux sur le Mac, ouvrez « Réglages » > « Mobile » et activez le jumelage iOS. Tant que vous ne l’avez pas fait, le Mac reste masqué et cmux ne démarre aucun réseau de jumelage iOS." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "هذه الخطوة مطلوبة ليظهر جهاز Mac على iPhone. في cmux على جهاز Mac، افتح «الإعدادات» > «الهاتف المحمول» وفعّل اقتران iOS. حتى تفعل ذلك، يظل جهاز Mac مخفيًا ولا يبدأ cmux أي شبكة لاقتران iOS." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Este paso es necesario para que tu Mac aparezca en el iPhone. En cmux del Mac, abre «Ajustes» > «Móvil» y activa el enlace con iOS. Hasta entonces, el Mac permanece oculto y cmux no inicia ninguna red de enlace con iOS." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "若要讓 Mac 顯示在 iPhone 上,必須完成這個步驟。請在 Mac 上的 cmux 開啟「設定」>「行動裝置」,並啟用 iOS 配對。在啟用前,Mac 會保持隱藏,cmux 不會啟動 iOS 配對網路。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "要让 Mac 显示在 iPhone 上,必须完成此步骤。请在 Mac 上的 cmux 打开“设置”>“移动”,并启用 iOS 配对。在启用之前,Mac 会保持隐藏,cmux 不会启动 iOS 配对网络。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "iPhone에 Mac을 표시하려면 이 단계가 필요합니다. Mac의 cmux에서 “설정” > “모바일”로 이동해 iOS 페어링을 켜세요. 켜기 전에는 Mac이 숨겨지고 cmux가 iOS 페어링 네트워크를 시작하지 않습니다." + } + } + } + }, + "mobile.onboarding.pairing.primary": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "I've enabled iOS pairing"}}, + "ja": {"stringUnit": {"state": "translated", "value": "iOS ペアリングを有効にしました"}}, + "de": {"stringUnit": {"state": "translated", "value": "iOS-Kopplung ist aktiviert"}}, + "fr": {"stringUnit": {"state": "translated", "value": "J’ai activé le jumelage iOS"}}, + "ar": {"stringUnit": {"state": "translated", "value": "فعّلت اقتران iOS"}}, + "es": {"stringUnit": {"state": "translated", "value": "He activado el enlace con iOS"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "我已啟用 iOS 配對"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "我已启用 iOS 配对"}}, + "ko": {"stringUnit": {"state": "translated", "value": "iOS 페어링을 활성화했습니다"}} + } + }, + "mobile.onboarding.pairing.required": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Required for Mac discovery"}}, + "ja": {"stringUnit": {"state": "translated", "value": "Mac の検出に必要"}}, + "de": {"stringUnit": {"state": "translated", "value": "Für die Mac-Erkennung erforderlich"}}, + "fr": {"stringUnit": {"state": "translated", "value": "Requis pour détecter le Mac"}}, + "ar": {"stringUnit": {"state": "translated", "value": "مطلوب لاكتشاف جهاز Mac"}}, + "es": {"stringUnit": {"state": "translated", "value": "Necesario para detectar el Mac"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "Mac 偵測所需"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "Mac 检测所需"}}, + "ko": {"stringUnit": {"state": "translated", "value": "Mac 검색에 필요"}} + } + }, + "mobile.onboarding.pairing.macLabel": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "On your Mac"}}, + "ja": {"stringUnit": {"state": "translated", "value": "Mac で"}}, + "de": {"stringUnit": {"state": "translated", "value": "Auf dem Mac"}}, + "fr": {"stringUnit": {"state": "translated", "value": "Sur votre Mac"}}, + "ar": {"stringUnit": {"state": "translated", "value": "على جهاز Mac"}}, + "es": {"stringUnit": {"state": "translated", "value": "En tu Mac"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "在 Mac 上"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "在 Mac 上"}}, + "ko": {"stringUnit": {"state": "translated", "value": "Mac에서"}} + } + }, + "mobile.onboarding.pairing.macDetail": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Settings > Mobile > Enable iOS pairing"}}, + "ja": {"stringUnit": {"state": "translated", "value": "設定 > Mobile > iOS ペアリングを有効にする"}}, + "de": {"stringUnit": {"state": "translated", "value": "Einstellungen > Mobile > iOS-Kopplung aktivieren"}}, + "fr": {"stringUnit": {"state": "translated", "value": "Réglages > Mobile > Activer le jumelage iOS"}}, + "ar": {"stringUnit": {"state": "translated", "value": "الإعدادات > الهاتف المحمول > تمكين اقتران iOS"}}, + "es": {"stringUnit": {"state": "translated", "value": "Ajustes > Móvil > Activar el enlace con iOS"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "設定 > 行動裝置 > 啟用 iOS 配對"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "设置 > 移动 > 启用 iOS 配对"}}, + "ko": {"stringUnit": {"state": "translated", "value": "설정 > 모바일 > iOS 페어링 활성화"}} + } + }, + "mobile.onboarding.pairing.phoneLabel": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "On this iPhone"}}, + "ja": {"stringUnit": {"state": "translated", "value": "この iPhone で"}}, + "de": {"stringUnit": {"state": "translated", "value": "Auf diesem iPhone"}}, + "fr": {"stringUnit": {"state": "translated", "value": "Sur cet iPhone"}}, + "ar": {"stringUnit": {"state": "translated", "value": "على هذا الـ iPhone"}}, + "es": {"stringUnit": {"state": "translated", "value": "En este iPhone"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "在此 iPhone 上"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "在此 iPhone 上"}}, + "ko": {"stringUnit": {"state": "translated", "value": "이 iPhone에서"}} + } + }, + "mobile.onboarding.pairing.phoneDetail": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Sign in to the same cmux account"}}, + "ja": {"stringUnit": {"state": "translated", "value": "同じ cmux アカウントでサインイン"}}, + "de": {"stringUnit": {"state": "translated", "value": "Mit demselben cmux-Konto anmelden"}}, + "fr": {"stringUnit": {"state": "translated", "value": "Connectez-vous au même compte cmux"}}, + "ar": {"stringUnit": {"state": "translated", "value": "سجّل الدخول إلى حساب cmux نفسه"}}, + "es": {"stringUnit": {"state": "translated", "value": "Inicia sesión en la misma cuenta de cmux"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "登入相同的 cmux 帳戶"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "登录相同的 cmux 帐户"}}, + "ko": {"stringUnit": {"state": "translated", "value": "같은 cmux 계정으로 로그인"}} + } + }, + "mobile.connectionsUpdate.tailscale.title": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Tailscale, on your terms"}}, + "ja": {"stringUnit": {"state": "translated", "value": "Tailscaleは自分のペースで"}}, + "de": {"stringUnit": {"state": "translated", "value": "Tailscale, zu Ihren Bedingungen"}}, + "fr": {"stringUnit": {"state": "translated", "value": "Tailscale, selon vos conditions"}}, + "ar": {"stringUnit": {"state": "translated", "value": "Tailscale ، وفقًا لشروطك"}}, + "es": {"stringUnit": {"state": "translated", "value": "Tailscale, según tus condiciones"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "Tailscale,依照您的條件"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "Tailscale,按照您的条件"}}, + "ko": {"stringUnit": {"state": "translated", "value": "Tailscale, 원하는 대로"}} + } + }, + "mobile.connectionsUpdate.tailscale.detail": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Choosing Tailscale Only shows exactly what's missing and offers the pairing-code scan right there. Nothing opens on its own."}}, + "ja": {"stringUnit": {"state": "translated", "value": "「Tailscaleのみ」を選ぶと、足りない設定とペアリングコードのスキャンをその場で案内します。勝手に画面が開くことはありません。"}}, + "de": {"stringUnit": {"state": "translated", "value": "Wenn Sie sich für „Nur Tailscale“ entscheiden, wird genau angezeigt, was fehlt, und der Pairing-Code-Scan wird direkt dort angeboten. Nichts öffnet sich von alleine."}}, + "fr": {"stringUnit": {"state": "translated", "value": "Choisir Tailscale Only montre exactement ce qui manque et propose l'analyse du code d'appariement directement sur place. Rien ne s'ouvre tout seul."}}, + "ar": {"stringUnit": {"state": "translated", "value": "يؤدي اختيار Tailscale فقط إلى عرض ما هو مفقود بالضبط ويوفر فحص رمز الاقتران هناك. لا شيء يفتح من تلقاء نفسه."}}, + "es": {"stringUnit": {"state": "translated", "value": "Elegir Tailscale Only muestra exactamente lo que falta y ofrece el escaneo del código de emparejamiento allí mismo. Nada se abre por sí solo."}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "選擇“Tailscale Only”會準確顯示缺少的內容,並在此處提供配對碼掃描。没有什么是可以自行打开的。"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "选择“Tailscale Only”会准确显示缺少的内容,并在此处提供配对码扫描。没有什么是可以自行打开的。"}}, + "ko": {"stringUnit": {"state": "translated", "value": "Tailscale Only를 선택하면 누락된 내용이 정확히 표시되고 바로 페어링 코드 스캔이 제공됩니다. 저절로 열리는 것은 없습니다."}} + } + }, + "mobile.pairingOptInUpdate.title": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Required: Enable iOS pairing on Mac"}}, + "ja": {"stringUnit": {"state": "translated", "value": "必須: Mac で iOS ペアリングを有効にする"}}, + "de": {"stringUnit": {"state": "translated", "value": "Erforderlich: iOS-Kopplung auf dem Mac aktivieren"}}, + "fr": {"stringUnit": {"state": "translated", "value": "Requis : activez le jumelage iOS sur le Mac"}}, + "ar": {"stringUnit": {"state": "translated", "value": "مطلوب: فعّل اقتران iOS على Mac"}}, + "es": {"stringUnit": {"state": "translated", "value": "Obligatorio: activa el enlace con iOS en el Mac"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "必要:在 Mac 上開啟 iOS 配對"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "必需:在 Mac 上开启 iOS 配对"}}, + "ko": {"stringUnit": {"state": "translated", "value": "필수: Mac에서 iOS 페어링 켜기"}} + } + }, + "mobile.pairingOptInUpdate.detail": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Before this iPhone can find a cmux Mac, open Settings > Mobile on that Mac and turn on Enable iOS pairing. While it is off, the Mac stays hidden and starts no iOS pairing networking."}}, + "ja": {"stringUnit": {"state": "translated", "value": "この iPhone で cmux Mac を見つけるには、その Mac で「設定」>「Mobile」を開き、「iOS ペアリングを有効にする」をオンにしてください。オフの間、その Mac は非表示になり、iOS ペアリングのネットワーク通信を開始しません。"}}, + "de": {"stringUnit": {"state": "translated", "value": "Damit dieses iPhone einen cmux Mac findet, öffnen Sie auf dem Mac „Einstellungen“ > „Mobile“ und aktivieren Sie die iOS-Kopplung. Solange sie deaktiviert ist, bleibt der Mac verborgen und startet kein iOS-Kopplungsnetzwerk."}}, + "fr": {"stringUnit": {"state": "translated", "value": "Pour que cet iPhone trouve un Mac cmux, ouvrez Réglages > Mobile sur ce Mac et activez le jumelage iOS. Tant qu’il est désactivé, le Mac reste masqué et ne démarre aucun réseau de jumelage iOS."}}, + "ar": {"stringUnit": {"state": "translated", "value": "لكي يعثر هذا الـ iPhone على Mac يعمل بتطبيق cmux، افتح الإعدادات > الهاتف المحمول على جهاز Mac هذا وفعّل اقتران iOS. أثناء إيقافه، سيبقى Mac مخفيًا ولن يبدأ أي اتصال شبكي لاقتران iOS."}}, + "es": {"stringUnit": {"state": "translated", "value": "Para que este iPhone encuentre un Mac con cmux, abre Ajustes > Móvil en ese Mac y activa el enlace con iOS. Mientras esté desactivado, el Mac permanece oculto y no inicia la red de enlace con iOS."}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "若要讓此 iPhone 找到 cmux Mac,請在該 Mac 上開啟「設定」>「行動裝置」,然後開啟 iOS 配對。關閉時,該 Mac 會保持隱藏,且不會啟動 iOS 配對網路。"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "要让此 iPhone 找到 cmux Mac,请在该 Mac 上打开“设置”>“移动”,然后开启 iOS 配对。关闭时,该 Mac 会保持隐藏,也不会启动 iOS 配对网络。"}}, + "ko": {"stringUnit": {"state": "translated", "value": "이 iPhone에서 cmux Mac을 찾으려면 해당 Mac에서 설정 > 모바일을 열고 iOS 페어링을 켜세요. 꺼져 있는 동안 Mac은 숨겨지고 iOS 페어링 네트워크를 시작하지 않습니다."}} + } + }, + "mobile.pairingOptInUpdate.requirement": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Open cmux Settings > Mobile on your Mac and turn on Enable iOS pairing before connecting."}}, + "ja": {"stringUnit": {"state": "translated", "value": "接続する前に必須: iPhone で使う各 cmux Mac で「設定」>「Mobile」を開き、「iOS ペアリングを有効にする」をオンにしてください。"}}, + "de": {"stringUnit": {"state": "translated", "value": "Vor dem Verbinden erforderlich: Öffnen Sie auf jedem cmux Mac, den Sie mit dem iPhone verwenden möchten, „Einstellungen“ > „Mobile“ und aktivieren Sie die iOS-Kopplung."}}, + "fr": {"stringUnit": {"state": "translated", "value": "Requis avant la connexion : sur chaque Mac cmux que vous voulez utiliser avec l’iPhone, ouvrez Réglages > Mobile et activez le jumelage iOS."}}, + "ar": {"stringUnit": {"state": "translated", "value": "مطلوب قبل الاتصال: على كل Mac يعمل بتطبيق cmux وتريد استخدامه مع iPhone، افتح الإعدادات > الهاتف المحمول وفعّل اقتران iOS."}}, + "es": {"stringUnit": {"state": "translated", "value": "Obligatorio antes de conectar: en cada Mac con cmux que quieras usar con iPhone, abre Ajustes > Móvil y activa el enlace con iOS."}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "連線前必須完成:在每個要搭配 iPhone 使用的 cmux Mac 上,開啟「設定」>「行動裝置」,然後開啟 iOS 配對。"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "连接前必须完成:在每个要搭配 iPhone 使用的 cmux Mac 上,打开“设置”>“移动”,然后开启 iOS 配对。"}}, + "ko": {"stringUnit": {"state": "translated", "value": "연결하기 전에 필수: iPhone과 함께 사용할 각 cmux Mac에서 설정 > 모바일을 열고 iOS 페어링을 켜세요."}} + } + }, + "mobile.pairingOptInUpdate.releaseLabel": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "1.0.4 · September 2026"}}, + "ja": {"stringUnit": {"state": "translated", "value": "1.0.4 · 2026年9月"}}, + "de": {"stringUnit": {"state": "translated", "value": "1.0.4 · September 2026"}}, + "fr": {"stringUnit": {"state": "translated", "value": "1.0.4 · septembre 2026"}}, + "ar": {"stringUnit": {"state": "translated", "value": "1.0.4 · سبتمبر 2026"}}, + "es": {"stringUnit": {"state": "translated", "value": "1.0.4 · septiembre de 2026"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "1.0.4 · 2026 年 9 月"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "1.0.4 · 2026 年 9 月"}}, + "ko": {"stringUnit": {"state": "translated", "value": "1.0.4 · 2026년 9월"}} + } + }, + "mobile.whatsNew.pairing.pageTitle": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Action Required: Enable iOS pairing on your Mac"}} + } + }, + "mobile.whatsNew.pairing.macSettingsLabel": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Mac Settings"}} + } + }, + "mobile.whatsNew.pairing.compatibilityTitle": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Mac version required"}} + } + }, + "mobile.whatsNew.pairing.screenshotLabel": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "cmux Mac Settings, Mobile section, showing Enable iOS pairing."}} + } + }, + "mobile.whatsNew.pairing.stableLabel": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Stable Mac"}} + } + }, + "mobile.whatsNew.pairing.nightlyLabel": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Nightly Mac"}} + } + }, + "mobile.whatsNew.pairing.macVersionFormat": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "cmux %@ or later"}} + } + }, + "mobile.whatsNew.pairing.nightlyVersionFormat": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "cmux NIGHTLY %@ or later"}} + } + }, + "mobile.whatsNew.pairing.noStableMinimum": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "No stable minimum listed"}} + } + }, + "mobile.whatsNew.pairing.noNightlyMinimum": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "No separate minimum"}} + } + }, + "mobile.whatsNew.pairing.connectionChoicesTitle": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Connection choices"}} + } + }, + "mobile.workspaces.empty.title": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "No workspaces yet"}}, + "ja": {"stringUnit": {"state": "translated", "value": "ワークスペースはまだありません"}}, + "de": {"stringUnit": {"state": "translated", "value": "Noch keine Arbeitsbereiche"}}, + "fr": {"stringUnit": {"state": "translated", "value": "Aucun espace de travail pour le moment"}}, + "ar": {"stringUnit": {"state": "translated", "value": "لا توجد مساحات عمل بعد"}}, + "es": {"stringUnit": {"state": "translated", "value": "Aún no hay espacios de trabajo"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "目前沒有工作區"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "还没有工作区"}}, + "ko": {"stringUnit": {"state": "translated", "value": "아직 워크스페이스가 없습니다"}} + } + }, + "mobile.workspaces.empty.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable iOS pairing in cmux Settings > Mobile on your Mac, sign in to the same cmux account on both devices, and keep cmux running. Your Mac and its workspaces will appear here after pairing is enabled." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Mac の cmux で「設定」>「Mobile」から iOS ペアリングを有効にし、両方のデバイスで同じ cmux アカウントにサインインして、cmux を起動したままにしてください。ペアリングを有効にすると、Mac とそのワークスペースがここに表示されます。" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktivieren Sie auf dem Mac in cmux „Einstellungen“ > „Mobile“ die iOS-Kopplung, melden Sie sich auf beiden Geräten mit demselben cmux-Konto an und lassen Sie cmux geöffnet. Nach der Aktivierung werden der Mac und seine Arbeitsbereiche hier angezeigt." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activez le jumelage iOS dans cmux, sous « Réglages » > « Mobile » sur le Mac, connectez-vous au même compte cmux sur les deux appareils et laissez cmux ouvert. Le Mac et ses espaces de travail apparaîtront ici une fois le jumelage activé." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فعّل اقتران iOS في cmux على الـ Mac من «الإعدادات» > «الهاتف المحمول»، وسجّل الدخول إلى حساب cmux نفسه على الجهازين، واترك cmux قيد التشغيل. سيظهر الـ Mac ومساحات العمل الخاصة به هنا بعد تفعيل الاقتران." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Activa el enlace con iOS en cmux, en «Ajustes» > «Móvil» del Mac, inicia sesión con la misma cuenta de cmux en ambos dispositivos y deja cmux abierto. El Mac y sus espacios de trabajo aparecerán aquí cuando se active el enlace." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "請在 Mac 上的 cmux「設定」>「行動裝置」中啟用 iOS 配對,並在兩部裝置上登入相同的 cmux 帳戶,讓 cmux 保持執行。啟用配對後,Mac 和其工作區會顯示在這裡。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请在 Mac 上的 cmux“设置”>“移动”中启用 iOS 配对,并在两台设备上登录相同的 cmux 帐户,让 cmux 保持运行。启用配对后,Mac 及其工作区会显示在这里。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Mac의 cmux에서 “설정” > “모바일”로 이동해 iOS 페어링을 켜고, 두 기기에서 같은 cmux 계정으로 로그인한 뒤 cmux를 실행 상태로 두세요. 페어링을 켜면 Mac과 워크스페이스가 여기에 표시됩니다." + } + } + } } }, "version": "1.0" diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/MacSettingsMobilePairing-dark.png b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/MacSettingsMobilePairing-dark.png new file mode 100644 index 000000000000..f9fe058936ac Binary files /dev/null and b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/MacSettingsMobilePairing-dark.png differ diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/MacSettingsMobilePairing-light.png b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/MacSettingsMobilePairing-light.png new file mode 100644 index 000000000000..f0c165d52900 Binary files /dev/null and b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/MacSettingsMobilePairing-light.png differ diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/SetupHelpGateContent.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/SetupHelpGateContent.swift index 1a5f4b577dff..4975455076e8 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/SetupHelpGateContent.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/SetupHelpGateContent.swift @@ -43,7 +43,7 @@ struct SetupHelpGateContent { Open Tailscale Pairing on the Mac and scan its QR here, or enter the Mac's \ numeric Tailscale IP and port. """ - ), + ) + " " + MobilePairingCopy().enableOnMac, link: nil, identifierSuffix: "signedInNeverPaired", linkAccessibilityIdentifier: "MobileSetupHelpMacAppLink" diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListTableCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListTableCoordinator.swift index 17c5429c64e2..a49cb04aa942 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListTableCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListTableCoordinator.swift @@ -28,6 +28,7 @@ final class WorkspaceListTableCoordinator: NSObject, UITableViewDelegate, case recoveryBanner(String) case macStatus(String) case filterEmpty(MobileWorkspaceListFilter) + case emptyWorkspaceList } private struct HeightCacheKey: Hashable { @@ -809,7 +810,7 @@ final class WorkspaceListTableCoordinator: NSObject, UITableViewDelegate, } identifier = group.id.rawValue as NSString actions = contextMenuActions(for: group) - case .chrome, .groupFooter, .filterEmpty: + case .chrome, .groupFooter, .filterEmpty, .emptyWorkspaceList: return nil } guard !actions.isEmpty else { return nil } @@ -851,7 +852,7 @@ final class WorkspaceListTableCoordinator: NSObject, UITableViewDelegate, || previousAnchor?.actionCapabilities.supportsCloseActions != nextAnchor?.actionCapabilities.supportsCloseActions || nativeActionAvailabilityChanged(previous: previous, next: next) - case .chrome, .groupFooter, .filterEmpty: + case .chrome, .groupFooter, .filterEmpty, .emptyWorkspaceList: return false } } @@ -960,7 +961,7 @@ final class WorkspaceListTableCoordinator: NSObject, UITableViewDelegate, guard let group = configuration.groupsByID[groupID] else { return nil } guard let anchorWorkspaceID = group.liveAnchorWorkspaceID else { return nil } return configuration.workspacesByID[anchorWorkspaceID] - case .chrome, .groupFooter, .filterEmpty: + case .chrome, .groupFooter, .filterEmpty, .emptyWorkspaceList: return nil } } @@ -981,7 +982,7 @@ final class WorkspaceListTableCoordinator: NSObject, UITableViewDelegate, configuration.groupsByID[groupID] .map { !$0.isEmpty && groupActionCapabilities(for: $0).supportsMoveActions } ?? false - case .chrome, .filterEmpty, .groupFooter: + case .chrome, .filterEmpty, .groupFooter, .emptyWorkspaceList: false } } @@ -1055,6 +1056,13 @@ final class WorkspaceListTableCoordinator: NSObject, UITableViewDelegate, .margins(.trailing, 12) case .filterEmpty: break + case .emptyWorkspaceList: + hosting = hosting + .margins(.top, 8) + .margins(.bottom, 8) + .margins(.leading, 12) + .margins(.trailing, 12) + .minSize(width: 0, height: 0) } cell.contentConfiguration = hosting } @@ -1203,6 +1211,8 @@ final class WorkspaceListTableCoordinator: NSObject, UITableViewDelegate, showAll: configuration.showAll ) ) + case .emptyWorkspaceList: + return AnyView(MobileWorkspaceListEmptyRow()) } } @@ -1263,6 +1273,8 @@ final class WorkspaceListTableCoordinator: NSObject, UITableViewDelegate, ].joined(separator: "|")) case .filterEmpty: kind = .filterEmpty(configuration.filter) + case .emptyWorkspaceList: + kind = .emptyWorkspaceList case .groupFooter: // Unreachable while heightForRowAt returns the fixed 16pt slot // height before consulting the cache; keyed distinctly anyway so a @@ -1375,6 +1387,8 @@ final class WorkspaceListTableCoordinator: NSObject, UITableViewDelegate, || (previous.reconnect != nil) != (next.reconnect != nil) case .filterEmpty: return previous.filter != next.filter + case .emptyWorkspaceList: + return false } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListTableItem.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListTableItem.swift index 89978b4a0b71..4d7f67ef61e8 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListTableItem.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListTableItem.swift @@ -11,6 +11,7 @@ enum WorkspaceListChromeKind: Hashable { enum WorkspaceListTableItem: Hashable, Identifiable { case chrome(WorkspaceListChromeKind) case filterEmpty + case emptyWorkspaceList case groupHeader(MobileWorkspaceGroupPreview.ID) case groupFooter(MobileWorkspaceGroupPreview.ID) case workspace(MobileWorkspacePreview.ID, indented: Bool) @@ -23,6 +24,8 @@ enum WorkspaceListTableItem: Hashable, Identifiable { "chrome.macStatusRow" case .filterEmpty: "filter.empty" + case .emptyWorkspaceList: + "workspace.empty" case .groupHeader(let groupID): "groupHeader.\(groupID.rawValue)" case .groupFooter(let groupID): diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView+Table.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView+Table.swift index a76d989541ba..1119621fa4ab 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView+Table.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView+Table.swift @@ -27,18 +27,27 @@ extension WorkspaceListView { } if rendersGroupedSections { - items.append(contentsOf: groupedItems.map { item in - switch item { - case .groupHeader(let group, _): - .groupHeader(group.id) - case .groupFooter(let groupID): - .groupFooter(groupID) - case .workspace(let workspace, let indented): - .workspace(workspace.id, indented: indented) - } - }) + if groupedItems.isEmpty + && trimmedQuery.isEmpty + && !activeFilter.isActive + && workspaces.isEmpty { + items.append(.emptyWorkspaceList) + } else { + items.append(contentsOf: groupedItems.map { item in + switch item { + case .groupHeader(let group, _): + .groupHeader(group.id) + case .groupFooter(let groupID): + .groupFooter(groupID) + case .workspace(let workspace, let indented): + .workspace(workspace.id, indented: indented) + } + }) + } } else if showsWorkspaceTableFilterEmptyRow { items.append(.filterEmpty) + } else if trimmedQuery.isEmpty && !activeFilter.isActive && workspaces.isEmpty { + items.append(.emptyWorkspaceList) } else { items.append(contentsOf: displayedFlatWorkspaces.map { .workspace($0.id, indented: false) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift index 8639d085e03d..0bbfc4d13eea 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift @@ -652,8 +652,8 @@ struct WorkspaceShellView: View { whatsNewWebLoads = [:] }) { // Presentation sizing lives inside the sheet: fitted to content - // for the common single-page case, full height only for web - // pages, multi-page catch-up, and accessibility type. + // for each selected native page, full height for web pages and + // accessibility type. MobileWhatsNewSheet( pages: whatsNewSheetPages, allowedWebHosts: whatsNewCenter?.allowedWebHosts ?? [], @@ -687,19 +687,18 @@ struct WorkspaceShellView: View { /// that miss it are dropped unacknowledged and try again next launch. private static let whatsNewPreloadDeadline: Duration = .seconds(10) - /// Stages the one-time What's New sheet when there are unseen pages and - /// the device already has Computers. Staging is not presenting: the - /// preload gate (`preloadAndPresentWhatsNew`) presents only once every - /// page in the sheet renders immediately. Acknowledgement happens in the - /// sheet content's `onAppear` (first actual presentation, not on - /// dismiss): early enough that a kill mid-presentation cannot re-show - /// the sheet forever, late enough that a swallowed presentation (a - /// state-restored sheet already occupying the presenter) never marks - /// pages as seen. + /// Stages the one-time What's New sheet when there are unseen pages. + /// Pairing requirements must be visible before the first Mac is + /// discovered, so this gate cannot depend on a nonempty computer list. + /// Staging is not presenting: the preload gate + /// (`preloadAndPresentWhatsNew`) presents only once every page in the + /// sheet renders immediately. Acknowledgement happens in the sheet + /// content's `onAppear` (first actual presentation, not on dismiss): + /// early enough that a kill mid-presentation cannot re-show the sheet + /// forever, late enough that a swallowed presentation (a state-restored + /// sheet already occupying the presenter) never marks pages as seen. private func presentWhatsNewIfNeeded() { - guard let whatsNewCenter, - !store.pairedMacs.isEmpty, - !showsWhatsNewSheet else { return } + guard let whatsNewCenter, !showsWhatsNewSheet else { return } let pages = whatsNewCenter.unseenPages guard !pages.isEmpty else { return } whatsNewCandidatePages = pages @@ -742,15 +741,14 @@ struct WorkspaceShellView: View { } guard !Task.isCancelled else { return } whatsNewCandidatePages = nil - // The gate conditions can drift during the bounded preload window (a - // refresh can withdraw a page, the last Computer can disappear), so - // re-check them now instead of trusting the staging-time snapshot. - guard let whatsNewCenter, !store.pairedMacs.isEmpty else { return } + // The remote list can change during the bounded preload window, so + // re-check visibility now instead of trusting the staging snapshot. + guard let whatsNewCenter else { return } let stillUnseen = Set(whatsNewCenter.unseenPages.map(\.listID)) let readyPages = pages.filter { page in guard stillUnseen.contains(page.listID) else { return false } switch page.body { - case .features: + case .features, .pairingSetup: return true case .web: return loads[page.listID]?.phase == .loaded diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileOfficialChannelCopyTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileOfficialChannelCopyTests.swift index f1bb61d27533..e903a2a230c2 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileOfficialChannelCopyTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileOfficialChannelCopyTests.swift @@ -1,48 +1,59 @@ #if os(iOS) +import CmuxMobileShell import CmuxMobileShellModel import Testing @testable import CmuxMobileShellUI /// Official (App Store) builds must not render internal build-lane vocabulary -/// (DEV, BETA, INTERNAL, TestFlight) in the What's New compat notice or the -/// Mac-detail presence footer; team channels keep the precise internal copy. +/// (DEV, BETA, INTERNAL, TestFlight) in compatibility copy or the Mac-detail +/// presence footer; team channels keep the precise internal copy. /// App Review rejected the App Store app under Guideline 2.2 for that /// vocabulary in production UI. @MainActor @Suite struct MobileOfficialChannelCopyTests { - @Test func whatsNewCompatFootnoteIsNeutralOnOfficialBuilds() { - let official = MobileWhatsNewCatalog.macUpdateFootnote(buildType: .prod) - #expect(!official.contains("BETA")) - #expect(official.contains("Requires")) + @Test func whatsNewCompatCopyIsNeutralOnOfficialBuilds() { + let official = MobileWhatsNewCatalog.macCompatibility( + policy: .baked, + iosVersion: "1.0.4", + buildType: .prod + ) + #expect(official.stableVersion == "0.64.23") + #expect(official.nightlyVersion?.contains("nightly") == true) } - @Test func whatsNewCompatFootnoteKeepsRollbackRecipeOnTeamBuilds() { - let team = MobileWhatsNewCatalog.macUpdateFootnote(buildType: .beta) - #expect(team.contains("cmux BETA 1.0.4")) - #expect(team.contains("Requires")) + @Test func whatsNewCompatCopyUsesTeamSpecificFloor() { + let team = MobileWhatsNewCatalog.macCompatibility( + policy: .baked, + iosVersion: "1.0.4", + buildType: .beta + ) + #expect(team.stableVersion == "0.64.20") + #expect(team.nightlyVersion == nil) } - @Test func whatsNewCompatFootnoteUsesTheBakedMacCompatFloors() { - let beta = MobileWhatsNewCatalog.macUpdateFootnote( + @Test func whatsNewMacUpdateDetailUsesTheResolvedFloor() { + let team = MobileWhatsNewCatalog.macUpdateDetail( buildType: .beta, - iosVersion: "1.0.5" + requiredVersion: "0.64.20" ) - #expect(beta.contains("0.64.23")) - #expect(beta.contains("0.64.22-nightly.3345650013202")) + #expect(team.contains("0.64.20")) + #expect(team.contains("BETA")) + #expect(!team.contains("%@")) - let prod = MobileWhatsNewCatalog.macUpdateFootnote( + let official = MobileWhatsNewCatalog.macUpdateDetail( buildType: .prod, - iosVersion: "1.0.5" + requiredVersion: "0.64.23" ) - #expect(prod.contains("0.64.23")) - #expect(prod.contains("0.64.22-nightly.3345650013202")) + #expect(official.contains("0.64.23")) + #expect(!official.contains("BETA")) + #expect(!official.contains("%@")) } - @Test func whatsNewCarriesTheCompatNoticeAsFootnoteNotFeatureRow() { - let page = MobileWhatsNewCatalog.connectionsUpdate - #expect(page.footnote != nil) - guard case .features(let features) = page.body else { - Issue.record("connections update page lost its feature rows") + @Test func whatsNewUsesTheCustomPairingPage() throws { + let page = try #require(MobileWhatsNewCatalog.entry(withID: "connections.v2")) + #expect(page.footnote == nil) + guard case .pairingSetup(let features) = page.body else { + Issue.record("connections update page lost its custom body") return } #expect(!features.contains { $0.symbol == "exclamationmark.triangle.fill" }) diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewChannelGateTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewChannelGateTests.swift index 5b4e895bd9bc..6d79126badfb 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewChannelGateTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewChannelGateTests.swift @@ -1,4 +1,5 @@ #if os(iOS) +import CmuxMobileShell import CmuxMobileShellModel import Foundation import Testing @@ -15,11 +16,18 @@ import Testing @Suite struct MobileWhatsNewChannelGateTests { private func makeCenter( buildType: MobileBuildType, - payload: String? = nil + payload: String? = nil, + acknowledgedEntryID: String? = nil ) -> MobileWhatsNewCenter { let suiteName = "MobileWhatsNewChannelGateTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suiteName)! defaults.removePersistentDomain(forName: suiteName) + if let acknowledgedEntryID { + defaults.set( + acknowledgedEntryID, + forKey: MobileWhatsNewCenter.markerKey + ) + } return MobileWhatsNewCenter( apiBaseURL: "https://cmux.test", appVersion: "1.0.5", @@ -42,6 +50,74 @@ import Testing #expect(center.unseenPages.isEmpty) } + @Test func pairingUpdateAppearsAfterAnOlderPageWasAcknowledged() async { + let payload = #""" + { + "visibleEntryIds": ["connections.v2", "connections.v1"], + "announcements": [] + } + """# + for oldMarker in ["pairing-opt-in.v1", "connections.v1"] { + let center = makeCenter( + buildType: .beta, + payload: payload, + acknowledgedEntryID: oldMarker + ) + await center.refresh() + #expect(center.unseenPages.map(\.id) == ["connections.v2"]) + } + } + + @Test func pairingPageFocusesOnPairingRequirement() throws { + let page = try #require(MobileWhatsNewCatalog.entry(withID: "connections.v2")) + guard case .pairingSetup(let features) = page.body else { + Issue.record("connections.v2 should render the custom pairing page") + return + } + #expect(features.isEmpty) + #expect(page.title == "Action Required: Enable iOS pairing on your Mac") + #expect(MobileWhatsNewCatalog.entry(withID: "pairing-opt-in.v1") == nil) + } + + @Test func archiveKeepsBothUpdatesAfterAcknowledgingPairing() async throws { + let center = makeCenter( + buildType: .beta, + payload: #"{"visibleEntryIds":["connections.v2","connections.v1"],"announcements":[]}"# + ) + await center.refresh() + #expect(center.archivePages.map(\.id) == ["connections.v2", "connections.v1"]) + #expect(center.unseenPages.map(\.id) == ["connections.v2", "connections.v1"]) + let oldPage = try #require(MobileWhatsNewCatalog.entry(withID: "connections.v1")) + guard case .features(let features) = oldPage.body else { + Issue.record("The earlier connection update must keep its feature rows") + return + } + #expect(features.map(\.symbol) == ["desktopcomputer.and.macbook", "bolt.horizontal", "network", "qrcode.viewfinder"]) + center.acknowledge(center.unseenPages) + #expect(center.unseenPages.isEmpty) + #expect(center.archivePages.count == 2) + center.acknowledge([oldPage]) + #expect(center.unseenPages.isEmpty) + } + + @Test func compatibilityCopyUsesTheRemotePolicyShape() { + let beta = MobileWhatsNewCatalog.macCompatibility( + policy: .baked, + iosVersion: "1.0.4", + buildType: .beta + ) + #expect(beta.stableVersion == "0.64.20") + #expect(beta.nightlyVersion == nil) + + let official = MobileWhatsNewCatalog.macCompatibility( + policy: .baked, + iosVersion: "1.0.4", + buildType: .prod + ) + #expect(official.stableVersion == "0.64.23") + #expect(official.nightlyVersion == "0.64.22-nightly.3345650013202") + } + @Test func neverFetchedTeamBuildsKeepTheFullCatalog() { for buildType in [MobileBuildType.dev, .beta, .internal] { let center = makeCenter(buildType: buildType) @@ -56,10 +132,10 @@ import Testing @Test func legacyPayloadWithoutChannelFieldsKeepsTeamBehavior() async { // The pre-channel server payload shape must keep decoding and must // keep meaning "team lanes only" (not "everyone"). - let payload = #"{"visibleEntryIds":["connections.v1"],"announcements":[]}"# + let payload = #"{"visibleEntryIds":["connections.v2"],"announcements":[]}"# let team = makeCenter(buildType: .beta, payload: payload) await team.refresh() - #expect(team.visibleBinaryEntries.map(\.id) == ["connections.v1"]) + #expect(team.visibleBinaryEntries.map(\.id) == ["connections.v2"]) let official = makeCenter(buildType: .prod, payload: payload) await official.refresh() @@ -67,18 +143,42 @@ import Testing #expect(official.unseenPages.isEmpty) } + @Test func staleServerCatalogKeepsCurrentNativePageAvailable() async { + let payload = #"{"visibleEntryIds":["retired.v1"],"announcements":[]}"# + let center = makeCenter(buildType: .beta, payload: payload) + await center.refresh() + #expect(center.visibleBinaryEntries.map(\.id) == ["connections.v2", "connections.v1"]) + #expect(center.archivePages.map(\.id) == ["connections.v2", "connections.v1"]) + } + + @Test func oldServerCatalogCannotHidePairingRequirement() async { + let payload = #"{"visibleEntryIds":["connections.v1"],"announcements":[]}"# + let center = makeCenter(buildType: .beta, payload: payload) + await center.refresh() + #expect(center.visibleBinaryEntries.map(\.id) == ["connections.v2", "connections.v1"]) + #expect(center.unseenPages.map(\.id) == ["connections.v2", "connections.v1"]) + } + + @Test func explicitEmptyServerCatalogStillHidesNativePages() async { + let payload = #"{"visibleEntryIds":[],"announcements":[]}"# + let center = makeCenter(buildType: .beta, payload: payload) + await center.refresh() + #expect(center.visibleBinaryEntries.isEmpty) + #expect(center.archivePages.isEmpty) + } + @Test func remoteEntryChannelsOptABinaryEntryIntoOfficial() async { let payload = #""" { - "visibleEntryIds": ["connections.v1"], - "entryChannels": { "connections.v1": ["dev", "beta", "internal", "prod"] }, + "visibleEntryIds": ["connections.v2"], + "entryChannels": { "connections.v2": ["dev", "beta", "internal", "prod"] }, "announcements": [] } """# let center = makeCenter(buildType: .prod, payload: payload) await center.refresh() - #expect(center.visibleBinaryEntries.map(\.id) == ["connections.v1"]) - #expect(center.unseenPages.map(\.id) == ["connections.v1"]) + #expect(center.visibleBinaryEntries.map(\.id) == ["connections.v2"]) + #expect(center.unseenPages.map(\.id) == ["connections.v2"]) } @Test func remoteEntryChannelsCanAlsoNarrowTeamBuilds() async { @@ -86,8 +186,8 @@ import Testing // operator can retract an entry from a single lane remotely. let payload = #""" { - "visibleEntryIds": ["connections.v1"], - "entryChannels": { "connections.v1": ["prod"] }, + "visibleEntryIds": ["connections.v2"], + "entryChannels": { "connections.v2": ["prod"] }, "announcements": [] } """# diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewReplayTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewReplayTests.swift new file mode 100644 index 000000000000..16f95bc1b491 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileWhatsNewReplayTests.swift @@ -0,0 +1,53 @@ +#if os(iOS) && DEBUG +import Foundation +import Testing +@testable import CmuxMobileShellUI + +@MainActor +@Suite struct MobileWhatsNewReplayTests { + private var pages: [MobileWhatsNewPage] { + [ + MobileWhatsNewPage(id: "same-id", releaseLabel: nil, title: "Announcement", body: .features([]), isAnnouncement: true), + MobileWhatsNewPage(id: "same-id", releaseLabel: nil, title: "Update", body: .features([]), isAnnouncement: false), + MobileWhatsNewPage(id: "older", releaseLabel: nil, title: "Older update", body: .features([]), isAnnouncement: false) + ] + } + + @Test func rangeIncludesBothEndpointsAndIntermediatePages() throws { + let replay = try #require(MobileWhatsNewReplay(pages: pages, firstID: "announcement:same-id", lastID: "entry:older")) + #expect(replay.pages.map(\.listID) == pages.map(\.listID)) + } + + @Test func reversedEndpointsKeepCatalogOrder() throws { + let replay = try #require(MobileWhatsNewReplay(pages: pages, firstID: "entry:older", lastID: "entry:same-id")) + #expect(replay.pages.map(\.listID) == ["entry:same-id", "entry:older"]) + } + + @Test func onePageUsesNamespacedIdentity() throws { + let replay = try #require(MobileWhatsNewReplay(pages: pages, firstID: "entry:same-id", lastID: "entry:same-id")) + #expect(replay.pages.map(\.title) == ["Update"]) + } + + @Test func missingOrEmptyCatalogDoesNotPresent() { + #expect(MobileWhatsNewReplay(pages: [], firstID: "", lastID: "") == nil) + #expect(MobileWhatsNewReplay(pages: pages, firstID: "removed", lastID: "entry:older") == nil) + } + + @Test func replayDoesNotAcknowledgeUpdates() throws { + let suite = "MobileWhatsNewReplayTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let center = MobileWhatsNewCenter(apiBaseURL: "https://cmux.test", buildType: .beta, defaults: defaults) + let before = center.unseenPages.map(\.listID) + let replay = try #require(MobileWhatsNewReplay( + pages: center.archivePages, + firstID: try #require(before.first), + lastID: try #require(before.last) + )) + #expect(!replay.pages.isEmpty) + #expect(center.unseenPages.map(\.listID) == before) + #expect(defaults.object(forKey: MobileWhatsNewCenter.markerKey) == nil) + #expect(defaults.object(forKey: MobileWhatsNewCenter.acknowledgedAnnouncementsKey) == nil) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/OnboardingSceneChromeTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/OnboardingSceneChromeTests.swift index 0f2d1c99ce7e..0f2afd580b93 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/OnboardingSceneChromeTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/OnboardingSceneChromeTests.swift @@ -17,6 +17,11 @@ import UIKit isAuthenticated: true, connectionPhase: .searching ) + let pairing = OnboardingSceneChrome( + stage: .pairing, + isAuthenticated: true, + connectionPhase: .searching + ) #expect(!agents.showsBack) #expect(agents.showsSkip) @@ -27,6 +32,11 @@ import UIKit #expect(notifications.showsSkip) #expect(notifications.primaryTitle != nil) #expect(notifications.secondaryTitle == nil) + + #expect(pairing.showsBack) + #expect(!pairing.showsSkip) + #expect(pairing.primaryTitle != nil) + #expect(pairing.secondaryTitle == nil) } /// The push page always offers the paired opt-in choice: Enable as the diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift index 4a2a03c22479..0b9a7010fe25 100644 --- a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift +++ b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift @@ -144,6 +144,18 @@ public struct UITestConfig { #endif } + /// When `CMUX_UITEST_WHATS_NEW_PREVIEW=1`, the root view renders the + /// native What's New sheet content directly, without sign-in or pairing. + /// DEBUG-only. + public static var whatsNewPreviewEnabled: Bool { + #if DEBUG + return ProcessInfo.processInfo.environment["CMUX_UITEST_WHATS_NEW_PREVIEW"] == "1" + || ProcessInfo.processInfo.arguments.contains("CMUX_UITEST_WHATS_NEW_PREVIEW=1") + #else + return false + #endif + } + /// Whether the full-app UI-test harness should treat the account-owned /// revoke step of Forget Computer as successful. The remaining operation, /// including durable paired-Mac deletion, store refresh, shell routing, and diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/MobileCatalogSection.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/MobileCatalogSection.swift index b143873938ec..2351973b4b6b 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/MobileCatalogSection.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/MobileCatalogSection.swift @@ -30,16 +30,28 @@ public struct MobileCatalogSection: SettingCatalogSection { userDefaultsKey: "mobile.artifactFolderAccess" ) - /// Every build requires explicit pairing opt-in before any IROH activity. + /// Mac-side iOS pairing and Iroh networking. Every build defaults OFF until + /// the user explicitly enables this setting. public let iOSPairingHost = DefaultsKey( id: "mobile.iOSPairingHost.enabled", defaultValue: false, userDefaultsKey: "mobile.iOSPairingHost.enabled" ) - /// Preferred IROH UDP port. A saved change applies at the next pairing - /// start. The runtime reports its actual port locally and falls back to - /// an available port if necessary; local addresses never go to the server. + /// Port both Mac-side iOS listeners prefer to bind: the legacy TCP + /// pairing listener and the Iroh endpoint's UDP socket (the port Direct + /// addresses dial). + /// + /// This is a *preference*: when the port is already in use each listener + /// independently falls back to an OS-assigned ephemeral port. The TCP + /// listener hands the iOS app its actual bound port, and the Iroh + /// endpoint registers its actual socket addresses with the broker, so + /// pairing still works either way. Applying a change rebinds the TCP + /// listener live; the Iroh endpoint adopts the new port the next time it + /// activates (in practice, app relaunch). Configure a fixed port when you + /// need predictable firewall rules or to avoid a conflict. The default + /// mirrors `CmxMobileDefaults.defaultHostPort`, the protocol default + /// mobile clients dial when a pairing payload omits a port. public let iOSPairingPort = DefaultsKey( id: "mobile.iOSPairingHost.port", defaultValue: 58_465, diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/MobileSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/MobileSection.swift index 6436c4e56e54..29bc1d2eac55 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/MobileSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/MobileSection.swift @@ -81,6 +81,8 @@ public struct MobileSection: View { Group { SettingsSectionHeader(String(localized: "settings.section.mobile", defaultValue: "Mobile"), section: .mobile) SettingsCard { + iOSPairingHostRow + SettingsCardDivider() if remoteControlManagedByPolicy { SettingsCardNote(String( localized: "settings.mobile.managedByOrganization", @@ -100,17 +102,14 @@ public struct MobileSection: View { Group { pairDeviceRow SettingsCardDivider() - iOSPairingHostRow - SettingsCardDivider() portRow boundPortStatusRow SettingsCardDivider() displayNameRow SettingsCardDivider() artifactFolderAccessRow - // The Iroh endpoint hosts for every signed-in Mac even when - // the legacy pairing listener is toggled off, so diagnostics - // follow the live snapshot rather than the toggle alone. + // Keep diagnostics visible while a live endpoint is draining + // after the user turns pairing off. if iOSPairingHost.current || status.current?.isRunning == true { SettingsCardDivider() diagnostics @@ -271,10 +270,10 @@ public struct MobileSection: View { SettingsCardRow( configurationReview: .settingsOnly, searchAnchorID: "setting:mobile:iOSPairingHost", - String(localized: "settings.mobile.iOSPairingHost", defaultValue: "iOS Pairing"), + String(localized: "settings.mobile.iOSPairingHost", defaultValue: "Enable iOS pairing"), subtitle: iOSPairingHost.current - ? String(localized: "settings.mobile.iOSPairingHost.subtitleOn", defaultValue: "Allows the iOS app to discover and sync with this Mac on your local network.") - : String(localized: "settings.mobile.iOSPairingHost.subtitleOff", defaultValue: "Keeps the Mac-side iOS pairing listener off until you enable it here.") + ? String(localized: "settings.mobile.iOSPairingHost.subtitleOn", defaultValue: "Allows iOS pairing and Iroh networking for this Mac.") + : String(localized: "settings.mobile.iOSPairingHost.subtitleOff", defaultValue: "Keeps iOS pairing and Iroh networking off until you enable it here.") ) { Toggle("", isOn: Binding(get: { iOSPairingHost.current }, set: { iOSPairingHost.set($0) })) .labelsHidden() diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index b8a38d0070cc..e4715224cb6e 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -259881,6 +259881,57 @@ } } }, + "mobile.pairing.disabled.body": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "iOS pairing is off on this Mac. Open Settings and enable iOS pairing to discover this Mac from cmux on your iPhone." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この Mac では iOS ペアリングがオフです。設定を開き、iOS ペアリングを有効にすると、iPhone の cmux からこの Mac を検出できます。" + } + } + } + }, + "mobile.pairing.disabled.openSettings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Settings" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定を開く" + } + } + } + }, + "mobile.pairing.disabled.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable iOS pairing" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "iOS ペアリングを有効にする" + } + } + } + }, "mobile.pairing.error.listenerOffline": { "extractionState": "manual", "localizations": { @@ -329917,13 +329968,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "iOS Pairing" + "value": "Enable iOS pairing" } }, "ja": { "stringUnit": { "state": "translated", - "value": "iOS ペアリング" + "value": "iOS ペアリングを有効にする" } }, "zh-Hans": { @@ -329976,13 +330027,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Keeps the Mac-side iOS pairing listener off until you enable it here." + "value": "Keeps iOS pairing and Iroh networking off until you enable it here." } }, "ja": { "stringUnit": { "state": "translated", - "value": "ここで有効にするまで、Mac 側の iOS ペアリングリスナーをオフにします。" + "value": "ここで有効にするまで、iOS ペアリングと Iroh ネットワークをオフにします。" } }, "zh-Hans": { @@ -330035,13 +330086,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Allows the iOS app to discover and sync with this Mac on your local network." + "value": "Allows iOS pairing and Iroh networking for this Mac." } }, "ja": { "stringUnit": { "state": "translated", - "value": "iOS アプリがローカルネットワーク上でこの Mac を検出し、同期できるようにします。" + "value": "この Mac の iOS ペアリングと Iroh ネットワークを有効にします。" } }, "zh-Hans": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index dd436bdbe1af..9dd85a3d3320 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2112,6 +2112,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } // A Mac that slept through the reply nudge picks parked replies up here. PhoneReplyInboxCoordinator.shared.sweepSoon(reason: "app-activation") + // Reconcile pairing on wake so an opted-in Mac resumes and an opted-out + // Mac tears down any work that was in flight before sleep. + MobileHostService.shared.syncToSettings() guard let notificationStore else { return } notificationStore.handleApplicationDidBecomeActive() diff --git a/Sources/Cloud/ConnectivityInvalidationSubscriberCoordinator.swift b/Sources/Cloud/ConnectivityInvalidationSubscriberCoordinator.swift index 976015938e22..9709b2d2879d 100644 --- a/Sources/Cloud/ConnectivityInvalidationSubscriberCoordinator.swift +++ b/Sources/Cloud/ConnectivityInvalidationSubscriberCoordinator.swift @@ -22,6 +22,7 @@ final class ConnectivityInvalidationSubscriberCoordinator { private var authObservationTask: Task? private var defaultsObserver: NSObjectProtocol? private var activeScopeKey: String? + private var authObservationArmed = false func configure(auth: AuthCoordinator) { self.auth = auth @@ -32,21 +33,26 @@ final class ConnectivityInvalidationSubscriberCoordinator { } } } - armAuthScopeObservation() evaluate() } private func armAuthScopeObservation() { - guard let auth else { return } + guard !authObservationArmed, + MobileHostService.isListeningEnabled, + let auth else { return } + authObservationArmed = true withObservationTracking { _ = auth.isAuthenticated _ = auth.currentUser?.id } onChange: { [weak self] in MainActor.assumeIsolated { guard let self else { return } + self.authObservationArmed = false self.authObservationTask?.cancel() self.authObservationTask = Task { @MainActor [weak self] in - guard !Task.isCancelled, let self else { return } + guard !Task.isCancelled, + MobileHostService.isListeningEnabled, + let self else { return } self.evaluate() self.armAuthScopeObservation() } @@ -55,7 +61,8 @@ final class ConnectivityInvalidationSubscriberCoordinator { } private func desiredScope() -> Scope? { - guard let auth, + guard MobileHostService.isListeningEnabled, + let auth, auth.isAuthenticated, let userID = auth.currentUser?.id, let baseURL = PresenceHeartbeatClient.resolvedServiceURL() @@ -67,6 +74,13 @@ final class ConnectivityInvalidationSubscriberCoordinator { } private func evaluate() { + if MobileHostService.isListeningEnabled { + armAuthScopeObservation() + } else { + authObservationArmed = false + authObservationTask?.cancel() + authObservationTask = nil + } let scope = desiredScope() guard scope?.key != activeScopeKey else { return } activeScopeKey = scope?.key @@ -76,7 +90,10 @@ final class ConnectivityInvalidationSubscriberCoordinator { let auth = auth reconfigureTask = Task { @MainActor [weak self] in await previous?.stop() - guard !Task.isCancelled, let self, let scope else { return } + guard !Task.isCancelled, + MobileHostService.isListeningEnabled, + let self, + let scope else { return } let next = CmxConnectivityInvalidationSubscriber( serviceBaseURL: scope.baseURL, accessToken: { [weak auth] in @@ -84,6 +101,7 @@ final class ConnectivityInvalidationSubscriberCoordinator { }, onStreamEvent: { event in await MainActor.run { + guard MobileHostService.isListeningEnabled else { return } #if DEBUG cmuxDebugLog("connectivity.stream \(event)") #endif @@ -99,6 +117,7 @@ final class ConnectivityInvalidationSubscriberCoordinator { }, handler: { invalidation in await MainActor.run { + guard MobileHostService.isListeningEnabled else { return } #if DEBUG cmuxDebugLog("connectivity.frame revision=\(invalidation.revision)") #endif @@ -125,6 +144,7 @@ final class ConnectivityInvalidationSubscriberCoordinator { } func appWillTerminate() { + authObservationArmed = false authObservationTask?.cancel() authObservationTask = nil reconfigureTask?.cancel() diff --git a/Sources/Cloud/DeviceRegistryClient.swift b/Sources/Cloud/DeviceRegistryClient.swift index 9193cea314ee..7d9e359f113f 100644 --- a/Sources/Cloud/DeviceRegistryClient.swift +++ b/Sources/Cloud/DeviceRegistryClient.swift @@ -9,10 +9,9 @@ import Foundation /// Event-driven: it observes ``MobileHostService/statusUpdates()`` and registers /// whenever the advertised route set changes (e.g. the Mac moved networks or /// rebound to a different port), which is exactly the freshness the phone needs. -/// Gating falls out of the routes: ``MobileHostService`` advertises no routes -/// until the user has enabled mobile pairing, so an empty route set is never -/// registered. There is no separate opt-in flag — the registry is core to the -/// pairing the user already turned on, not a distinct privacy surface. +/// The explicit iOS pairing setting gates both route publication and the +/// registry request, so a stale status callback cannot re-register a disabled +/// Mac. /// /// Best-effort and non-blocking, mirroring ``PhonePushClient``: a registry /// outage never disturbs the Mac, and pairing still works through the phone's @@ -25,6 +24,7 @@ final class DeviceRegistryClient { private let retryAfterGate = CmxRetryAfterGate() private var auth: AuthCoordinator? private var observeTask: Task? + private var defaultsObserver: NSObjectProtocol? /// The scope (team + tag + routes) most recently registered, used to skip /// redundant POSTs. Keyed on the full scope rather than routes alone so an /// account/team switch with unchanged routes still re-registers in the newly @@ -44,7 +44,18 @@ final class DeviceRegistryClient { /// once at the composition root (after `auth` is constructed). func configure(auth: AuthCoordinator) { self.auth = auth - startObserving() + if defaultsObserver == nil { + defaultsObserver = NotificationCenter.default.addObserver( + forName: UserDefaults.didChangeNotification, + object: UserDefaults.standard, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.evaluate() + } + } + } + evaluate() } /// Whether a registration with `current` scope differs from what was last @@ -54,26 +65,25 @@ final class DeviceRegistryClient { /// /// Fires (returns `true`) when the team, tag, or routes differ from the last /// registration. The team is part of the key so an account/team switch with - /// unchanged routes still registers in the new team. The routes-empty - /// transition (the user turned mobile pairing off) also fires once, so the - /// registry stops advertising stale routes; the phone already skips - /// empty-route instances. An unchanged scope (a connection-only - /// `statusUpdates()` tick) and the never-registered empty start (`nil` - /// previous with empty routes) are both no-ops, so the off-state is published - /// exactly once rather than on every empty tick. + /// unchanged routes still registers in the new team. An unchanged scope (a + /// connection-only `statusUpdates()` tick) and the never-registered empty + /// start (`nil` previous with empty routes) are both no-ops. Pairing opt-out + /// cancels observation before registering a clearing POST; the registry's + /// missed-heartbeat/expiry path handles any stale server projection without + /// making a backend request while iOS pairing is off. nonisolated static func shouldReRegister( previous: Registration?, current: Registration ) -> Bool { // Treat "never registered" as an empty-routes baseline in the same scope - // so an initial empty set (pairing off at launch) is a no-op, but a later - // clear, or any team/tag change, still fires. + // so an initial empty set is a no-op, but a later clear while pairing + // remains enabled, or any team/tag change, still fires. let baseline = previous ?? Registration(teamID: current.teamID, tag: current.tag, routes: []) return baseline != current } private func startObserving() { - observeTask?.cancel() + guard observeTask == nil else { return } // Registration is currently driven only by host-route changes. The dedup // key includes the team, so a team switch *does* re-register once the // next status tick arrives, but a mid-session team switch with otherwise @@ -87,9 +97,25 @@ final class DeviceRegistryClient { } } + private func evaluate() { + guard MobileHostService.isListeningEnabled else { + observeTask?.cancel() + observeTask = nil + lastRegistration = nil + return + } + startObserving() + } + private func registerIfRoutesChanged(routes: [CmxAttachRoute]) async { // Status, route, and foreground events share this gate. Cached routes // remain valid while the server owns the next registration attempt. + guard MobileHostService.isListeningEnabled else { + // Forget the last accepted scope while pairing is off. Re-enabling + // must POST even when the endpoint identity and routes are reused. + lastRegistration = nil + return + } guard await retryAfterGate.remainingSeconds() == nil else { return } guard let auth else { return } // Await tokens FIRST: this both gates on "signed in" and waits for launch @@ -105,6 +131,10 @@ final class DeviceRegistryClient { } catch { return // not signed in → nothing to do } + guard MobileHostService.isListeningEnabled else { + lastRegistration = nil + return + } // Resolve the team AFTER bootstrap, and use that same scope for both the // dedup decision and the request header, so a team switch with unchanged // routes is detected and the POST targets the intended team. diff --git a/Sources/Cloud/MacPairedMacBackupPublisher.swift b/Sources/Cloud/MacPairedMacBackupPublisher.swift index 0576af826ee2..6907fd97135f 100644 --- a/Sources/Cloud/MacPairedMacBackupPublisher.swift +++ b/Sources/Cloud/MacPairedMacBackupPublisher.swift @@ -31,6 +31,7 @@ final class MacPairedMacBackupPublisher { private let retryAfterGate = CmxRetryAfterGate() private var auth: AuthCoordinator? private var observeTask: Task? + private var defaultsObserver: NSObjectProtocol? /// The routes most recently published, so an unchanged status update (the /// common case) does not re-POST. private var lastPublishedRoutes: [CmxAttachRoute] = [] @@ -68,9 +69,27 @@ final class MacPairedMacBackupPublisher { func configure(auth: AuthCoordinator) { guard Self.isEnabled() else { return } self.auth = auth - // The iOS-pairing listener defaults ON in DEBUG builds (see - // MobileCatalogSection.iOSPairingHost), so an attach route comes up - // without a manual Settings toggle; we just observe and publish it. + if defaultsObserver == nil { + defaultsObserver = NotificationCenter.default.addObserver( + forName: UserDefaults.didChangeNotification, + object: UserDefaults.standard, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.evaluate() + } + } + } + evaluate() + } + + private func evaluate() { + guard MobileHostService.isListeningEnabled else { + observeTask?.cancel() + observeTask = nil + lastPublishedRoutes = [] + return + } startObserving() } @@ -79,6 +98,10 @@ final class MacPairedMacBackupPublisher { observeTask = Task { @MainActor [weak self] in for await status in MobileHostService.shared.statusUpdates() { guard let self, !Task.isCancelled else { break } + guard MobileHostService.isListeningEnabled else { + self.lastPublishedRoutes = [] + continue + } guard !status.routes.isEmpty, status.routes != self.lastPublishedRoutes else { continue } await self.publish(routes: status.routes) } @@ -86,6 +109,10 @@ final class MacPairedMacBackupPublisher { } private func publish(routes: [CmxAttachRoute]) async { + guard MobileHostService.isListeningEnabled else { + lastPublishedRoutes = [] + return + } guard (try? await retryAfterGate.wait()) != nil else { return } guard let auth, let baseURL = PresenceHeartbeatClient.resolvedServiceURL() else { return } let tokens: (accessToken: String, refreshToken: String) @@ -94,6 +121,10 @@ final class MacPairedMacBackupPublisher { } catch { return // not signed in -> nothing to publish } + guard MobileHostService.isListeningEnabled else { + lastPublishedRoutes = [] + return + } let teamID = auth.resolvedTeamID guard var comps = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { return } @@ -186,7 +217,7 @@ final class MacPairedMacBackupPublisher { /// Republishes unchanged routes after the selected iOS target changes. func pairingTargetDidChange(routes: [CmxAttachRoute]) { lastPublishedRoutes = [] - guard !routes.isEmpty else { return } + guard MobileHostService.isListeningEnabled, !routes.isEmpty else { return } Task { await publish(routes: routes) } } } diff --git a/Sources/Cloud/PhoneReplyInboxCoordinator.swift b/Sources/Cloud/PhoneReplyInboxCoordinator.swift index 8067e10cb2c5..2a47c6eb2594 100644 --- a/Sources/Cloud/PhoneReplyInboxCoordinator.swift +++ b/Sources/Cloud/PhoneReplyInboxCoordinator.swift @@ -101,7 +101,8 @@ final class PhoneReplyInboxCoordinator { // `DisableRemoteControl` (MDM): a relayed reply is phone input into a // terminal, the same as a direct RPC send, so the sweep stays idle // under the policy. Parked replies age out server-side. - guard MobileRemoteControlPolicy.isEnabled else { return } + guard MobileRemoteControlPolicy.isEnabled, + MobileHostService.isListeningEnabled else { return } guard let client, let inject = injectTerminalInput else { #if DEBUG cmuxDebugLog("phoneReply.sweepAborted cause=\(client == nil ? "no_client" : "no_injector")") diff --git a/Sources/Cloud/PresenceHeartbeatClient.swift b/Sources/Cloud/PresenceHeartbeatClient.swift index 11908b8b36d2..a229e2ee6d59 100644 --- a/Sources/Cloud/PresenceHeartbeatClient.swift +++ b/Sources/Cloud/PresenceHeartbeatClient.swift @@ -1,4 +1,3 @@ -import CmuxFoundation import CMUXMobileCore import CmuxAuthRuntime import Foundation @@ -20,9 +19,10 @@ import Foundation /// route change additionally triggers one immediate out-of-cadence beat, so /// the presence service can push the fresh port/IP to subscribed phones live. /// -/// Offline is explicit on the server: a clean quit sends a `stopping: true` -/// goodbye; a crash or sleep is caught by the service's missed-heartbeat alarm -/// (45s), so this client never needs a watchdog of its own. +/// Offline is explicit on the server while iOS pairing stays enabled: a clean +/// quit sends a `stopping: true` goodbye; a crash, sleep, or pairing opt-out is +/// caught by the service's missed-heartbeat alarm (45s), so disabling iOS +/// pairing never makes one last backend request. @MainActor final class PresenceHeartbeatClient { static let shared = PresenceHeartbeatClient() @@ -46,12 +46,15 @@ final class PresenceHeartbeatClient { /// once at the composition root, alongside ``DeviceRegistryClient``. func configure(auth: AuthCoordinator) { self.auth = auth - startObservingRoutes() if defaultsObserver == nil { - // Re-evaluate when the flag or URL flips, so enabling presence in a - // running app starts the loop without a relaunch (and disabling - // stops it and says goodbye). - defaultsObserver = NotificationCenter.default.addUserDefaultsObserver(object: UserDefaults.standard) { + // Re-evaluate when the pairing flag, presence flag, or URL flips, + // so enabling iOS pairing in a running app starts the loop without + // a relaunch and disabling it stops all network contact. + defaultsObserver = NotificationCenter.default.addObserver( + forName: UserDefaults.didChangeNotification, + object: UserDefaults.standard, + queue: .main + ) { _ in MainActor.assumeIsolated { PresenceHeartbeatClient.shared.evaluate() } @@ -63,10 +66,10 @@ final class PresenceHeartbeatClient { /// Cancel the loop and send a best-effort goodbye. Called from /// `applicationWillTerminate`; the process may exit before the request /// lands, which is fine: the service's missed-heartbeat timeout covers - /// every unclean path, the goodbye only makes clean quits flip offline - /// immediately instead of within 45s. + /// every unclean path. Pairing opt-out suppresses this goodbye so the + /// setting's off state remains network-silent. func appWillTerminate() { - guard loopTask != nil else { return } + guard loopTask != nil, isEnabled else { return } stopLoop() Task { await self.sendHeartbeat(stopping: true) } } @@ -142,13 +145,17 @@ final class PresenceHeartbeatClient { private func evaluate() { let shouldRun = auth != nil && isEnabled && Self.resolvedServiceURL() != nil + if shouldRun, routesObserveTask == nil { + startObservingRoutes() + } else if !shouldRun { + routesObserveTask?.cancel() + routesObserveTask = nil + currentRoutes = [] + } if shouldRun && loopTask == nil { startLoop() } else if !shouldRun, loopTask != nil { stopLoop() - // Flag turned off while running: announce the disappearance instead - // of leaving the instance to time out. - Task { await self.sendHeartbeat(stopping: true) } } } @@ -178,6 +185,7 @@ final class PresenceHeartbeatClient { // Cadence, route-change, and shutdown triggers share one server-owned // floor so an immediate trigger cannot reopen a rate-limited endpoint. guard (try? await retryAfterGate.wait()) != nil else { return } + guard isEnabled else { return } guard let auth, let baseURL = Self.resolvedServiceURL() else { return } // Await tokens first, mirroring DeviceRegistryClient: gates on "signed // in" and on launch auth bootstrap so the team header resolves from a @@ -188,6 +196,7 @@ final class PresenceHeartbeatClient { } catch { return // not signed in -> nothing to announce } + guard isEnabled || stopping else { return } let teamID = auth.resolvedTeamID guard var comps = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { return } @@ -242,8 +251,9 @@ final class PresenceHeartbeatClient { /// Build the heartbeat JSON body. Routes are always present (the wire /// treats an absent field as "unchanged", but this client knows the full - /// current set on every beat, so it always states it — an empty array - /// accurately means "no routes", e.g. mobile pairing off). Pure and + /// current set on every beat, so it always states it. An empty array means + /// the enabled host currently has no routes; pairing opt-out suppresses the + /// heartbeat entirely. Pure and /// nonisolated for tests. nonisolated static func heartbeatBody( deviceID: String, diff --git a/Sources/Cloud/PresenceSettings.swift b/Sources/Cloud/PresenceSettings.swift index 7c6d30556bcc..07fb8aeae3d1 100644 --- a/Sources/Cloud/PresenceSettings.swift +++ b/Sources/Cloud/PresenceSettings.swift @@ -26,16 +26,15 @@ enum PresenceSettings { /// See workers/presence/README.md. static let productionServiceURL = "https://presence.cmux.dev" - /// Whether the heartbeat gate is on. An explicitly written value always wins. - /// With no stored value, presence FOLLOWS the mobile feature: announcing the - /// Mac's presence only makes sense once the user has enabled iOS pairing/host - /// (``MobileHostService/isListeningEnabled``), and a user who turns mobile on - /// expects their phone to see the Mac online. Default (mobile off) => off, for - /// privacy — the Mac announces nothing until the user opts into mobile. + /// Whether the heartbeat gate is on. Pairing opt-in is the outer gate, so an + /// explicit presence value cannot publish a Mac that has iOS pairing off. static func isEnabled(defaults: UserDefaults = .standard) -> Bool { + guard MobileHostService.isListeningEnabled(defaults: defaults) else { + return false + } if defaults.object(forKey: enabledKey) != nil { return defaults.bool(forKey: enabledKey) } - return MobileHostService.isListeningEnabled(defaults: defaults) + return true } } diff --git a/Sources/Mobile/MobileHostIrohRuntime+Activation.swift b/Sources/Mobile/MobileHostIrohRuntime+Activation.swift new file mode 100644 index 000000000000..a9dcd406af9f --- /dev/null +++ b/Sources/Mobile/MobileHostIrohRuntime+Activation.swift @@ -0,0 +1,667 @@ +import CMUXMobileCore +import CmuxAuthRuntime +import CmuxIrohTransport +import Foundation + +@MainActor +extension MobileHostIrohRuntime { + func activate(accountID: String, revision: UInt64) async throws { + // The explicit pairing setting and managed policy both gate endpoint + // creation, relay traffic, and route publication. Check them before + // any activation state is mutated. + guard MobileHostService.isListeningEnabled, + ManagedIrohNetworkingPolicy.isEnabled else { + throw CmxIrohHostRuntimeError.inactive + } + beginIrohRouteActivation(revision: revision) + guard let auth else { throw CmxIrohHostRuntimeError.inactive } + // Pin the runtime's broker to the session identity that owns + // `accountID` — a cheap local check now, and every broker request + // below re-reads an ATOMIC authenticated snapshot validated against + // this pin. An A→B account switch therefore makes the old runtime's + // requests fail closed immediately instead of pairing B's credentials + // with A's endpoint/device state (registering or refreshing a binding + // under B and caching it as A) before lifecycle reconciliation runs. + guard auth.currentUser?.id == accountID else { + throw CmxIrohHostRuntimeError.inactive + } + let tag = Self.currentTag() + guard let clientNamespace = CmxIrohMacBundleNamespace( + bundleIdentifier: Bundle.main.bundleIdentifier + ) else { + throw CmxIrohHostRuntimeError.invalidLocalBinding + } + let appInstanceID = try await appInstances.appInstanceID( + accountID: accountID, + tag: tag + ) + let identity = try await identities.identity( + accountID: accountID, + appInstanceID: appInstanceID + ) + let deviceID = cmxCanonicalDeviceID(MobileHostIdentity.deviceID()) + let cachedBinding = try await brokerCredentials.loadBinding( + accountID: accountID, + appInstanceID: appInstanceID + ) + guard let derivedEndpointID = identity.peerIdentity else { + throw CmxIrohHostRuntimeError.invalidLocalBinding + } + let bindingMatches = cachedBinding.map { + $0.deviceID == deviceID + && $0.appInstanceID == appInstanceID + && $0.clientNamespace == clientNamespace.rawValue + && $0.tag == tag + && $0.platform == .mac + && derivedEndpointID == $0.endpointID + && $0.identityGeneration == identity.generation + } ?? false + let cachedManagedRelayURLs: Set + if let relayPolicyTrustRoot, + let cachedPolicy = try? await relayPolicyCache.load( + trustRoot: relayPolicyTrustRoot, + now: Date() + ) { + cachedManagedRelayURLs = Set(cachedPolicy.relays.map(\.url)) + } else { + cachedManagedRelayURLs = [] + } + let cachedRelay: CmxIrohRelayTokenResponse? + if let cachedBinding, bindingMatches { + lastKnownBindingID = cachedBinding.bindingID + lastKnownAccountID = accountID + lastKnownTag = tag + cachedRelay = try await brokerCredentials.loadRelayCredential( + accountID: accountID, + binding: cachedBinding, + expectedRelayFleet: cachedManagedRelayURLs, + now: Date() + ) + } else { + cachedRelay = nil + } + let policyExpectation = try CmxIrohHostPolicyExpectation( + accountID: accountID, + deviceID: deviceID, + appInstanceID: appInstanceID, + clientNamespace: clientNamespace.rawValue, + tag: tag, + endpointID: derivedEndpointID, + identityGeneration: identity.generation, + // The broker must receive the same explicit opt-in as the local + // lifecycle, with managed remote-control policy as a second gate. + pairingEnabled: MobileHostService.isListeningEnabled + && MobileRemoteControlPolicy.isEnabled, + capabilities: Self.capabilities + ) + let cachedHostPolicy: CmxIrohCachedHostPolicy? + do { + cachedHostPolicy = try await hostPolicies.load( + for: policyExpectation, + now: Date() + ) + } catch { + cachedHostPolicy = nil + mobileHostIrohLog.error( + "Iroh offline policy load failed: \(String(describing: error), privacy: .private)" + ) + } + if let cachedHostPolicy { + lastKnownBindingID = cachedHostPolicy.binding.bindingID + lastKnownAccountID = accountID + lastKnownTag = tag + } + + guard let brokerBaseURL = AuthEnvironment.irohBrokerBaseURL else { + throw CmxIrohTrustBrokerClientError.invalidBaseURL + } + let rawBroker = try CmxIrohTrustBrokerClient( + baseURL: brokerBaseURL, + tokenSource: .accountPinned( + to: accountID, + // An ATOMIC authenticated snapshot per fetch, validated + // against the activation's ACCOUNT pin: identity and + // credentials come from one transition-checked capture, so an + // account switch completing while the read is suspended can + // never hand this runtime a DIFFERENT account's credentials, + // and the pin fails requests closed the moment the account + // changes. Deliberately NOT generation-pinned: every completed + // sign-in advances the generation, and a same-account + // re-sign-in must keep this long-lived runtime serviceable — + // it is still the same user, so serving the new session's + // credentials is correct, whereas a generation pin would + // strand the runtime on nil credentials until relaunch. The + // snapshot's pair capture is store-level (no network while the + // stored access token is valid). + snapshot: { [weak auth] in + guard let auth else { return nil } + let session: AuthenticatedSessionSnapshot + do { + session = try await auth.authenticatedSessionSnapshot() + } catch AuthError.unauthorized { + // Definitively signed out: fail closed. + return nil + } + // Transient failures (a revalidation owns the token + // store, a re-mint is in flight or offline) rethrow so + // the broker classifies them connectivity instead of + // tearing the host runtime down as unauthorized. + return CmxIrohAccountCredentialSnapshot( + accountID: session.accountID, + credentials: CmxIrohBrokerCredentials( + accessToken: session.accessToken, + refreshToken: session.refreshToken + ) + ) + }, + forceRefresh: { [weak auth] in + guard let auth else { return } + _ = try await auth.forceRefreshAccessToken() + } + ), + clientNamespace: clientNamespace.rawValue, + discoveryScope: try CmxConnectivityDiscoveryScope( + deviceID: deviceID, + appInstanceID: appInstanceID, + tag: tag, + platform: .mac, + peerPlatform: .ios + ), + backpressureMode: .callerOwned + ) + let broker = CmxIrohBackpressuredHostBroker( + broker: rawBroker, + gate: brokerBackpressureGate, + accountID: accountID + ) + let relayPolicyBroker = CmxIrohBackpressuredRelayPolicyBroker( + broker: rawBroker, + gate: brokerBackpressureGate, + accountID: accountID + ) + let endpointRelayProfile: CmxIrohEndpointRelayProfile? + let managedRelayURLs: Set + let resolvedPolicyService: CmxIrohRelayPolicyService? + let resolvedEffectivePolicy: CmxIrohEffectiveRelayPolicy? + var freshRelayCredential: CmxIrohRelayTokenResponse? + var relayPolicyNeedsImmediateRefresh = false + if let relayPolicyTrustRoot { + let service = CmxIrohRelayPolicyService( + policyCache: relayPolicyCache, + preferenceStore: relayPreferenceStore, + credentialStore: customRelayCredentials, + broker: relayPolicyBroker + ) + let effective: CmxIrohEffectiveRelayPolicy + if protocolConfiguration.allowsNATTraversalAfterAdmission { + // A verified cached policy is sufficient to bind, register, + // and discover. Refresh it immediately after activation so + // broker latency never gates direct-path availability. + effective = await service.restore( + accountID: accountID, + trustRoot: relayPolicyTrustRoot, + relayCredential: cachedRelay, + now: Date() + ) + relayPolicyNeedsImmediateRefresh = true + } else { + // Relay-only verification cannot become active without the + // current signed fleet and credential, so keep its explicit + // readiness barrier. + diagnosticLog.record(DiagnosticEvent(.relayPolicyRefreshStarted)) + do { + let outcome = try await service.refreshWithCredential( + endpointID: derivedEndpointID, + accountID: accountID, + trustRoot: relayPolicyTrustRoot, + now: Date() + ) + effective = outcome.effective + freshRelayCredential = outcome.relayCredential + diagnosticLog.record(DiagnosticEvent(.relayPolicyRefreshSucceeded)) + } catch { + diagnosticLog.record(DiagnosticEvent( + .relayPolicyRefreshFailed, + b: Self.diagnosticFailureKind(for: error).rawValue + )) + effective = await service.restore( + accountID: accountID, + trustRoot: relayPolicyTrustRoot, + relayCredential: cachedRelay, + now: Date() + ) + relayPolicyNeedsImmediateRefresh = true + } + } + endpointRelayProfile = effective.endpointRelayProfile + managedRelayURLs = Set(effective.managedPolicy?.relays.map(\.url) ?? []) + resolvedPolicyService = service + resolvedEffectivePolicy = effective + } else { + switch await customRelayProfiles.loadSelection() { + case .managed: + endpointRelayProfile = nil + case let .custom(profile): + endpointRelayProfile = CmxIrohEndpointRelayProfile(customProfile: profile) + case .customUnavailable: + mobileHostIrohLog.error( + "Custom relay profile unavailable; managed relays remain disabled" + ) + endpointRelayProfile = .unavailableCustomOverride + } + managedRelayURLs = [] + resolvedPolicyService = nil + resolvedEffectivePolicy = nil + } + let compatibleCachedRelay = cachedRelay.flatMap { relay in + Set(relay.relayFleet) == managedRelayURLs ? relay : nil + } + let freshCompatibleRelay = freshRelayCredential.flatMap { relay in + Set(relay.relayFleet) == managedRelayURLs ? relay : nil + } + let configuration = CmxIrohHostRuntimeConfiguration( + accountID: accountID, + deviceID: deviceID, + appInstanceID: appInstanceID, + clientNamespace: clientNamespace, + tag: tag, + displayName: MobileHostIdentity.instanceDisplayName(), + identity: identity, + // The broker must receive the same explicit opt-in as the local + // lifecycle, with managed remote-control policy as a second gate. + pairingEnabled: MobileHostService.isListeningEnabled + && MobileRemoteControlPolicy.isEnabled, + capabilities: Self.capabilities, + bindPolicy: .preferred( + try CmxIrohBindAddress( + ipAddress: "0.0.0.0", + port: UInt16(MobileHostService.configuredPort()) + ) + ), + managedRelayURLs: managedRelayURLs, + endpointRelayProfile: endpointRelayProfile, + cachedRelayCredential: freshCompatibleRelay ?? compatibleCachedRelay, + cachedHostPolicy: cachedHostPolicy + ) + let credentialRepository = brokerCredentials + let hostPolicyCache = hostPolicies + let lanPublisher = lanPublisher + let activeRelayPolicyService = resolvedPolicyService + let hostRuntime = CmxIrohHostRuntime( + factory: CmxIrohLibEndpointFactory( + transportVerificationMode: transportVerificationMode + ), + broker: broker, + configuration: configuration, + pendingRevocations: pendingRevocations, + protocolConfiguration: protocolConfiguration, + handleTransport: { [weak self] session, isCurrent in + guard let self else { + await session.close() + return + } + let diagnosticSessionID = await self.makeDiagnosticSessionID() + let diagnosticLog = self.diagnosticLog + diagnosticLog.record(DiagnosticEvent( + .admissionSucceeded, + a: DiagnosticTransportKind.iroh.rawValue + )) + CmuxEventBus.shared.publish( + name: "mobile.iroh.admission.succeeded", + category: "mobile", + source: "mobile.iroh.host" + ) + diagnosticLog.record(DiagnosticEvent( + .transportSessionLifecycle, + a: DiagnosticSessionLifecycleKind.established.rawValue, + b: Int(CmxTransportSessionPurpose.foregroundControl.rawValue), + c: diagnosticSessionID + )) + let connectionDiagnostics = CmxIrohConnectionDiagnosticRecorder( + diagnosticLog: diagnosticLog, + sessionID: diagnosticSessionID + ) + let pathEvents = await session.observedPathEvents() + let pathEventTask = Task { + for await event in pathEvents { + guard !Task.isCancelled else { return } + connectionDiagnostics.record(event) + } + } + let eventWriter = MobileHostIrohServerEventWriter( + session: session + ) + let artifactTransfers = MobileHostIrohArtifactTransferRegistry() + let laneRouter = MobileHostIrohApplicationLaneRouter( + session: session, + artifactHandler: MobileHostIrohArtifactLaneHandler( + registry: artifactTransfers + ), + simulatorStreamHandler: MobileHostIrohSimulatorStreamLaneHandler() + ) + let connectionSupervisor = CmxIrohAdmittedConnectionSupervisor( + runControl: { + await MobileHostService.acceptTransport( + session.controlTransport, + authorization: .irohAdmission(session.peer), + artifactTransfers: artifactTransfers, + independentEventWriter: eventWriter, + idleTimeoutNanoseconds: 0, + promoteUsableSession: { + await session.markUsable() + }, + isCurrent: isCurrent + ) + }, + runApplicationLanes: { + await laneRouter.run(isCurrent: isCurrent) + }, + closeConnection: { + await session.close() + }, + stopApplicationLanes: { + await laneRouter.stop() + } + ) + let observedExit = await connectionSupervisor.run() + let exit = await session.connectionExit(resolving: observedExit) + await pathEventTask.value + connectionDiagnostics.record(await session.closeAttribution()) + diagnosticLog.record(DiagnosticEvent( + .transportSessionLifecycle, + a: exit.lifecycle.rawValue, + b: Int(CmxTransportSessionPurpose.foregroundControl.rawValue), + c: diagnosticSessionID + )) + diagnosticLog.record(DiagnosticEvent( + .sessionClosed, + a: DiagnosticTransportKind.iroh.rawValue, + b: exit.failure == .none ? nil : exit.failure.rawValue, + c: diagnosticSessionID + )) + }, + handleBinding: { [weak self] registration, discovery, attestation in + let binding = registration.binding + let metadata = CmxIrohBrokerBindingMetadata(binding: binding) + guard await self?.allowsPersistence( + accountID: accountID, + revision: revision + ) == true else { return } + await self?.bindingPersistenceQueue.publishAndEnqueue( + publish: { [weak self] in + self?.recordRegisteredBinding( + binding, + accountID: accountID, + tag: tag, + revision: revision + ) + }, + persist: { [weak self] in + guard let self, + self.allowsPersistence( + accountID: accountID, + revision: revision + ) else { return } + try? await credentialRepository.saveBinding( + metadata, + accountID: accountID + ) + guard self.allowsPersistence( + accountID: accountID, + revision: revision + ) else { return } + if let attestation, + let discovered = discovery.bindings.first(where: { + $0.bindingID == binding.bindingID + }) { + do { + let policy = try CmxIrohCachedHostPolicy( + binding: discovered, + grantVerificationKeys: discovery.grantVerificationKeys, + endpointAttestation: attestation, + lanRendezvous: discovery.lanRendezvous + ) + try await hostPolicyCache.save( + policy, + for: policyExpectation, + now: Date() + ) + } catch { + try? await hostPolicyCache.delete(for: policyExpectation) + mobileHostIrohLog.error( + "Iroh offline policy cache rejected: \(String(describing: error), privacy: .private)" + ) + } + } else if cachedHostPolicy?.binding != metadata { + try? await hostPolicyCache.delete(for: policyExpectation) + } + } + ) + }, + handleRoute: { [weak self] binding, pathHints in + guard await self?.allowsPersistence( + accountID: accountID, + revision: revision + ) == true else { return } + await self?.recordActiveRoute( + binding, + pathHints: pathHints, + accountID: accountID, + tag: tag, + revision: revision + ) + }, + handleDeactivation: { [weak self] _ in + await self?.handleActiveRuntimeDeactivation( + revision: revision, + stopLANPublication: { + await lanPublisher.stop() + }, + clearHostRuntime: { + // The runtime owns the local Mac binding, while admitted + // sessions carry remote iOS binding IDs. Endpoint teardown + // therefore closes every Iroh-authorized connection and + // leaves Tailscale/other private-network sessions intact. + MobileHostService.shared.closeAllIrohConnections() + } + ) + }, + handleRelayCredential: { [weak self] response, binding in + guard await self?.allowsPersistence( + accountID: accountID, + revision: revision + ) == true else { return } + let expectedRelayFleet = await activeRelayPolicyService?.managedPolicy() + .map { Set($0.relays.map(\.url)) } ?? managedRelayURLs + try? await credentialRepository.saveRelayCredential( + response, + accountID: accountID, + binding: binding, + expectedRelayFleet: expectedRelayFleet, + now: Date() + ) + }, + handleLANRefresh: { + guard MobileHostService.isListeningEnabled else { + await lanPublisher.stop() + return + } + await lanPublisher.refresh() + }, + handleLANPolicy: { context, directAddresses in + guard MobileHostService.isListeningEnabled else { + await lanPublisher.stop() + return + } + await lanPublisher.activate( + rendezvous: context.rendezvous, + binding: context.binding, + directAddresses: directAddresses + ) + } + ) + + do { + try await hostRuntime.start() + } catch { + if revision != lifecycleRevision || Task.isCancelled { + runtime = hostRuntime + activeAccountID = accountID + activeAppInstanceID = appInstanceID + throw CancellationError() + } + await hostRuntime.stop() + clearIrohRoutePublication(revision: revision) + throw error + } + guard revision == lifecycleRevision, + !Task.isCancelled, + !signOutIntentActive, + MobileHostService.isListeningEnabled, + desiredActive, + observedAccountID == accountID else { + // The succeeding reconcile owns this runtime. Retaining it lets a + // sign-out or account-switch transition capture a binding that was + // registered while activation was being superseded. + runtime = hostRuntime + activeAccountID = accountID + activeAppInstanceID = appInstanceID + throw CancellationError() + } + runtime = hostRuntime + activeAccountID = accountID + activeAppInstanceID = appInstanceID + _ = publishIrohRouteIfActive(revision: revision) + diagnosticLog.record(DiagnosticEvent( + .endpointActive, + a: DiagnosticTransportKind.iroh.rawValue + )) + relayPolicyService = resolvedPolicyService + relayPolicyEffective = resolvedEffectivePolicy + relayPolicyDiagnostics = await resolvedPolicyService?.diagnosticsSnapshot() + relayPolicyEndpointID = derivedEndpointID + observeSelectedPathChanges( + runtime: hostRuntime, + accountID: accountID, + revision: revision + ) + observeRelayPolicyDiagnostics( + service: resolvedPolicyService, + accountID: accountID, + revision: revision + ) + scheduleRelayPolicyRefresh( + service: resolvedPolicyService, + accountID: accountID, + endpointID: derivedEndpointID, + trustRoot: relayPolicyTrustRoot, + revision: revision, + refreshImmediately: relayPolicyNeedsImmediateRefresh + ) + publishIrohSettingsUpdate() + if preparedSignOut?.pendingRevocation?.accountID == accountID { + preparedSignOut = nil + } + } + + private func recordRegisteredBinding( + _ binding: CmxIrohBrokerBinding, + accountID: String, + tag: String, + revision: UInt64 + ) { + guard allowsPersistence( + accountID: accountID, + revision: revision + ) else { return } + lastKnownBindingID = binding.bindingID + lastKnownAccountID = accountID + lastKnownTag = tag + if preparedSignOut?.pendingRevocation?.accountID == accountID { + preparedSignOut = nil + } + } + + private func recordActiveRoute( + _ binding: CmxIrohBrokerBindingMetadata, + pathHints: [CmxIrohPathHint], + accountID: String, + tag: String, + revision: UInt64 + ) { + guard revision == lifecycleRevision, + MobileHostService.isListeningEnabled else { return } + lastKnownBindingID = binding.bindingID + lastKnownAccountID = accountID + lastKnownTag = tag + if preparedSignOut?.pendingRevocation?.accountID == accountID { + preparedSignOut = nil + } + stageIrohRoute(binding, pathHints: pathHints, revision: revision) + if runtime != nil, activeAccountID == accountID { + _ = publishIrohRouteIfActive(revision: revision) + } + } + + /// Starts a new availability generation. Persisted broker identity is not + /// a dialable route until the matching endpoint reports active. + func beginIrohRouteActivation(revision: UInt64) { + guard revision == lifecycleRevision, + MobileHostService.isListeningEnabled else { return } + pendingIrohRouteBinding = nil + routePublicationPhase = .starting(revision: revision) + MobileHostService.shared.updateIrohRoute(identity: nil) + } + + func stageIrohRoute( + _ binding: CmxIrohBrokerBindingMetadata, + pathHints: [CmxIrohPathHint], + revision: UInt64 + ) { + guard revision == lifecycleRevision, + MobileHostService.isListeningEnabled else { return } + pendingIrohRouteBinding = ( + revision: revision, + binding: binding, + pathHints: pathHints + ) + } + + /// Publishes only the binding staged by the activation generation whose + /// endpoint has completed `start()`. + @discardableResult + func publishIrohRouteIfActive(revision: UInt64) -> Bool { + guard revision == lifecycleRevision, + MobileHostService.isListeningEnabled, + let pendingIrohRouteBinding, + pendingIrohRouteBinding.revision == revision else { return false } + self.pendingIrohRouteBinding = nil + routePublicationPhase = .active( + revision: revision, + binding: pendingIrohRouteBinding.binding + ) + MobileHostService.shared.updateIrohRoute( + identity: pendingIrohRouteBinding.binding.endpointID, + pathHints: pendingIrohRouteBinding.pathHints + ) + return true + } + + func clearIrohRoutePublication(revision: UInt64? = nil) { + if let revision, revision != lifecycleRevision { return } + pendingIrohRouteBinding = nil + routePublicationPhase = .unavailable + MobileHostService.shared.updateIrohRoute(identity: nil) + } + + private func allowsPersistence( + accountID: String, + revision: UInt64 + ) -> Bool { + revision == lifecycleRevision + && MobileHostService.isListeningEnabled + && !signOutIntentActive + && desiredActive + && observedAccountID == accountID + } +} diff --git a/Sources/Mobile/MobileHostIrohRuntime+Lifecycle.swift b/Sources/Mobile/MobileHostIrohRuntime+Lifecycle.swift new file mode 100644 index 000000000000..3f958fa1a213 --- /dev/null +++ b/Sources/Mobile/MobileHostIrohRuntime+Lifecycle.swift @@ -0,0 +1,682 @@ +import CMUXMobileCore +import CmuxAuthRuntime +import CmuxIrohTransport +import Foundation + +extension MobileHostIrohRuntime { + var transportVerificationMode: CmxIrohTransportVerificationMode { + #if DEBUG + Self.debugTransportVerificationMode(defaults: .standard) + #else + .automatic + #endif + } + + var protocolConfiguration: CmxIrohProtocolConfiguration { + Self.protocolConfiguration(for: transportVerificationMode) + } + + static func protocolConfiguration( + for mode: CmxIrohTransportVerificationMode + ) -> CmxIrohProtocolConfiguration { + CmxIrohProtocolConfiguration( + alpn: CmxIrohProtocolConfiguration.cmuxMobileV1.alpn, + maximumHeaderByteCount: CmxIrohProtocolConfiguration.cmuxMobileV1.maximumHeaderByteCount, + maximumConcurrentClientApplicationLaneCount: + MobileHostIrohApplicationLaneRouter.maximumConcurrentLaneCount, + allowsNATTraversalAfterAdmission: mode.allowsNATTraversalAfterAdmission + ) + } + + #if DEBUG + /// Resolves DEBUG overrides before the release-safe path preference. + static func debugTransportVerificationMode( + defaults: UserDefaults + ) -> CmxIrohTransportVerificationMode { + if let rawValue = defaults.string( + forKey: CmxIrohTransportVerificationMode.debugDefaultsKey + ), let mode = CmxIrohTransportVerificationMode(rawValue: rawValue) { + return mode + } + if defaults.bool(forKey: debugRelayOnlyDefaultsKey) { + return .relayOnly + } + return .automatic + } + + static var isDebugRelayOnlyEnabled: Bool { + debugTransportVerificationMode(defaults: .standard) == .relayOnly + } + #endif + + /// Fences lifecycle work before auth begins its first asynchronous token read. + func beginSignOutPreparation() { + guard signOutPreparationTask == nil else { return } + signOutIntentActive = true + signOutPreparationRevision &+= 1 + let task = scheduleReconcile(eraseAccountState: true) + signOutPreparationTask = task + } + + func prepareSignOut() async { + beginSignOutPreparation() + await signOutPreparationTask?.value + } + + /// Uses auth's captured tokens to revoke the exact preparation made before clear. + func revokeAfterSignOut( + accessToken: String?, + refreshToken: String? + ) async { + observedAccountID = nil + if let signOutPreparationTask { + guard await cancellationAwareWait(for: signOutPreparationTask) else { + return + } + } else if preparedSignOut == nil { + beginSignOutPreparation() + if let signOutPreparationTask { + guard await cancellationAwareWait(for: signOutPreparationTask) else { + return + } + } + } + defer { + signOutIntentActive = false + signOutPreparationTask = nil + } + + guard var preparation = preparedSignOut else { return } + guard let pendingRevocation = preparation.pendingRevocation else { + preparedSignOut = nil + return + } + preparation = await retryPersistingQuarantinedPreparation(preparation) + + guard let accessToken, + !accessToken.isEmpty, + let refreshToken, + !refreshToken.isEmpty else { return } + do { + guard let brokerBaseURL = AuthEnvironment.irohBrokerBaseURL else { + throw CmxIrohTrustBrokerClientError.invalidBaseURL + } + guard let clientNamespace = CmxIrohMacBundleNamespace( + bundleIdentifier: Bundle.main.bundleIdentifier + ) else { + throw CmxIrohHostRuntimeError.invalidLocalBinding + } + let requestClientNamespace = preparation.bindingAuthorization? + .clientNamespace ?? clientNamespace.rawValue + let rawBroker = try CmxIrohTrustBrokerClient( + baseURL: brokerBaseURL, + tokenSource: CmxIrohBrokerTokenSource( + // The pair was captured together up front, so it is coherent + // by construction. + credentialPair: { + CmxIrohBrokerCredentials( + accessToken: accessToken, + refreshToken: refreshToken + ) + } + ), + clientNamespace: requestClientNamespace, + bindingAuthorization: preparation.bindingAuthorization, + backpressureMode: .callerOwned + ) + let broker = CmxIrohBackpressuredHostBroker( + broker: rawBroker, + gate: brokerBackpressureGate, + accountID: pendingRevocation.accountID + ) + try await preparation.revoke( + using: broker, + pendingRevocations: pendingRevocations + ) + if !preparation.wasPersisted { + await wipePersistedAccountState( + after: CmxIrohHostSignOutPreparation( + pendingRevocation: preparation.pendingRevocation, + wasPersisted: true, + bindingAuthorization: preparation.bindingAuthorization + ) + ) + } + if preparedSignOut?.pendingRevocation == preparation.pendingRevocation { + preparedSignOut = nil + } + } catch { + mobileHostIrohLog.error( + "Iroh binding revoke failed: \(String(describing: error), privacy: .private)" + ) + } + } + + private func cancellationAwareWait( + for operation: Task + ) async -> Bool { + let stream = AsyncStream { continuation in + let waiter = Task { @MainActor in + await operation.value + guard !Task.isCancelled else { + continuation.finish() + return + } + continuation.yield() + continuation.finish() + } + continuation.onTermination = { @Sendable _ in + waiter.cancel() + } + } + for await _ in stream { + return true + } + return false + } + + func configure(auth: AuthCoordinator) { + self.auth = auth + authObservationTask?.cancel() + authObservationTask = Task { @MainActor [weak self] in + await auth.awaitBootstrapped() + guard !Task.isCancelled, let self else { return } + let states = self.authObserver.states(for: auth) + for await state in states { + guard !Task.isCancelled else { return } + let previousAccountID = self.observedAccountID + self.observedAccountID = state.accountID + if self.signOutIntentActive { + if state.accountID == nil { + self.releaseSignOutIntentAfterPreparation() + } + continue + } + guard Self.shouldReconcileAuthObservation( + accountID: state.accountID, + previousAccountID: previousAccountID, + activeAccountID: self.activeAccountID, + hasRuntime: self.runtime != nil, + transitionInFlight: self.transitionTask != nil, + preparedSignOutNeedsPersistence: self.preparedSignOut?.wasPersisted == false + ) else { continue } + self.scheduleReconcile( + eraseAccountState: (state.accountID == nil + && (previousAccountID != nil + || self.activeAccountID != nil + || self.runtime != nil)) + || (previousAccountID != nil + && previousAccountID != state.accountID) + || (self.activeAccountID != nil + && self.activeAccountID != state.accountID) + || self.preparedSignOut?.wasPersisted == false + ) + } + } + } + + /// Cancels auth-driven wakeups immediately and queues a non-destructive + /// runtime stop. Persisted identity and account state stay intact so a + /// later opt-in can reuse the same pairing identity. + func beginPairingOptOut() { + authObservationTask?.cancel() + authObservationTask = nil + auth = nil + observedAccountID = nil + desiredActive = false + scheduleReconcile(eraseAccountState: false) + } + + static func shouldReconcileAuthObservation( + accountID: String?, + previousAccountID: String?, + activeAccountID: String?, + hasRuntime: Bool, + transitionInFlight: Bool, + preparedSignOutNeedsPersistence: Bool + ) -> Bool { + let hasRelevantState = accountID != nil + || previousAccountID != nil + || activeAccountID != nil + || hasRuntime + guard hasRelevantState else { return false } + if preparedSignOutNeedsPersistence { return true } + if accountID != previousAccountID { return true } + if let activeAccountID, activeAccountID != accountID { return true } + guard let accountID else { return hasRuntime } + guard !transitionInFlight else { return false } + return activeAccountID != accountID || !hasRuntime + } + + private func releaseSignOutIntentAfterPreparation() { + guard let signOutPreparationTask else { + signOutIntentActive = false + return + } + let revision = signOutPreparationRevision + Task { @MainActor [weak self] in + await signOutPreparationTask.value + guard let self, + self.signOutPreparationRevision == revision, + self.observedAccountID == nil else { return } + self.signOutIntentActive = false + self.signOutPreparationTask = nil + } + } + + func setDesiredActive(_ requested: Bool) { + // Apply the transport policy to every activation/retry entry point, + // including settings reconciliation while a runtime is already live. + let desired = requested + && MobileHostService.isListeningEnabled + && ManagedIrohNetworkingPolicy.isEnabled + guard desiredActive != desired else { + if desired { retryIfNeeded() } + return + } + desiredActive = desired + guard !signOutIntentActive else { return } + scheduleReconcile(eraseAccountState: false) + } + + func retryIfNeeded() { + guard !signOutIntentActive, + MobileHostService.isListeningEnabled, + desiredActive, + observedAccountID != nil else { return } + if preparedSignOut?.wasPersisted == false { + scheduleReconcile(eraseAccountState: true) + return + } + // Network-path observations are freshness hints, not ownership + // transitions. The in-flight activation already observes endpoint + // changes and replays one pending registration refresh after startup. + guard transitionTask == nil else { return } + guard let activeRuntime = runtime else { + scheduleReconcile(eraseAccountState: false) + return + } + let revision = lifecycleRevision + cancelRetryInspection() + retryInspectionRevision &+= 1 + let inspectionRevision = retryInspectionRevision + retryInspectionTask = Task { @MainActor [weak self] in + defer { + if let self, + self.retryInspectionRevision == inspectionRevision { + self.retryInspectionTask = nil + } + } + guard let self, + self.retryInspectionRevision == inspectionRevision, + MobileHostService.isListeningEnabled, + self.desiredActive, + self.runtime === activeRuntime, + revision == self.lifecycleRevision else { return } + if await activeRuntime.snapshot().state == .failed { + guard MobileHostService.isListeningEnabled, + self.desiredActive, + !self.signOutIntentActive, + self.runtime === activeRuntime, + self.retryInspectionRevision == inspectionRevision, + revision == self.lifecycleRevision else { return } + // A fresh external signal resets the backoff ladder, then uses + // the single guarded recovery entrypoint. + self.retryInspectionTask = nil + self.cancelFailureRecovery(resetBackoff: true) + await self.recoverFailedRuntimeIfNeeded() + return + } + guard MobileHostService.isListeningEnabled, + self.runtime === activeRuntime, + revision == self.lifecycleRevision else { return } + await self.synchronizeLANPublicationWithSettings() + } + } + + /// Arms one pending rebuild after bounded exponential backoff. + /// + /// `CmxIrohHostRuntime` fails closed on a non-transient broker rejection + /// (for example 401/403/409): it tears the endpoint down into a terminal + /// `.failed` phase so a rejected binding can never keep accepting + /// connections. Recovery is owned here instead: every failure arms one + /// rebuild through the shared `reconcile` path, and any external wake + /// signal (`retryIfNeeded`, sign-in, settings) re-evaluates immediately, + /// so a rejected registration recovers without an app relaunch. + /// Idempotent while an attempt is pending, so overlapping failure signals + /// (an activation throw plus the runtime's deactivation callback) cannot + /// double-schedule. + func scheduleFailureRecovery() { + guard failureRecoveryTask == nil, + MobileHostService.isListeningEnabled, + desiredActive, + !signOutIntentActive, + observedAccountID != nil else { return } + let delay = failureRecoverySchedule.delay( + failureCount: failureRecoveryFailureCount, + retryAfterSeconds: nil, + jitterUnitInterval: failureRecoveryJitter() + ) + failureRecoveryFailureCount = min(failureRecoveryFailureCount + 1, 20) + let clock = failureRecoveryClock + let deadline = clock.now().addingTimeInterval(delay) + mobileHostIrohLog.error( + "Iroh host runtime failed; rebuild attempt \(self.failureRecoveryFailureCount) in \(Int(delay))s" + ) + failureRecoveryTask = Task { @MainActor [weak self] in + do { + try await clock.sleep(until: deadline) + } catch { + return + } + guard let self, !Task.isCancelled else { return } + self.failureRecoveryTask = nil + await self.recoverFailedRuntimeIfNeeded() + } + } + + /// Rebuilds the host runtime when it is absent or terminally failed. + /// Level-triggered: the action is re-derived from current state, so a + /// stale wake-up is a no-op rather than a disruption. + func recoverFailedRuntimeIfNeeded() async { + guard MobileHostService.isListeningEnabled, + desiredActive, + !signOutIntentActive, + observedAccountID != nil, + transitionTask == nil else { return } + guard let activeRuntime = runtime else { + scheduleReconcile(eraseAccountState: false) + return + } + let state = await activeRuntime.snapshot().state + guard state == .failed, + runtime === activeRuntime, + transitionTask == nil, + MobileHostService.isListeningEnabled, + desiredActive, + !signOutIntentActive else { return } + scheduleReconcile(eraseAccountState: false, restartActiveRuntime: true) + } + + /// Clears shared host state only while this deactivation still owns the + /// composition-root lifecycle revision. Re-check after the suspending LAN + /// stop so a replacement activation cannot be torn down by an older + /// runtime's late callback. + func handleActiveRuntimeDeactivation( + revision: UInt64, + stopLANPublication: @MainActor @Sendable () async -> Void, + clearHostRuntime: @MainActor @Sendable () -> Void + ) async { + guard ownsDeactivationCleanup(revision: revision) else { return } + await stopLANPublication() + guard ownsDeactivationCleanup(revision: revision) else { return } + clearHostRuntime() + clearIrohRoutePublication(revision: revision) + guard ownsDeactivationCleanup(revision: revision) else { return } + await noteActiveRuntimeDeactivated(revision: revision) + } + + private func ownsDeactivationCleanup(revision: UInt64) -> Bool { + revision == lifecycleRevision + && MobileHostService.isListeningEnabled + && desiredActive + && !signOutIntentActive + } + + /// Invoked from the active runtime's deactivation handler. Deliberate + /// stops (reconcile, settings restart, sign-out) bump `lifecycleRevision` + /// before stopping, so a matching revision means the runtime tore itself + /// down after a registration-refresh failure. Failed cold starts throw + /// before `runtime` is assigned; `reconcile`'s failure path owns + /// scheduling for those. + func noteActiveRuntimeDeactivated(revision: UInt64) async { + guard revision == lifecycleRevision, + MobileHostService.isListeningEnabled, + desiredActive, + !signOutIntentActive, + let activeRuntime = runtime else { return } + let state = await activeRuntime.snapshot().state + guard state == .failed, + revision == lifecycleRevision, + runtime === activeRuntime else { return } + scheduleFailureRecovery() + } + + func cancelFailureRecovery(resetBackoff: Bool) { + cancelRetryInspection() + failureRecoveryTask?.cancel() + failureRecoveryTask = nil + if resetBackoff { + failureRecoveryFailureCount = 0 + } + } + + func cancelRetryInspection() { + retryInspectionRevision &+= 1 + retryInspectionTask?.cancel() + retryInspectionTask = nil + } + + /// Applies the explicit iOS pairing setting to LAN publication. The same + /// setting also owns the authenticated Iroh endpoint and broker binding. + func synchronizeLANPublicationWithSettings() async { + guard MobileHostService.isListeningEnabled else { + await lanPublisher.stop() + await recordLANPublicationState(reason: 1) + return + } + guard desiredActive, + let runtime, + let context = await runtime.lanAdvertisementContext() else { + await lanPublisher.stop() + await recordLANPublicationState(reason: 2) + return + } + await lanPublisher.activate( + rendezvous: context.rendezvous, + binding: context.binding, + directAddresses: { await runtime.localDirectAddresses() } + ) + await recordLANPublicationState(reason: 0) + } + + /// Records the publisher's resulting state so relay-free bootstrap + /// failures can distinguish a Mac that never advertised. `reason` is + /// 0 settings applied, 1 listener setting disabled, 2 runtime context + /// unavailable. + private func recordLANPublicationState(reason: Int) async { + let state: DiagnosticLANPublicationState = + switch await lanPublisher.snapshot() { + case .inactive: .inactive + case .active: .active + case .unavailable: .unavailable + case .policyDenied: .policyDenied + } + diagnosticLog.record(DiagnosticEvent( + .lanPublicationState, + a: state.rawValue, + b: reason + )) + } + + /// Stops the endpoint and durably quarantines its binding before auth clears tokens. + func quarantineForSignOut() async { + let preparation: CmxIrohHostSignOutPreparation + if let runtime { + preparation = await runtime.deactivateForSignOut() + } else { + preparation = await prepareWithoutRuntime() + } + preparedSignOut = preparation + await lanPublisher.stop() + if preparation.wasPersisted { + await wipePersistedAccountState(after: preparation) + } else { + mobileHostIrohLog.error( + "Iroh binding quarantine persistence failed; account state retained" + ) + } + await diagnosticLog.clear() + } + + func prepareWithoutRuntime() async -> CmxIrohHostSignOutPreparation { + let pending: CmxIrohPendingRevocation? + if preparedSignOut?.wasPersisted == false { + pending = preparedSignOut?.pendingRevocation + } else { + pending = currentPendingRevocation() + ?? preparedSignOut?.pendingRevocation + } + var wasPersisted = pending == nil || preparedSignOut?.wasPersisted == true + if let pending, !wasPersisted { + do { + try await pendingRevocations.enqueue(pending) + wasPersisted = true + } catch { + mobileHostIrohLog.error( + "Iroh binding quarantine persistence failed: \(String(describing: error), privacy: .private)" + ) + } + } + return CmxIrohHostSignOutPreparation( + pendingRevocation: pending, + wasPersisted: wasPersisted, + bindingAuthorization: preparedSignOut?.bindingAuthorization + ) + } + + func retryPersistingQuarantinedPreparation( + _ preparation: CmxIrohHostSignOutPreparation + ) async -> CmxIrohHostSignOutPreparation { + guard !preparation.wasPersisted else { return preparation } + let retried: CmxIrohHostSignOutPreparation + if let runtime { + retried = await runtime.deactivateForSignOut() + } else { + retried = await prepareWithoutRuntime() + } + guard retried.pendingRevocation == preparation.pendingRevocation else { + mobileHostIrohLog.error( + "Iroh binding quarantine retry returned a different binding" + ) + return preparation + } + preparedSignOut = retried + if retried.wasPersisted { + await wipePersistedAccountState(after: retried) + } + return retried + } + + func wipePersistedAccountState( + after preparation: CmxIrohHostSignOutPreparation + ) async { + guard preparation.wasPersisted else { return } + let accountID = activeAccountID ?? lastKnownAccountID + do { + try await hostPolicies.deactivate() + } catch { + mobileHostIrohLog.error( + "Iroh offline policy deletion failed: \(String(describing: error), privacy: .private)" + ) + } + do { + try await brokerCredentials.deactivate() + } catch { + mobileHostIrohLog.error( + "Iroh broker credential deletion failed: \(String(describing: error), privacy: .private)" + ) + } + do { + try await identities.deactivate() + } catch { + mobileHostIrohLog.error( + "Iroh identity deletion failed: \(String(describing: error), privacy: .private)" + ) + } + if let accountID { + try? await relayPreferenceStore.deactivate(accountID: accountID) + try? await customRelayCredentials.deactivate(accountID: accountID) + } + await appInstances.deactivate() + clearRelayPolicyRuntimeState() + runtime = nil + activeAccountID = nil + activeAppInstanceID = nil + lastKnownBindingID = nil + lastKnownAccountID = nil + lastKnownTag = nil + } + + func currentPendingRevocation() -> CmxIrohPendingRevocation? { + guard let accountID = lastKnownAccountID ?? activeAccountID, + let tag = lastKnownTag, + let bindingID = lastKnownBindingID else { return nil } + return try? CmxIrohPendingRevocation( + accountID: accountID, + tag: tag, + bindingID: bindingID + ) + } + + #if DEBUG + static func developmentStoreDirectory(service: String) -> URL { + let rawBundleScope = Bundle.main.bundleIdentifier + ?? "com.cmuxterm.app.debug" + let bundleScope = String(rawBundleScope.map { character in + character.isASCII + && (character.isLetter + || character.isNumber + || ["-", ".", "_"].contains(character)) + ? character + : "_" + }) + let applicationSupport = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + )[0] + return applicationSupport + .appendingPathComponent("cmux", isDirectory: true) + .appendingPathComponent("iroh-debug", isDirectory: true) + .appendingPathComponent(bundleScope, isDirectory: true) + .appendingPathComponent(service, isDirectory: true) + } + #endif + + static func currentTag( + environment: [String: String] = ProcessInfo.processInfo.environment, + bundleIdentifier: String? = Bundle.main.bundleIdentifier + ) -> String { + MobileHostIdentity.instanceTag( + environment: environment, + bundleIdentifier: bundleIdentifier + ) + } +} + +#if DEBUG +extension MobileHostIrohRuntime: CmxIrohDebugSettingsControlling { + func setIrohDebugRelayOnly(_ enabled: Bool) async throws { + let mode: CmxIrohTransportVerificationMode = enabled ? .relayOnly : .automatic + await setIrohDebugTransportVerificationMode(mode) + } + + /// Applies one Debug-only path constraint through the same runtime restart + /// boundary used by Settings and the Debug menu. + func setIrohDebugTransportVerificationMode( + _ mode: CmxIrohTransportVerificationMode + ) async { + guard transportVerificationMode != mode else { return } + UserDefaults.standard.set( + mode.rawValue, + forKey: CmxIrohTransportVerificationMode.debugDefaultsKey + ) + UserDefaults.standard.removeObject(forKey: Self.debugRelayOnlyDefaultsKey) + publishIrohSettingsUpdate() + await scheduleReconcile( + eraseAccountState: false, + restartActiveRuntime: true + ).value + } +} +#endif diff --git a/Sources/Mobile/MobileHostIrohRuntime.swift b/Sources/Mobile/MobileHostIrohRuntime.swift new file mode 100644 index 000000000000..5d60cd163fa4 --- /dev/null +++ b/Sources/Mobile/MobileHostIrohRuntime.swift @@ -0,0 +1,396 @@ +import CMUXMobileCore +import CmuxAuthRuntime +import CmuxIrohTransport +import CryptoKit +import Foundation +import Observation +import OSLog + +let mobileHostIrohLog = Logger( + subsystem: "dev.cmux", + category: "mobile-host-iroh" +) + +/// Stages binding state synchronously while secure persistence drains on a +/// lifecycle-cancellable, latest-value serial lane. Live route publication is +/// owned separately by `MobileHostIrohRuntime` after endpoint activation. +@MainActor +final class MobileHostIrohPersistenceQueue { + typealias Operation = @MainActor @Sendable () async -> Void + + private var pending: Operation? + private var worker: Task? + private var generation: UInt64 = 0 + + func publishAndEnqueue( + publish: @MainActor () -> Void, + persist: @escaping Operation + ) { + publish() + pending = persist + guard worker == nil else { return } + startWorker(generation: generation) + } + + func cancel() { + generation &+= 1 + pending = nil + worker?.cancel() + worker = nil + } + + private func startWorker(generation: UInt64) { + worker = Task { @MainActor [weak self] in + await self?.drain(generation: generation) + } + } + + private func drain(generation expectedGeneration: UInt64) async { + while generation == expectedGeneration, + !Task.isCancelled, + let operation = pending { + pending = nil + await operation() + } + guard generation == expectedGeneration else { return } + worker = nil + if pending != nil, !Task.isCancelled { + startWorker(generation: expectedGeneration) + } + } +} + +/// macOS composition root for the account-scoped Iroh host runtime. +@MainActor +final class MobileHostIrohRuntime { + enum RoutePublicationPhase: Equatable { + case unavailable + case starting(revision: UInt64) + case active(revision: UInt64, binding: CmxIrohBrokerBindingMetadata) + } + + enum SettingsError: Error, Equatable { + case unavailable + case incompleteCustomRelay + case missingCustomRelay + } + static let shared = MobileHostIrohRuntime() + + static let capabilities = [ + "mobile-rpc-v1", + "multistream-v1", + MobileHostService.irohPrivatePathsCapability, + ] + #if DEBUG + static let debugRelayOnlyDefaultsKey = "cmux.iroh.debug.relay-only" + #endif + + let appInstances: CmxIrohAppInstanceRepository + let identities: CmxIrohIdentityRepository + let brokerCredentials: CmxIrohBrokerCredentialRepository + let brokerBackpressureGate: CmxIrohBrokerBackpressureGate + let hostPolicies: CmxIrohHostPolicyCache + let pendingRevocations: CmxIrohPendingRevocationOutbox + let customRelayProfiles: CmxIrohCustomRelayProfileStore + let relayPolicyCache: CmxIrohRelayPolicyCache + let relayPreferenceStore: CmxIrohRelayPreferenceStore + let customRelayCredentials: CmxIrohCustomRelayCredentialStore + let relayPolicyTrustRoot: CmxIrohRelayPolicyTrustRoot? + let lanPublisher: CmxIrohLANHostPublisher + /// Release-safe, bounded host-side connection timeline. Event payloads are + /// fixed numeric categories, never peer identities, addresses, or tokens. + let diagnosticLog: DiagnosticLog + let authObserver = MobileHostIrohAuthObserver() + let bindingPersistenceQueue = MobileHostIrohPersistenceQueue() + + weak var auth: AuthCoordinator? + var authObservationTask: Task? + var transitionTask: Task? + var runtime: CmxIrohHostRuntime? + var relayPolicyService: CmxIrohRelayPolicyService? + var relayPolicyEffective: CmxIrohEffectiveRelayPolicy? + var relayPolicyDiagnostics: CmxIrohRelayDiagnosticsSnapshot? + var relayPolicyEndpointID: CmxIrohPeerIdentity? + var relayPolicyObservationTask: Task? + var relayPolicyRefreshTask: Task? + var selectedPathObservationTask: Task? + var irohSettingsContinuations: [UUID: AsyncStream.Continuation] = [:] + var desiredActive = false + var observedAccountID: String? + var activeAccountID: String? + var activeAppInstanceID: String? + var lastKnownAccountID: String? + var lastKnownTag: String? + var lastKnownBindingID: String? + var pendingIrohRouteBinding: ( + revision: UInt64, + binding: CmxIrohBrokerBindingMetadata, + pathHints: [CmxIrohPathHint] + )? + var routePublicationPhase: RoutePublicationPhase = .unavailable + var preparedSignOut: CmxIrohHostSignOutPreparation? + var signOutIntentActive = false + var signOutPreparationTask: Task? + var signOutPreparationRevision: UInt64 = 0 + var lifecycleRevision: UInt64 = 0 + var nextDiagnosticSessionID = 0 + var failureRecoveryTask: Task? + var retryInspectionTask: Task? + var retryInspectionRevision: UInt64 = 0 + var failureRecoveryFailureCount = 0 + var failureRecoveryClock: any CmxIrohRelayClock = CmxIrohSystemRelayClock() + var failureRecoverySchedule = CmxIrohRetrySchedule() + var failureRecoveryJitter: @Sendable () -> Double = { + Double.random(in: 0 ... 1) + } + var relayPolicyRetryJitter: @Sendable () -> Double = { + Double.random(in: 0 ... 1) + } + /// Single-flight owner for revision reconciliation: one task in flight, + /// later signals coalesce at the greatest observed revision. + var serverSignalRefreshTask: Task? + var serverSignalPendingRevision: UInt64? + + private init() { + let installState = CmxIrohUserDefaultsInstallStateStore() + diagnosticLog = Self.hostDiagnosticLog + appInstances = CmxIrohAppInstanceRepository(store: installState) + brokerBackpressureGate = CmxIrohBrokerBackpressureGate(store: installState) + #if DEBUG + identities = CmxIrohIdentityRepository( + secureStore: CmxIrohDevelopmentFileIdentityStore( + directory: Self.developmentStoreDirectory(service: "identity") + ), + installState: installState + ) + brokerCredentials = CmxIrohBrokerCredentialRepository( + secureStore: CmxIrohDevelopmentFileCredentialStore( + directory: Self.developmentStoreDirectory( + service: "broker-credentials" + ) + ), + installState: installState + ) + hostPolicies = CmxIrohHostPolicyCache( + secureStore: CmxIrohDevelopmentFileCredentialStore( + directory: Self.developmentStoreDirectory(service: "host-policy") + ) + ) + pendingRevocations = CmxIrohPendingRevocationOutbox( + secureStore: CmxIrohDevelopmentFileCredentialStore( + directory: Self.developmentStoreDirectory( + service: "pending-revocations" + ) + ) + ) + customRelayProfiles = CmxIrohCustomRelayProfileStore( + secureStore: CmxIrohDevelopmentFileCredentialStore( + directory: Self.developmentStoreDirectory(service: "custom-relays") + ) + ) + relayPolicyCache = CmxIrohRelayPolicyCache( + secureStore: CmxIrohDevelopmentFileCredentialStore( + directory: Self.developmentStoreDirectory(service: "relay-policy") + ) + ) + relayPreferenceStore = CmxIrohRelayPreferenceStore( + secureStore: CmxIrohDevelopmentFileCredentialStore( + directory: Self.developmentStoreDirectory(service: "relay-preference") + ) + ) + customRelayCredentials = CmxIrohCustomRelayCredentialStore( + secureStore: CmxIrohDevelopmentFileCredentialStore( + directory: Self.developmentStoreDirectory(service: "custom-relay-credentials") + ) + ) + #else + identities = CmxIrohIdentityRepository(installState: installState) + brokerCredentials = CmxIrohBrokerCredentialRepository( + installState: installState + ) + hostPolicies = CmxIrohHostPolicyCache() + pendingRevocations = CmxIrohPendingRevocationOutbox( + secureStore: CmxIrohKeychainCredentialStore( + service: "com.cmuxterm.iroh.pending-revocations.v1" + ) + ) + customRelayProfiles = CmxIrohCustomRelayProfileStore() + relayPolicyCache = CmxIrohRelayPolicyCache() + relayPreferenceStore = CmxIrohRelayPreferenceStore() + customRelayCredentials = CmxIrohCustomRelayCredentialStore() + #endif + relayPolicyTrustRoot = Self.relayPolicyTrustRoot( + infoDictionary: Bundle.main.infoDictionary + ) + lanPublisher = CmxIrohLANHostPublisher() + } + + /// The host diagnostic ring, deliberately `nonisolated` so read paths like + /// the `iroh_diag` socket verb can snapshot it without a main-actor hop: + /// the ring must stay exportable even when the main thread is wedged, + /// which is exactly when connection diagnostics matter most. + nonisolated static let hostDiagnosticLog = DiagnosticLog( + buildStamp: MobileHostIrohRuntime.diagnosticBuildStamp, + role: .macHost + ) + + private nonisolated static var diagnosticBuildStamp: String { + DiagnosticBuildStamp.make(infoDictionary: Bundle.main.infoDictionary) + } + + @discardableResult + func scheduleReconcile( + eraseAccountState: Bool, + restartActiveRuntime: Bool = false + ) -> Task { + lifecycleRevision &+= 1 + cancelRetryInspection() + bindingPersistenceQueue.cancel() + let revision = lifecycleRevision + let previous = transitionTask + previous?.cancel() + let task = Task { @MainActor [weak self] in + await previous?.value + guard let self, revision == self.lifecycleRevision else { return } + await self.reconcile( + targetAccountID: self.signOutIntentActive + ? nil + : (self.desiredActive ? self.observedAccountID : nil), + eraseAccountState: eraseAccountState || self.signOutIntentActive, + restartActiveRuntime: restartActiveRuntime, + revision: revision + ) + if revision == self.lifecycleRevision { + self.transitionTask = nil + } + } + transitionTask = task + return task + } + + func reconcile( + targetAccountID: String?, + eraseAccountState: Bool, + restartActiveRuntime: Bool, + revision: UInt64 + ) async { + // Each transition re-derives failure recovery from its own outcome: + // success resets the backoff ladder, failure re-arms it, and a + // deactivating transition ends the need for it. + cancelFailureRecovery(resetBackoff: false) + if eraseAccountState { + clearIrohRoutePublication(revision: revision) + await quarantineForSignOut() + } else if restartActiveRuntime + || activeAccountID != targetAccountID + || targetAccountID == nil { + let previousRuntime = runtime + runtime = nil + clearIrohRoutePublication(revision: revision) + selectedPathObservationTask?.cancel() + selectedPathObservationTask = nil + activeAccountID = nil + activeAppInstanceID = nil + await previousRuntime?.stop() + if previousRuntime != nil { + diagnosticLog.record(DiagnosticEvent( + .endpointStopped, + a: DiagnosticTransportKind.iroh.rawValue + )) + } + await lanPublisher.stop() + clearRelayPolicyRuntimeState() + } + + guard revision == lifecycleRevision, + !Task.isCancelled, + !signOutIntentActive, + MobileHostService.isListeningEnabled, + desiredActive, + let targetAccountID, + runtime == nil else { return } + + diagnosticLog.record(DiagnosticEvent( + .endpointStarting, + a: DiagnosticTransportKind.iroh.rawValue + )) + do { + try await activate(accountID: targetAccountID, revision: revision) + failureRecoveryFailureCount = 0 + } catch is CancellationError { + return + } catch { + let failureKind = Self.diagnosticFailureKind(for: error) + let failureType = String(reflecting: type(of: error)) + diagnosticLog.record(DiagnosticEvent( + .endpointFailed, + a: DiagnosticTransportKind.iroh.rawValue, + b: failureKind.rawValue + )) + mobileHostIrohLog.error( + "Iroh host activation failed kind=\(failureKind.rawValue, privacy: .public) type=\(failureType, privacy: .public) detail=\(String(describing: error), privacy: .private)" + ) + scheduleFailureRecovery() + } + } + + nonisolated static func diagnosticFailureKind( + for error: any Error + ) -> DiagnosticFailureKind { + DiagnosticFailureKind.classify(error) + } + + /// An account-scoped invalidation says a newer authoritative route + /// revision exists. One owned task performs a read-only v2 reconciliation; + /// bursts coalesce at the greatest revision instead of creating one waiter + /// per frame. Terminal evidence rebuilds through the shared lifecycle path. + func reconcileConnectivityFromServerSignal(revision: UInt64) { + if serverSignalRefreshTask != nil { + serverSignalPendingRevision = max( + serverSignalPendingRevision ?? revision, + revision + ) + return + } + guard let signalRuntime = runtime else { + retryIfNeeded() + return + } + serverSignalRefreshTask = Task { @MainActor [weak self] in + _ = await signalRuntime.reconcileConnectivityRevision(revision) + guard let self else { return } + self.serverSignalRefreshTask = nil + let replayRevision = self.serverSignalPendingRevision + self.serverSignalPendingRevision = nil + guard self.runtime === signalRuntime, + self.desiredActive, + !self.signOutIntentActive, + self.transitionTask == nil else { return } + if await signalRuntime.snapshot().state == .failed { + guard self.runtime === signalRuntime, + self.desiredActive, + !self.signOutIntentActive, + self.transitionTask == nil else { return } + self.scheduleReconcile( + eraseAccountState: false, + restartActiveRuntime: true + ) + return + } + if let replayRevision { + self.reconcileConnectivityFromServerSignal( + revision: replayRevision + ) + } + } + } + + func makeDiagnosticSessionID() -> Int { + if nextDiagnosticSessionID == Int.max { + nextDiagnosticSessionID = 1 + } else { + nextDiagnosticSessionID += 1 + } + return nextDiagnosticSessionID + } +} diff --git a/Sources/Mobile/MobileHostIrxRuntime+SettingsControl.swift b/Sources/Mobile/MobileHostIrxRuntime+SettingsControl.swift index cc2afc461f96..9e0b234b1ba3 100644 --- a/Sources/Mobile/MobileHostIrxRuntime+SettingsControl.swift +++ b/Sources/Mobile/MobileHostIrxRuntime+SettingsControl.swift @@ -1,5 +1,4 @@ import CMUXMobileCore -import CmuxIrxTransport import Foundation /// Thrown for Settings mutations the irx runtime does not support yet @@ -24,8 +23,10 @@ extension MobileHostIrxRuntime: CmxIrohSettingsControlling { func irohSettingsSnapshot() async -> CmxIrohSettingsSnapshot { let phase = settingsPhase let hadLiveDiscovery = hadLiveDiscoveryThisRun - let cache = cachedState + let broker = brokerService let supervisor = endpointSupervisor + let trust = await broker?.cachedTrust() + let credentials = await broker?.cachedRelayCredentials() ?? [] let endpointOnline = await supervisor?.isHealthy() ?? false let homeRelayURL = await supervisor?.homeRelayURL() return Self.settingsSnapshot( @@ -33,10 +34,10 @@ extension MobileHostIrxRuntime: CmxIrohSettingsControlling { forceRelayOnly: Self.forceRelayOnly, endpointOnline: endpointOnline, homeRelayURL: homeRelayURL, - relayFleet: cache?.directory?.relayURLs ?? cache?.relayCredentials.map(\.relayURL) ?? [], - hasTrustSnapshot: cache?.directory != nil, + relayFleet: trust?.relayFleet ?? [], + hasTrustSnapshot: trust != nil, hadLiveDiscovery: hadLiveDiscovery, - credentialExpiry: cache?.relayCredentials.map { Date(timeIntervalSince1970: Double($0.expiresAt)) }.max() + credentialExpiry: credentials.map(\.expiresAt).max() ) } @@ -106,12 +107,14 @@ extension MobileHostIrxRuntime: CmxIrohSettingsControlling { } func refreshIrohSettings() async { - guard isNetworkingAllowed, let service = controlService else { + guard MobileHostService.isListeningEnabled, + isNetworkingAllowed, + let broker = brokerService else { publishIrxSettingsUpdate() return } // Force a live discovery so the fleet and policy source are current. - if (try? await service.refreshDirectory()) != nil { + if (try? await broker.discover(maximumAge: 0)) != nil { noteLiveDiscoverySucceeded() } publishIrxSettingsUpdate() diff --git a/Sources/Mobile/MobileHostIrxRuntime.swift b/Sources/Mobile/MobileHostIrxRuntime.swift index 7c23439cb3d8..a48771f1615c 100644 --- a/Sources/Mobile/MobileHostIrxRuntime.swift +++ b/Sources/Mobile/MobileHostIrxRuntime.swift @@ -1,771 +1,989 @@ -import AppKit import CMUXMobileCore import CmuxAuthRuntime import CmuxIrohTransport import CmuxIrxTransport import CmuxSettings import Foundation -import IrohLib import OSLog -private final class MobileHostV2RelayAddressCallback: AddrChangeCallback, Sendable { - private let handler: @Sendable () async -> Void +/// macOS composition root for the irx transport (the from-scratch iroh +/// rebuild in `CmuxIrxTransport`). DEBUG-only and default-off: when +/// `cmux.irx.enabled` is set (or `CMUX_IRX_ENABLED=1`), this runtime owns the +/// app's iroh identity slot and the legacy `MobileHostIrohRuntime` stays +/// dormant, so the two stacks can never fight over the broker binding. +@MainActor +final class MobileHostIrxRuntime { + static let shared = MobileHostIrxRuntime(publishesPublicHostStatus: true) - init(handler: @escaping @Sendable () async -> Void) { self.handler = handler } + nonisolated static let enabledDefaultsKey = "cmux.irx.enabled" + nonisolated static let forceRelayDefaultsKey = "cmux.irx.force-relay" + nonisolated static let pathModeDefaultsKey = CmxIrohTransportVerificationMode.debugDefaultsKey - func onChange(addr: EndpointAddr) async throws { await handler() } -} + /// irx is the PRIMARY transport: on by default in every configuration. + /// An explicit `false` in defaults (the remote revert switch writes it) + /// falls back to the legacy runtime; the env var re-arms and persists. + nonisolated static var isEnabled: Bool { + if ProcessInfo.processInfo.environment["CMUX_IRX_ENABLED"] == "1" { + UserDefaults.standard.set(true, forKey: enabledDefaultsKey) + return true + } + if UserDefaults.standard.object(forKey: enabledDefaultsKey) != nil { + return UserDefaults.standard.bool(forKey: enabledDefaultsKey) + } + return true + } -/// The sole Mac IROH owner for a selected Stack team and opted-in installation. -@MainActor -final class MobileHostIrxRuntime: MobileHostPairingRuntime { - static let shared = MobileHostIrxRuntime(publishesPublicHostStatus: true) - nonisolated static let forceRelayDefaultsKey = "cmux.iroh.v2.force-relay" - nonisolated static let pathModeDefaultsKey = "cmux.iroh.v2.path-mode" + /// Longest wait between two activation attempts on the doubling ladder. nonisolated static let maximumActivationRetryDelay: TimeInterval = 5 * 60 - nonisolated static var forceRelayOnly: Bool { - ProcessInfo.processInfo.environment["CMUX_IROH_V2_FORCE_RELAY"] == "1" - || UserDefaults.standard.string(forKey: "cmux.iroh.v2.config.CMUX_IROH_V2_FORCE_RELAY") == "1" - || UserDefaults.standard.bool(forKey: forceRelayDefaultsKey) - || UserDefaults.standard.string(forKey: pathModeDefaultsKey) == "relay-only" - } - nonisolated static var pathMode: IrxPathMode { - if forceRelayOnly { return .relayOnly } - return UserDefaults.standard.string(forKey: pathModeDefaultsKey) == "direct-only" ? .directOnly : .automatic + + /// Delay before the next activation attempt after `error`. + /// + /// The ladder starts at 5 s and doubles per consecutive failure up to + /// `maximumActivationRetryDelay`. A broker `Retry-After` is a floor that + /// wins over the ladder, and `jitterUnitInterval` (0...1) adds up to a + /// quarter of the resulting delay so a fleet told to wait the same window + /// does not re-mint in lockstep. + nonisolated static func activationRetryDelay( + after error: any Error, + failureCount: Int, + jitterUnitInterval: Double + ) -> TimeInterval { + let exponent = min(max(failureCount, 0), 16) + let ladder = min(5 * pow(2, Double(exponent)), maximumActivationRetryDelay) + let serverFloor = TimeInterval( + max(0, (error as? any CmxRetryAfterProviding)?.retryAfterSeconds ?? 0) + ) + let base = max(ladder, serverFloor) + let jitter = min(max(jitterUnitInterval, 0), 1) * base * 0.25 + return base + jitter } - nonisolated static let journal = IrxJournal(subsystem: "dev.cmux", category: "irx-host", - journalFileURL: URL(fileURLWithPath: "/tmp/cmux-irx-journal-mac-\(MobileHostIdentity.instanceTag()).jsonl")) - nonisolated static func activationRetryDelay(after error: any Error, failureCount: Int, jitterUnitInterval: Double) -> TimeInterval { - let ladder = min(5 * pow(2, Double(min(max(failureCount, 0), 16))), maximumActivationRetryDelay) - let floor = TimeInterval(max(0, (error as? any CmxRetryAfterProviding)?.retryAfterSeconds ?? 0)) - let base = max(ladder, floor) - return base + min(max(jitterUnitInterval, 0), 1) * base * 0.25 + nonisolated static var forceRelayOnly: Bool { + if ProcessInfo.processInfo.environment["CMUX_IRX_FORCE_RELAY"] == "1" { + UserDefaults.standard.set(true, forKey: forceRelayDefaultsKey) + return true + } + return UserDefaults.standard.bool(forKey: forceRelayDefaultsKey) } - private nonisolated static var hostReleaseTrack: String { - #if DEBUG - return "dev" - #else - return (Bundle.main.bundleIdentifier ?? "").contains("nightly") ? "nightly" : "stable" - #endif + #if DEBUG + func setIrohDebugTransportVerificationMode( + _ mode: CmxIrohTransportVerificationMode + ) async { + UserDefaults.standard.set(mode.rawValue, forKey: Self.pathModeDefaultsKey) + UserDefaults.standard.set( + mode == .relayOnly, + forKey: Self.forceRelayDefaultsKey + ) + await applyManagedNetworkingPolicy() } + #endif + + /// One journal for every irx component on the Mac. The soak analyzer + /// tails the JSONL file; `log show` sees the mirrored notice lines. + nonisolated static let journal: IrxJournal = { + let tag = MobileHostIdentity.instanceTag() + return IrxJournal( + subsystem: "dev.cmux", + category: "irx-host", + journalFileURL: URL( + fileURLWithPath: "/tmp/cmux-irx-journal-mac-\(tag).jsonl") + ) + }() - enum SettingsPhase: Equatable { case idle, activating, active, failed } private let managedDevicePolicy: ManagedDevicePolicy - private let pairingEnabled: @MainActor () -> Bool + /// Only the process-wide host publishes into ``MobileHostPublicStatusCache``. + /// Test-constructed runtimes leave that cache alone so parallel suites + /// cannot clobber the live identity, and so comparing against ``shared`` + /// cannot lazily create it. private let publishesPublicHostStatus: Bool - private weak var auth: AuthCoordinator? - private var authObservationTask: Task? - private var wakeTask: Task? - private var shutdownTask: Task? - private var activeScope: AuthenticatedTeamScope? - private var signingOutScope: AuthenticatedTeamScope? - private var wantsHost = true - private var requiresTransition = false - private var generationToken = UUID() - private var activationTask: Task? - private var controlTask: Task? - private var endpointTask: Task? - private var endpointRefreshPending = false - private var relayAddressWatch: WatchHandle? - private var relayAddressWatchGeneration: Int? - private var permissionExpiryTask: Task? - private var acceptLoop: Task? - private var lastLoggedControlState: String? - private var admission: V2InboundAdmissionAuthority? - /// Compatibility publication for older iOS dialects. It shares the v2 - /// signing key but has its own filtered authority and broker lifecycle. - private var legacyService: LegacyCompatibilityService? - private var legacyAcceptorPeer: CmxIrohGrantPeer? - private var legacyStartTask: Task? - private var legacyEventsTask: Task? - private var registry: IrxServerSessionRegistry? - private var identity: IrxIdentity? - private(set) var controlService: V2ControlService? - private(set) var endpointSupervisor: IrxEndpointSupervisor? - private(set) var cachedState: V2CachedState? - private var listenerContinuations: [UUID: AsyncStream.Continuation] = [:] - private(set) var listenerState = MobileHostListenerState() { - didSet { - guard listenerState != oldValue else { return } - for continuation in listenerContinuations.values { continuation.yield(listenerState) } - if publishesPublicHostStatus { - NotificationCenter.default.post(name: .mobileHostStatusDidChange, object: nil) - } - } - } - private(set) var settingsPhase: SettingsPhase = .idle - private(set) var hadLiveDiscoveryThisRun = false - var irxSettingsContinuations: [UUID: AsyncStream.Continuation] = [:] - var irxSettingsRefreshTask: Task? - init(managedDevicePolicy: ManagedDevicePolicy = ManagedDevicePolicy(), publishesPublicHostStatus: Bool = false, - pairingEnabled: @escaping @MainActor () -> Bool = { MobileHostService.isListeningEnabled }) { + init( + managedDevicePolicy: ManagedDevicePolicy = ManagedDevicePolicy(), + publishesPublicHostStatus: Bool = false + ) { self.managedDevicePolicy = managedDevicePolicy self.publishesPublicHostStatus = publishesPublicHostStatus - self.pairingEnabled = pairingEnabled } var isNetworkingAllowed: Bool { - pairingEnabled() - && !managedDevicePolicy.isEnforced(.disableIrohNetworking) + !managedDevicePolicy.isEnforced(.disableIrohNetworking) && !managedDevicePolicy.isEnforced(.disableRemoteControl) } - private func isCurrent(_ token: UUID) -> Bool { - guard generationToken == token, wantsHost, isNetworkingAllowed, - let activeScope, signingOutScope != activeScope else { return false } - return auth?.isAuthenticatedTeamScopeCurrent(activeScope) == true + var canStartNetworking: Bool { + isNetworkingAllowed && MobileHostService.isListeningEnabled } - func listenerStateUpdates() -> AsyncStream { - let id = UUID() - return AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in - listenerContinuations[id] = continuation - continuation.yield(listenerState) - continuation.onTermination = { @Sendable [weak self] _ in - Task { @MainActor in self?.listenerContinuations.removeValue(forKey: id) } - } - } + /// Tears the host down without treating the transition as a sign-out: + /// persisted device-list leases stay so a later policy lift can re-arm + /// the same account. Idempotent when already idle. + func stopHost() async { + await enqueueManagedNetworking(.stop) } - func configure(auth: AuthCoordinator) { - self.auth = auth + /// Cancels auth-driven wakeups immediately and queues a non-destructive + /// runtime stop. Persisted identity and account state stay intact so a + /// later opt-in can reuse the same pairing identity. + func beginPairingOptOut() { authObservationTask?.cancel() - authObservationTask = Task { @MainActor [weak self, weak auth] in - guard let auth else { return } - await auth.awaitBootstrapped() - for await scope in auth.authenticatedTeamScopes() { - guard !Task.isCancelled else { return } - if let scope, scope != self?.signingOutScope { self?.wantsHost = true } - await self?.reconcile() - } - } - wakeTask?.cancel() - wakeTask = Task { @MainActor [weak self] in - for await _ in NSWorkspace.shared.notificationCenter.notifications(named: NSWorkspace.didWakeNotification) { - guard !Task.isCancelled else { return } - await self?.foreground() - } - } + authObservationTask = nil + auth = nil + _ = scheduleManagedNetworking(.stop) } - func applyManagedNetworkingPolicy() async { - wantsHost = true - await reconcile() + /// Reconciles the IRX host with the composition-root pairing decision. + /// The IRX runtime derives its active state from pairing and managed policy, + /// so it does not need a second mutable desired-state flag. + func setDesiredActive(_ requested: Bool) { + _ = scheduleManagedNetworking(requested ? .reconcile : .stop) } - func prepareForStop() { - wantsHost = false - requiresTransition = true - admission?.invalidate() - generationToken = UUID() - listenerState = MobileHostListenerState() - if publishesPublicHostStatus { MobileHostPublicStatusCache.removeAll() } + /// Applies `DisableIrohNetworking` / `DisableRemoteControl` and the + /// current signed-in account to the live IRX host. Policy activation + /// stops the endpoint immediately; lifting it re-arms without a relaunch. + func applyManagedNetworkingPolicy() async { + await enqueueManagedNetworking(.reconcile) } - func stopHost() async { - prepareForStop() - await transition(to: nil) + private enum ManagedNetworkingWork { + case stop + case reconcile } - func beginSignOutPreparation() { - signingOutScope = auth?.authenticatedTeamScope - admission?.invalidate() - generationToken = UUID() - wantsHost = false - listenerState = MobileHostListenerState() - if publishesPublicHostStatus { MobileHostPublicStatusCache.removeAll() } - let token = generationToken - shutdownTask?.cancel() - shutdownTask = Task { @MainActor [weak self] in - guard let self, self.generationToken == token else { return } - await self.stopHost() - } + /// Serializes policy and account transitions. `MobileHostService.stop()` + /// and `syncToSettings()` both fire these from unstructured tasks, so + /// without a chain a lift could run against a half-drained teardown and + /// no-op on a still-set `activeAccountID`, or a late stop could clear the + /// account after a re-arm and tear the new activation down. Each unit of + /// work re-reads the policy and the account after the queue drains, so + /// the last transition to be requested is the one that decides the state. + private func enqueueManagedNetworking(_ work: ManagedNetworkingWork) async { + let task = scheduleManagedNetworking(work) + await task.value } - func foreground() async { - await reconcile() - guard isCurrent(generationToken) else { return } - let token = generationToken - await controlService?.foreground() - guard isCurrent(token) else { return } - await refreshListenerState(token: token) - requestEndpointReady(token: token) + @discardableResult + private func scheduleManagedNetworking( + _ work: ManagedNetworkingWork + ) -> Task { + let previous = managedNetworkingTask + let task = Task { @MainActor [weak self] in + await previous?.value + guard let self else { return } + switch work { + case .stop: + await self.performStopHost() + case .reconcile: + await self.performManagedNetworkingReconcile() + } + } + managedNetworkingTask = task + return task } - func restartForConfigurationChange() async { - guard wantsHost, isNetworkingAllowed else { return } - await transition(to: auth?.authenticatedTeamScope) + private func performStopHost() async { + guard activeAccountID != nil + || activationTask != nil + || settingsPhase != .idle + || brokerService != nil + || endpointSupervisor != nil + || controlPlane != nil + || acceptLoop != nil + else { + return + } + await deactivate() + activeAccountID = nil } - #if DEBUG - func setIrohDebugTransportVerificationMode(_ mode: CmxIrohTransportVerificationMode) async { - let modeName: String - switch mode { - case .automatic: modeName = IrxPathMode.automatic.rawValue - case .relayOnly: modeName = IrxPathMode.relayOnly.rawValue - case .directOnly: modeName = IrxPathMode.directOnly.rawValue + private func performManagedNetworkingReconcile() async { + guard canStartNetworking else { + await performStopHost() + return + } + let accountID = auth?.currentUser?.id + if accountID != activeAccountID { + await transition(to: accountID) } - UserDefaults.standard.set(modeName, forKey: Self.pathModeDefaultsKey) - await restartForConfigurationChange() } - #endif - private func reconcile() async { - let scope = wantsHost && isNetworkingAllowed ? auth?.authenticatedTeamScope : nil - let permitted = scope == signingOutScope ? nil : scope - guard requiresTransition || permitted != activeScope || (permitted != nil && controlService == nil && activationTask == nil) - || (permitted == nil && settingsPhase != .idle) else { return } - await transition(to: permitted) + private weak var auth: AuthCoordinator? + private var authObservationTask: Task? + private var activeAccountID: String? + private var activationTask: Task? + /// Chain head for ``enqueueManagedNetworking(_:)``. + private var managedNetworkingTask: Task? + /// Changes on every (de)activation; per-connection supervisors compare it. + private var generationToken = UUID() + /// Consecutive activation failures since the last successful activation. + /// Drives the doubling retry ladder; reset on success and on transition. + private var activationFailureCount = 0 + + /// Coarse lifecycle mirror for the Settings Networking section (see + /// `MobileHostIrxRuntime+SettingsControl`). `failed` means the last + /// activation attempt errored and the retry ladder owns recovery; it is + /// only cleared by a successful activation or an account change. + enum SettingsPhase: Equatable { + case idle + case activating + case active + case failed } - private func transition(to scope: AuthenticatedTeamScope?) async { - requiresTransition = false - generationToken = UUID() - let token = generationToken - activeScope = scope - admission?.invalidate() - let oldControl = controlService - let oldEndpoint = endpointSupervisor - let oldRegistry = registry - let oldLegacy = legacyService - let oldRelayWatch = relayAddressWatch - activationTask?.cancel(); activationTask = nil - controlTask?.cancel(); controlTask = nil - endpointTask?.cancel(); endpointTask = nil - endpointRefreshPending = false - relayAddressWatch = nil; relayAddressWatchGeneration = nil - permissionExpiryTask?.cancel(); permissionExpiryTask = nil - acceptLoop?.cancel(); acceptLoop = nil - admission = nil; registry = nil; identity = nil - legacyService = nil - legacyAcceptorPeer = nil - legacyStartTask?.cancel(); legacyStartTask = nil - legacyEventsTask?.cancel(); legacyEventsTask = nil - controlService = nil; endpointSupervisor = nil; cachedState = nil - lastLoggedControlState = nil - hadLiveDiscoveryThisRun = false - setSettingsPhase(.idle) - if publishesPublicHostStatus { MobileHostPublicStatusCache.removeAll() } - await oldControl?.stop() - await oldRelayWatch?.stop() - await oldRegistry?.closeAll(code: .hostShutdown) - await oldLegacy?.stop(revokeOwnBinding: true) - await oldEndpoint?.deactivate() - guard generationToken == token, let scope, isCurrent(token) else { return } - setSettingsPhase(.activating) - activationTask = Task { @MainActor [weak self] in - var failureCount = 0 + private(set) var settingsPhase: SettingsPhase = .idle + /// True once an authenticated broker discovery succeeded during the + /// current activation, so Settings can report the relay fleet as + /// server-verified rather than served from the disk cache. + private(set) var hadLiveDiscoveryThisRun = false + /// Live settings-snapshot subscribers (`irohSettingsUpdates()`). + var irxSettingsContinuations: [UUID: AsyncStream.Continuation] = [:] + /// Periodic re-yield loop; runs only while subscribers exist. + var irxSettingsRefreshTask: Task? + + private var stateDirectory: URL? + private(set) var brokerService: IrxBrokerService? + private(set) var endpointSupervisor: IrxEndpointSupervisor? + private var autopilot: IrxRelayCredentialAutopilot? + private var registry: IrxServerSessionRegistry? + private var acceptLoop: Task? + private var localBinding: IrxBindingSnapshot? + /// The always-on fact channel to the per-account control-plane DO: the + /// host publishes hint announcements on it (instant propagation to + /// phones) and ingests pushed relay passes. Never on any serving path. + private var controlPlane: IrxControlPlaneClient? + /// The CURRENT device-list lease the accept loop judges against: + /// synchronous O(1) reads, atomically swapped on every directory apply, + /// cleared (fail closed) on deactivation. + private var deviceListBox: IrxDeviceListCurrent? + /// Durable home of the lease (Keychain in Release, dev file store in + /// DEBUG), loaded at activation so admission works offline. + private var deviceListStore: IrxDeviceListStore? + /// Authenticated Bonjour publisher for the IRX endpoint. Iroh's native + /// candidate discovery handles public paths, while this publisher makes + /// same-account LAN candidates available to the client-side fallback. + private let lanPublisher = CmxIrohLANHostPublisher() + + func configure(auth: AuthCoordinator) { + self.auth = auth + Self.journal.record( + "host-runtime", "configured", + ["force_relay": String(Self.forceRelayOnly)] + ) + authObservationTask?.cancel() + authObservationTask = Task { @MainActor [weak self] in + await auth.awaitBootstrapped() + guard !Task.isCancelled else { return } while !Task.isCancelled { - guard let self, self.isCurrent(token) else { return } - do { - try await self.provision(scope: scope, token: token) - return - } catch { - guard self.isCurrent(token), !Task.isCancelled else { return } - self.setSettingsPhase(.failed) - Self.journal.record("v2-host", "setup-retry", [ - "error": (error as? V2ControlFailure)?.diagnosticCode ?? String(describing: type(of: error)) - ]) - let delay = Self.activationRetryDelay(after: error, failureCount: failureCount, jitterUnitInterval: Double.random(in: 0...1)) - failureCount += 1 - try? await Task.sleep(for: .seconds(delay)) - } + // Account changes and MDM policy share this cadence so a + // profile that never posts UserDefaults notifications still + // lands. `syncToSettings()` also calls the same reconcile + // immediately on an observed policy transition. + await self?.applyManagedNetworkingPolicy() + try? await Task.sleep(for: .seconds(2)) } } } + /// Sets the settings-facing phase and pushes a fresh snapshot to any + /// Settings subscribers. Safe to call redundantly; only changes publish. func setSettingsPhase(_ phase: SettingsPhase) { - guard settingsPhase != phase else { return } + guard phase != settingsPhase else { return } settingsPhase = phase - switch phase { - case .idle: listenerState = MobileHostListenerState() - case .activating: listenerState.phase = .starting - case .failed: listenerState = MobileHostListenerState(phase: .retrying) - case .active: break - } publishIrxSettingsUpdate() } - func noteLiveDiscoverySucceeded() { hadLiveDiscoveryThisRun = true } - - private func provision(scope: AuthenticatedTeamScope, token: UUID) async throws { - guard isCurrent(token), let auth else { throw V2ControlFailure.stopped } - let configuration = try MobileHostV2Configuration.current() - let installation = MobileHostV2Installation(configuration: configuration) - let deviceID = try await installation.deviceID() - let tuple = V2Identity(appNamespace: configuration.namespace, buildTag: configuration.tag, - deviceID: deviceID, environment: configuration.environment, projectID: configuration.projectID, - teamID: scope.teamID, userID: scope.session.accountID) - let key = try await installation.key(identity: tuple) - let store = V2FileStateStore(rootDirectory: configuration.stateDirectory, fileManager: FileManager()) - let restored = try await store.load(identity: tuple) - guard isCurrent(token), !Task.isCancelled else { throw V2ControlFailure.stopped } - let device = V2DeviceDescriptor(endpointID: key.endpointID, identity: tuple, - identityGeneration: restored?.device?.descriptor.identityGeneration ?? 1, - metadata: V2DeviceMetadata(appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0", - capabilities: ["irx-v2"], displayName: Host.current().localizedName ?? "Mac", - pairingEnabled: true, platform: .mac, - relayURLs: restored?.device?.descriptor.metadata.relayURLs ?? [])) - let identity = IrxIdentity(privateKeyData: key.secretKey, deviceID: deviceID, appInstanceID: key.endpointID) - let preferredPort = MobileHostService.configuredPort() - let supervisor = IrxEndpointSupervisor(configuration: .init(identity: identity, pathMode: Self.pathMode, - preferredBindAddress: "0.0.0.0:\(preferredPort)", - initialRemoteBiStreams: 1, initialRemoteUniStreams: 0, - additionalALPNs: [MobileHostIrxLegacyDialectServer.legacyALPN]), journal: Self.journal) - let admission = try V2InboundAdmissionAuthority(host: device) - if let restored { _ = admission.restore(restored) } - let http = V2URLSessionHTTPTransport(session: .shared) - let dependencies = V2ControlDependencies( - connect: { V2URLSessionSocket(session: .shared, request: $0) }, - http: { try await http.send($0) }, - stackAccessToken: { force in - try await Self.accessToken(auth: auth, scope: scope, force: force) - }, - sign: { data in - guard await auth.isAuthenticatedTeamScopeCurrent(scope) else { throw V2ControlFailure.scopeMismatch } - return try key.sign(data) - }) - let service = V2ControlService(configuration: try .init(baseURL: configuration.baseURL, device: device), - dependencies: dependencies, store: store) - listenerState.preferredPort = preferredPort - self.identity = identity - self.admission = admission - if let namespace = CmxIrohMacBundleNamespace(bundleIdentifier: Bundle.main.bundleIdentifier), - let brokerBaseURL = AuthEnvironment.irohBrokerBaseURL { - let compatibility = try LegacyCompatibilityService( + /// Marks the current run as having completed a live (network) broker + /// discovery, so the Settings policy source reads "server". Called from + /// activation and from the settings refresh action. + func noteLiveDiscoverySucceeded() { + hadLiveDiscoveryThisRun = true + } + + private func transition(to accountID: String?) async { + guard accountID != activeAccountID else { return } + // Explicit sign-out (account -> nil): erase the persisted device-list + // lease alongside the in-memory clear deactivate() performs, in the + // same breath the account's other cached authorization material + // stops being usable. An account SWITCH keeps the old account's + // lease (it is account-scoped and TTL-bounded). + if accountID == nil, let deviceListStore { + await deviceListStore.clear() + } + await deactivate() + activeAccountID = accountID + activationFailureCount = 0 + guard let accountID else { return } + Self.journal.record("host-runtime", "activating", ["account": accountID]) + setSettingsPhase(.activating) + activationTask = Task { @MainActor [weak self] in + await self?.activate(accountID: accountID) + } + } + + private func activate(accountID: String) async { + guard canStartNetworking, !Task.isCancelled, let auth else { return } + generationToken = UUID() + let token = generationToken + // The control-plane client now starts EARLY in activation (before the + // broker calls that can throw), so a retry after a mid-activation + // failure must stop the previous attempt's client instead of leaking + // its reconnect loop beside a fresh one. + if let controlPlane { + await controlPlane.stop() + self.controlPlane = nil + } + let tag = MobileHostIrohRuntime.currentTag() + guard let brokerBaseURL = AuthEnvironment.irohBrokerBaseURL, + let namespace = CmxIrohMacBundleNamespace( + bundleIdentifier: Bundle.main.bundleIdentifier) + else { + Self.journal.record("host-runtime", "activation-failed", ["reason": "environment"]) + setSettingsPhase(.failed) + return + } + let appSupport = FileManager.default.urls( + for: .applicationSupportDirectory, in: .userDomainMask + )[0] + // Per-bundle, per-backend state: another build (or another + // environment's) caches must never be readable here, or staging + // trust keys reject production grants at admission. + let stateDir = IrxStateLocation.directory( + base: appSupport, + bundleIdentifier: Bundle.main.bundleIdentifier, + brokerHost: brokerBaseURL.host + ) + IrxStateLocation.removeLegacySharedDirectory(base: appSupport) + stateDirectory = stateDir + do { + // IDENTITY ADOPTION: reuse the legacy stack's identity, device + // ID, and app-instance scope, so the EndpointID, binding slot, + // and every existing pair grant carry over (refresh-in-place; + // stored routes on phones keep working with zero re-pairing). + let legacy = MobileHostIrohRuntime.shared + let appInstanceID = try await legacy.appInstances.appInstanceID( + accountID: accountID, tag: tag) + let material = try await legacy.identities.identity( + accountID: accountID, appInstanceID: appInstanceID) + try Task.checkCancellation() + let deviceID = cmxCanonicalDeviceID(MobileHostIdentity.deviceID()) + let identity = IrxIdentity( + privateKeyData: material.secretKey.bytes, + deviceID: deviceID, + appInstanceID: appInstanceID + ) + let broker = try IrxBrokerService( configuration: .init( - brokerBaseURL: brokerBaseURL, - controlSocketURL: PresenceHeartbeatClient.resolvedServiceURL(), + baseURL: brokerBaseURL, clientNamespace: namespace.rawValue, - tag: configuration.tag, + tag: tag, platform: .mac, - displayName: device.metadata.displayName, - cacheDirectory: configuration.stateDirectory.appendingPathComponent("legacy-compat", isDirectory: true) - .appendingPathComponent(identity.endpointIDHex, isDirectory: true), - accountID: scope.session.accountID, - identityGeneration: device.identityGeneration, - appVersion: device.metadata.appVersion, - releaseTrack: Self.hostReleaseTrack), - identity: LegacyCompatibilityService.compatibilityIdentity(from: identity), + displayName: Host.current().localizedName, + cacheDirectory: stateDir, + identityGeneration: material.generation, + // Release: broker caches live in the Keychain, scoped + // per account + backend; DEBUG stays on the JSON files. + accountID: accountID + ), + identity: identity, accessTokenPair: { [weak auth] in guard let auth else { return nil } - guard await auth.isAuthenticatedTeamScopeCurrent(scope) else { return nil } - let snapshot = try await auth.authenticatedSessionSnapshot() - guard await auth.isAuthenticatedTeamScopeCurrent(scope) else { return nil } - return (access: snapshot.accessToken, refresh: snapshot.refreshToken) - }, journal: Self.journal) - self.legacyService = compatibility - } - registry = IrxServerSessionRegistry(journal: Self.journal) - endpointSupervisor = supervisor - cachedState = restored ?? V2CachedState(identity: tuple) - controlService = service - if publishesPublicHostStatus { MobileHostPublicStatusCache.updateV2DeviceID(deviceID) } - schedulePermissionExpiry(token: token) - // A returning Mac listens using its cache while control setup runs independently. - requestEndpointReady(token: token) - controlTask = Task { @MainActor [weak self] in - for await snapshot in await service.events() { - guard !Task.isCancelled else { return } - await self?.apply(snapshot, token: token) - } - } - await service.start() - guard isCurrent(token), !Task.isCancelled else { await service.stop(); throw V2ControlFailure.stopped } - Self.journal.record("v2-host", "control-started", ["cached": String(restored != nil)]) - } - - private static func accessToken(auth: AuthCoordinator, scope: AuthenticatedTeamScope, force: Bool) async throws -> String { - guard auth.isAuthenticatedTeamScopeCurrent(scope) else { throw V2ControlFailure.scopeMismatch } - let token: String - if force { token = try await auth.forceRefreshAccessToken() } - else { token = try await auth.authenticatedSessionSnapshot().accessToken } - guard auth.isAuthenticatedTeamScopeCurrent(scope) else { throw V2ControlFailure.scopeMismatch } - return token - } - - private func apply(_ snapshot: V2ControlSnapshot, token: UUID) async { - guard isCurrent(token), let admission else { return } - let status = String(describing: snapshot.status) - let failure = snapshot.failure?.diagnosticCode ?? "none" - let state = status + ":" + failure - if state != lastLoggedControlState { - lastLoggedControlState = state - Self.journal.record("v2-control", "state-changed", ["status": status, "failure": failure, - "environment": snapshot.cache.identity.environment, - "project": snapshot.cache.identity.projectID]) - } - // The service publishes an empty initial observation before loading disk. - guard snapshot.cache.device != nil || cachedState?.device == nil || snapshot.cache.authorityRevoked else { return } - let previousCredentials = cachedState?.relayCredentials - cachedState = snapshot.cache - _ = admission.apply(snapshot) - if let legacyService { - let modernEndpoints = Set((snapshot.cache.directory?.inboundPeers ?? []).map { - $0.device.descriptor.endpointID - }).union((snapshot.cache.directory?.devices ?? []).map { $0.descriptor.endpointID }) - await legacyService.excludeV2Endpoints(modernEndpoints) - guard isCurrent(token), !Task.isCancelled else { return } - } - if snapshot.cache.authorityRevoked { - admission.invalidate() - let oldLegacy = legacyService - legacyService = nil - legacyAcceptorPeer = nil - legacyStartTask?.cancel(); legacyStartTask = nil - legacyEventsTask?.cancel(); legacyEventsTask = nil - endpointTask?.cancel(); endpointTask = nil - endpointRefreshPending = false - let oldRelayWatch = relayAddressWatch - let oldRegistry = registry - let oldEndpoint = endpointSupervisor - relayAddressWatch = nil; relayAddressWatchGeneration = nil - permissionExpiryTask?.cancel(); permissionExpiryTask = nil - await oldRegistry?.closeAll(code: .revoked) - await oldEndpoint?.deactivate() - await oldRelayWatch?.stop() - await oldLegacy?.stop(revokeOwnBinding: true) - guard isCurrent(token) else { return } - setSettingsPhase(.failed) - return - } - if snapshot.status == .ready, let record = snapshot.cache.device, - !record.revoked, record.descriptor.identity == snapshot.cache.identity, - record.descriptor.endpointID == identity?.endpointIDHex { - listenerState.hasAuthenticatedRegistration = true - if snapshot.cache.directory != nil { noteLiveDiscoverySucceeded() } - startLegacyCompatibility(token: token) - } - await enforcePeerPermissions(token: token) - guard isCurrent(token) else { return } - schedulePermissionExpiry(token: token) - // Installing credentials does not replace the endpoint or its admitted sessions. - if previousCredentials != snapshot.cache.relayCredentials, let supervisor = endpointSupervisor { - await supervisor.rotateCredentials(Self.credentials(snapshot.cache)) - guard isCurrent(token) else { return } - } - requestEndpointReady(token: token) - publishIrxSettingsUpdate() - } + let session = try await auth.authenticatedSessionSnapshot() + return (session.accessToken, session.refreshToken) + }, + journal: Self.journal + ) + brokerService = broker + + // Credentials first (the relay-token bootstrap phase works before + // the binding exists), so registration can advertise the relay + // hint peers dial first. + let legacyListener = MobileHostIrxLegacyDialectServer.listenerEnabled + let supervisor = IrxEndpointSupervisor( + configuration: .init( + identity: identity, + pathMode: Self.forceRelayOnly ? .relayOnly : .automatic, + preferredBindAddress: nil, + // The phone opens control/keepalive/terminal/artifact + // lanes; 1 is enough to admit, raised post-admission. + initialRemoteBiStreams: 1, + initialRemoteUniStreams: 0, + // Dual ALPN: old phones speak the legacy dialect against + // the SAME endpoint/identity while irx is primary. + additionalALPNs: legacyListener + ? [MobileHostIrxLegacyDialectServer.legacyALPN] : [] + ), + journal: Self.journal + ) + endpointSupervisor = supervisor + let pilot = IrxRelayCredentialAutopilot( + broker: broker, endpoint: supervisor, journal: Self.journal) + autopilot = pilot + registry = IrxServerSessionRegistry(journal: Self.journal) - private func startLegacyCompatibility(token: UUID) { - guard isCurrent(token), legacyStartTask == nil, legacyAcceptorPeer == nil, - let service = legacyService, let identity, - let configuration = try? MobileHostV2Configuration.current() else { return } - legacyStartTask = Task { @MainActor [weak self] in - defer { - if let self, self.generationToken == token { self.legacyStartTask = nil } + // DEVICE LIST: the admission authority. Load the persisted lease + // BEFORE anything network-bound so admission works offline, and + // start the control-plane client FIRST so the fresh directory + // (and any revocation) lands as early as possible. Neither step + // blocks the endpoint bind. + let listStore = IrxDeviceListStore( + secureStore: Self.deviceListSecureStore(stateDirectory: stateDir), + accountID: accountID, + backendHost: brokerBaseURL.host ?? "unknown-broker", + journal: Self.journal + ) + deviceListStore = listStore + let listBox = IrxDeviceListCurrent() + deviceListBox = listBox + if let persisted = await listStore.loadPersisted() { + listBox.replace(persisted) } - var failures = 0 - while !Task.isCancelled { - guard let self, self.isCurrent(token), self.legacyService === service else { return } - do { - try await service.start() - let snapshot = await service.snapshot() - guard self.isCurrent(token), !Task.isCancelled, - self.legacyService === service, !snapshot.stopped, - let binding = snapshot.binding, - let endpointID = try? CmxIrohPeerIdentity(endpointID: identity.endpointIDHex) else { return } - self.legacyAcceptorPeer = CmxIrohGrantPeer(bindingID: binding.bindingID, - deviceID: binding.deviceID, tag: binding.tag, - platform: .mac, endpointID: endpointID, - identityGeneration: binding.identityGeneration) - self.legacyEventsTask?.cancel() - self.legacyEventsTask = Task { @MainActor [weak self] in - for await _ in await service.events() { - guard let self, self.isCurrent(token), !Task.isCancelled else { return } - await self.enforcePeerPermissions(token: token) - guard self.isCurrent(token), !Task.isCancelled else { return } - self.schedulePermissionExpiry(token: token) - } - } - await self.publishHomeRelayHintIfNeeded(token: token) - guard self.isCurrent(token), !Task.isCancelled else { return } - Self.journal.record("legacy-dialect", "compatibility-started", ["tag": configuration.tag]) - return - } catch { - guard self.isCurrent(token), !Task.isCancelled else { return } - Self.journal.record("legacy-dialect", "compatibility-start-failed", - ["error": String(describing: type(of: error))]) - if let brokerError = error as? CmxIrohTrustBrokerClientError { - switch brokerError { - case .rejected(let status, _), .rejectedWithRetryAfter(let status, _, _): - guard status == 408 || status == 425 || status == 429 || (500...599).contains(status) else { return } - case .missingAuthentication, .invalidAuthentication, .invalidBaseURL, - .nonHTTPResponse, .invalidResponse: - return - default: break + try Task.checkCancellation() + guard generationToken == token, canStartNetworking else { return } + + // Control-plane socket: hint announcements out (instant phone + // propagation, the signed HTTPS registration stays authoritative), + // pushed relay passes in (same mint rules as HTTPS), and the + // device-list directory in (admission authority). + let control: IrxControlPlaneClient? + if let controlURL = PresenceHeartbeatClient.resolvedServiceURL() { + let client = IrxControlPlaneClient( + configuration: .init( + socketURL: controlURL + .appendingPathComponent("v1/control/socket"), + endpointIDHex: identity.endpointIDHex, + // Phase A: passes stay on the HTTPS autopilot (with + // the stale-connection retry). The broker mint + // requires an endpoint-signed proof for non-legacy + // namespaces, which a bearer-only proxy cannot + // satisfy; flip when proof pass-through ships. + wantPasses: false, + cacheDirectory: stateDir, + clientInfo: IrxCtlClientInfo( + deviceID: deviceID, + platform: "mac", + appVersion: IrxCtlClientInfo.appVersionString( + infoDictionary: Bundle.main.infoDictionary), + releaseTrack: Self.hostReleaseTrack(), + capabilities: [ + "cmux.irx.v2", + "list-auth", + "iroh.private_paths.v1", + ] + ), + clientNamespace: namespace.rawValue + ), + tokenPair: { [weak auth] in + guard let auth else { return nil } + let session = try await auth.authenticatedSessionSnapshot() + return (session.accessToken, session.refreshToken) + }, + handlers: .init( + onRelayPasses: { [weak self, weak broker, weak supervisor, weak pilot] pushed in + guard await self?.canStartNetworking == true else { return false } + guard let broker, let supervisor, let pilot, + let accepted = await broker + .acceptPushedRelayCredentials(pushed) + else { return false } + await supervisor.rotateCredentials(accepted) + await pilot.kick() + await self?.publishIrxSettingsUpdate() + return true + }, + // The host dials no peers; hint facts are for clients. + onHintUpdate: { _, _ in true }, + onDirectory: { _ in true }, + onSnapshotComplete: { _ in }, + onDirectoryFact: { [weak self] fact in + await self?.applyDeviceListFact(fact) ?? false + }, + onFreshness: { [weak self] rev, issuedAt in + await self?.applyDeviceListFreshness( + rev: rev, issuedAt: issuedAt) } - } - let delay = Self.activationRetryDelay(after: error, failureCount: failures, - jitterUnitInterval: Double.random(in: 0...1)) - failures += 1 - do { try await Task.sleep(for: .seconds(delay)) } - catch { return } - } + ), + journal: Self.journal + ) + controlPlane = client + control = client + await client.start() + } else { + control = nil } - } - } - private static func credentials(_ cache: V2CachedState) -> [IrxRelayCredential] { - cache.relayCredentials.map { IrxRelayCredential(relayURL: $0.relayURL, token: $0.token, - expiresAt: Date(timeIntervalSince1970: Double($0.expiresAt)), - refreshAfter: Date(timeIntervalSince1970: Double($0.refreshAfter))) } - } + // Registration FIRST among the broker calls: non-legacy + // namespaces need the binding authorization it establishes + // before any other broker call (relay minting, discovery) is + // accepted. + try Task.checkCancellation() + let binding = try await broker.register( + pairingEnabled: true, + relayURLHint: nil + ) + try Task.checkCancellation() + localBinding = binding + let credentials = try await pilot.usableCredentials() + try Task.checkCancellation() + let initialDiscovery = try await broker.discover() + noteLiveDiscoverySucceeded() - private func requestEndpointReady(token: UUID) { - guard isCurrent(token) else { return } - guard endpointTask == nil else { endpointRefreshPending = true; return } - guard let supervisor = endpointSupervisor, - let cache = cachedState, !cache.authorityRevoked, - Self.pathMode == .directOnly || Self.credentials(cache).contains(where: { $0.isUsable(at: Date()) }) else { return } - endpointTask = Task { @MainActor [weak self] in - defer { - if let self, self.generationToken == token { - self.endpointTask = nil - if self.endpointRefreshPending { - self.endpointRefreshPending = false - self.requestEndpointReady(token: token) - } - } + try Task.checkCancellation() + guard generationToken == token, canStartNetworking else { return } + _ = try await supervisor.readyEndpoint(credentials: credentials) + try Task.checkCancellation() + // Advertise the relay the endpoint ACTUALLY homes on, then + // refresh the binding so registry consumers see it too. + let homeRelay = await supervisor.homeRelayURL() ?? credentials.first?.relayURL + let directAddresses = await supervisor.localDirectAddresses() + let directPorts = CmxIrohDirectPorts(localDirectAddresses: directAddresses) + try Task.checkCancellation() + _ = try? await broker.register( + pairingEnabled: true, + relayURLHint: homeRelay, + directAddresses: directAddresses, + directPorts: directPorts + ) + try Task.checkCancellation() + if let control, let homeRelay { + await control.publishHint(homeRelayURL: homeRelay) } - var failures = 0 - while !Task.isCancelled { - guard let self, self.isCurrent(token), let cache = self.cachedState, !cache.authorityRevoked else { return } - do { - let endpoint = try await supervisor.readyEndpoint(credentials: Self.credentials(cache)) - guard self.isCurrent(token), !Task.isCancelled else { return } - let endpointGeneration = await supervisor.currentGeneration - if self.relayAddressWatchGeneration != endpointGeneration { - await self.relayAddressWatch?.stop() - guard self.isCurrent(token), !Task.isCancelled else { return } - self.relayAddressWatchGeneration = endpointGeneration - self.relayAddressWatch = endpoint.watchAddr(callback: MobileHostV2RelayAddressCallback { [weak self] in - await self?.requestEndpointReady(token: token) - }) - } - await self.refreshListenerState(token: token) - guard self.isCurrent(token), !Task.isCancelled else { return } - self.startAcceptLoop(token: token) - self.setSettingsPhase(.active) - Self.journal.record("v2-host", "endpoint-ready", ["generation": String(await supervisor.currentGeneration)]) - await self.publishHomeRelayHintIfNeeded(token: token) - return - } catch { - guard self.isCurrent(token), !Task.isCancelled else { return } - self.listenerState.phase = .retrying - self.listenerState.boundPort = nil - self.listenerState.localSocketAddresses = [] - let delay = Self.activationRetryDelay(after: error, failureCount: failures, jitterUnitInterval: Double.random(in: 0...1)) - failures += 1 - try? await Task.sleep(for: .seconds(delay)) + let liveDiscovery = (try? await broker.discover(maximumAge: 0)) ?? initialDiscovery + try Task.checkCancellation() + if !Self.forceRelayOnly, + MobileHostService.isListeningEnabled, + let discoveredBinding = liveDiscovery.bindings.first(where: { + $0.endpointID.endpointID == identity.endpointIDHex + }), + let bindingMetadata = try? CmxIrohBrokerBindingMetadata( + bindingID: discoveredBinding.bindingID, + deviceID: discoveredBinding.deviceID, + appInstanceID: discoveredBinding.appInstanceID, + clientNamespace: discoveredBinding.clientNamespace, + tag: discoveredBinding.tag, + platform: discoveredBinding.platform, + endpointID: discoveredBinding.endpointID, + identityGeneration: discoveredBinding.identityGeneration, + pathHints: discoveredBinding.pathHints + ) + { + await lanPublisher.activate( + rendezvous: liveDiscovery.lanRendezvous, + binding: bindingMetadata, + directAddresses: { await supervisor.localDirectAddresses() } + ) + } + // Relay hints are server-capped at 1h; refresh the registration on + // every credential rotation so the advertised hint never expires, + // and announce it over the socket so phones hear about relay + // moves in milliseconds instead of at the next registry read. + try Task.checkCancellation() + await pilot.setOnRotation { [weak self, weak broker, weak supervisor] in + guard await self?.canStartNetworking == true, + let broker, let supervisor else { return } + let relay = await supervisor.homeRelayURL() + let directAddresses = await supervisor.localDirectAddresses() + let directPorts = CmxIrohDirectPorts(localDirectAddresses: directAddresses) + try? await broker.registerHintIfNeeded( + pairingEnabled: true, + relayURLHint: relay, + directAddresses: directAddresses, + directPorts: directPorts + ) + if let relay, let control { + await control.publishHint(homeRelayURL: relay) } + await self?.lanPublisher.refresh() + await self?.publishRoute( + identity: identity, + relayURL: relay, + directAddresses: directAddresses + ) + // Credential rotation (and any home-relay move it reveals) + // changes the Settings snapshot's policy expiry and relay + // selection; push it to live subscribers. + await self?.publishIrxSettingsUpdate() } - } - } + await pilot.start() - /// Publish only the public relay location needed by peers to address this - /// endpoint. Direct addresses stay local to the two clients. - private func publishHomeRelayHintIfNeeded(token: UUID) async { - guard isCurrent(token), let supervisor = endpointSupervisor else { return } - let relay = await supervisor.homeRelayURL() - guard isCurrent(token), let metadata = cachedState?.device?.descriptor.metadata else { return } - if let legacyService { - try? await legacyService.publishRelayHint(relay) - } - guard isCurrent(token), !Task.isCancelled, let relay, - metadata.relayURLs != [relay], let service = controlService else { return } - let next = V2DeviceMetadata( - appVersion: metadata.appVersion, - capabilities: metadata.capabilities, - displayName: metadata.displayName, - pairingEnabled: metadata.pairingEnabled, - platform: metadata.platform, - relayURLs: [relay] - ) - do { - try await service.updateMetadata(next) - guard isCurrent(token) else { return } - Self.journal.record("v2-host", "home-relay-published", ["relay": relay]) + try Task.checkCancellation() + publishRoute( + identity: identity, + relayURL: homeRelay, + directAddresses: directAddresses + ) + startAcceptLoop(token: token) + Self.journal.record( + "host-runtime", "active", + [ + "endpoint_id": identity.endpointIDHex, + "binding": binding.bindingID, + "tag": tag, + "path_mode": Self.forceRelayOnly ? "relay-only" : "automatic", + ] + ) + setSettingsPhase(.active) + activationFailureCount = 0 } catch { - guard isCurrent(token) else { return } - Self.journal.record("v2-host", "home-relay-publish-failed", ["error": String(describing: type(of: error))]) + guard !Task.isCancelled, generationToken == token else { return } + Self.journal.record( + "host-runtime", "activation-failed", + ["reason": String(describing: error)] + ) + if generationToken == token { + // Stays failed across the retry ladder (no activating/failed + // flicker in Settings); success or an account change clears it. + setSettingsPhase(.failed) + } + // One bounded retry ladder, reset on success and by the auth + // observation loop on account change. The broker's Retry-After + // (429 on challenge/register under mint spacing) is a floor, so a + // rejected Mac never re-mints inside the window it was told to + // wait out; the doubling ladder covers every other failure. + let delay = Self.activationRetryDelay( + after: error, + failureCount: activationFailureCount, + jitterUnitInterval: Double.random(in: 0 ... 1) + ) + activationFailureCount = min(activationFailureCount + 1, 20) + Self.journal.record( + "host-runtime", "activation-retry-scheduled", + [ + "delay_s": String(Int(delay)), + "server_floor_s": (error as? any CmxRetryAfterProviding)? + .retryAfterSeconds.map(String.init) ?? "-", + "failure_count": String(activationFailureCount), + ] + ) + try? await Task.sleep(for: .seconds(delay)) + if !Task.isCancelled, canStartNetworking, + generationToken == token, activeAccountID == accountID { + await activate(accountID: accountID) + } } } - private func enforcePeerPermissions(token: UUID) async { - guard isCurrent(token), let admission, let registry else { return } - let legacyCurrent = legacyService?.listCurrent - await registry.closeAll(code: .revoked, matching: { endpoint in - if let list = legacyCurrent?.current, let entry = list.entries[endpoint] { - return !list.isFresh(now: .now) || entry.revoked - || entry.capabilities?.contains(LegacyCompatibilityService.v2Capability) == true - } - return admission.authorizedPeer(endpointID: endpoint) == nil - }) + private func deactivate() async { + generationToken = UUID() + acceptLoop?.cancel() + let retiringAcceptLoop = acceptLoop + acceptLoop = nil + let retiringActivation = activationTask + activationTask = nil + retiringActivation?.cancel() + // Drain the canceled activation before releasing its resources: a + // late endpoint bind or broker response cannot repopulate them after + // this policy/account transition has torn them down. + await retiringActivation?.value + if let autopilot { + await autopilot.stop() + } + autopilot = nil + await lanPublisher.stop() + if let registry { + await registry.closeAll(code: .hostShutdown) + } + registry = nil + if let controlPlane { + await controlPlane.stop() + } + controlPlane = nil + // Fail closed immediately: with the box cleared, the accept loop's + // judge denies every hello. Persisted clearing happens only on + // explicit sign-out (see `transition(to:)`), so a relaunch on the + // same account keeps working offline. + deviceListBox?.clear() + deviceListBox = nil + deviceListStore = nil + if let endpointSupervisor { + await endpointSupervisor.close() + } + // Closing the endpoint wakes the accept loop. Drain it after the close + // so its endpoint-closed branch cannot outlive this deactivation. + await retiringAcceptLoop?.value + endpointSupervisor = nil + brokerService = nil + localBinding = nil + hadLiveDiscoveryThisRun = false + setSettingsPhase(.idle) + if publishesPublicHostStatus, Self.isEnabled { + MobileHostPublicStatusCache.update(irohIdentity: nil) + } + Self.journal.record("host-runtime", "deactivated") } - private func schedulePermissionExpiry(token: UUID) { - permissionExpiryTask?.cancel() - guard isCurrent(token), let admission else { return } - let legacyCurrent = legacyService?.listCurrent - permissionExpiryTask = Task { @MainActor [weak self] in - while !Task.isCancelled { - guard let self, self.isCurrent(token) else { return } - await self.enforcePeerPermissions(token: token) - guard self.isCurrent(token), !Task.isCancelled else { return } - let now = ContinuousClock().now - let legacyDeadline = legacyCurrent?.current.map { - $0.receivedAtMonotonic.advanced(by: .seconds($0.ttlSeconds)) - }.flatMap { $0 > now ? $0 : nil } - // An expired older-client list must not stop enforcement of - // future v2 permission expiry, or reschedule an elapsed deadline. - let deadline = [admission.nextExpiration, legacyDeadline].compactMap { $0 }.min() - guard let deadline else { return } - do { try await ContinuousClock().sleep(until: deadline) } - catch { return } + // MARK: - Device list (admission authority) + + /// Applies a pushed directory fact: build the lease snapshot, persist it, + /// swap it into the accept path atomically, acknowledge the revision, + /// then enforce it on LIVE sessions (a revoked or delisted device is cut + /// now with `.revoked`, not at its next admission). + private func applyDeviceListFact(_ fact: IrxCtlDirectoryFact) async -> Bool { + guard let deviceListBox, let deviceListStore else { return false } + if let current = deviceListBox.current, fact.rev <= current.rev { + Self.journal.record( + "host-runtime", "device-list-stale-rev", + ["rev": String(fact.rev), "have": String(current.rev)] + ) + return true + } + let snapshot = IrxDeviceListSnapshot( + fact: fact, + receivedAtWall: Date(), + receivedAtMonotonic: .now + ) + guard await deviceListStore.persist(snapshot) else { return false } + deviceListBox.replace(snapshot) + Self.journal.record( + "host-runtime", "device-list-applied", + ["rev": String(fact.rev), "entries": String(snapshot.entries.count)] + ) + if let registry { + await registry.closeAll(code: .revoked) { endpointIDHex in + guard let entry = snapshot.entries[endpointIDHex] else { return true } + return entry.revoked } } + return true + } + + /// An explicit freshness re-stamp (`current`, or a `snapshot_complete` + /// carrying `issuedAt`) extends the CURRENT lease without changing its + /// membership. + private func applyDeviceListFreshness(rev: Int, issuedAt: Date) async { + guard let deviceListBox, let deviceListStore else { return } + guard + let updated = deviceListBox.restamp( + rev: rev, + issuedAt: issuedAt, + receivedAtWall: Date(), + receivedAtMonotonic: .now + ) + else { return } + await deviceListStore.persist(updated) + Self.journal.record( + "host-runtime", "device-list-restamped", ["rev": String(rev)] + ) + } + + /// The lease's durable backend: Keychain in Release, the development + /// file store inside the irx state directory in DEBUG (the exact split + /// every other secure store uses; ad-hoc DEBUG builds lack the + /// data-protection Keychain entitlement). + private nonisolated static func deviceListSecureStore( + stateDirectory: URL + ) -> any CmxIrohSecureCredentialStoring { + #if DEBUG + CmxIrohDevelopmentFileCredentialStore( + directory: stateDirectory.appendingPathComponent( + "device-list", isDirectory: true) + ) + #else + CmxIrohKeychainCredentialStore( + service: "com.cmuxterm.irx.device-list.v1" + ) + #endif } - private func refreshListenerState(token: UUID) async { - guard isCurrent(token), let supervisor = endpointSupervisor else { return } - let healthy = await supervisor.isHealthy() - let port = await supervisor.boundPort() - let addresses = await supervisor.localDirectAddresses() - let relayURL = await supervisor.homeRelayURL() - guard isCurrent(token), !Task.isCancelled else { return } - var next = listenerState - next.phase = healthy ? .ready : .starting - next.boundPort = healthy ? port : nil - next.localSocketAddresses = healthy ? addresses : [] - listenerState = next - if healthy { publishRoute(relayURL: relayURL) } - else if publishesPublicHostStatus { MobileHostPublicStatusCache.update(irohIdentity: nil) } + /// The Mac build's control-plane release track: DEBUG builds are "dev", + /// nightly-flavored bundle ids are "nightly", everything else "stable". + private nonisolated static func hostReleaseTrack() -> String { + #if DEBUG + return "dev" + #else + let bundleIdentifier = Bundle.main.bundleIdentifier ?? "" + return bundleIdentifier.contains("nightly") ? "nightly" : "stable" + #endif } - private func publishRoute(relayURL: String?) { - guard publishesPublicHostStatus, let identity, - let peer = try? CmxIrohPeerIdentity(endpointID: identity.endpointIDHex) else { return } + /// Publishes the irx endpoint as THE iroh route: attach tickets, host + /// status, and presence all advertise it, so phones dial irx. Relay and + /// validated public direct hints are published here. Private LAN + /// candidates stay on the authenticated Bonjour path and are never copied + /// into the public status route. + private func publishRoute( + identity: IrxIdentity, + relayURL: String?, + directAddresses: [String] = [] + ) { + guard canStartNetworking else { return } + guard let peerIdentity = try? CmxIrohPeerIdentity(endpointID: identity.endpointIDHex) + else { return } + var hints: [CmxIrohPathHint] = [] let now = Date() - let expiry = cachedState?.relayCredentials.filter { $0.relayURL == relayURL }.map { - Date(timeIntervalSince1970: Double($0.expiresAt)) - }.max() - let hints: [CmxIrohPathHint] = relayURL.flatMap { relay in - guard let expiry, expiry > now else { return nil } - return try? CmxIrohPathHint(kind: .relayURL, value: relay, source: .native, - privacyScope: .publicInternet, observedAt: now, expiresAt: expiry) - }.map { [$0] } ?? [] - MobileHostPublicStatusCache.update(irohIdentity: peer, pathHints: hints) + if let relayURL, + let hint = try? CmxIrohPathHint( + kind: .relayURL, + value: relayURL, + source: .native, + privacyScope: .publicInternet, + observedAt: now, + expiresAt: now.addingTimeInterval(30 * 60) + ) + { + hints.append(hint) + } + if !Self.forceRelayOnly { + for address in directAddresses { + guard hints.count < 16, + let hint = try? CmxIrohPathHint( + kind: .directAddress, + value: address, + source: .native, + privacyScope: .publicInternet, + observedAt: now, + expiresAt: now.addingTimeInterval(30 * 60) + ) else { continue } + if !hints.contains(hint) { hints.append(hint) } + } + } + if publishesPublicHostStatus { + MobileHostPublicStatusCache.update(irohIdentity: peerIdentity, pathHints: hints) + } + Self.journal.record( + "host-runtime", "route-published", + [ + "hints": String(hints.count), + "direct": String(hints.count { $0.kind == .directAddress }), + "relay": relayURL ?? "-", + ] + ) } private func startAcceptLoop(token: UUID) { - guard acceptLoop == nil, let supervisor = endpointSupervisor, let registry, let admission else { return } - let legacyCurrent = legacyService?.listCurrent - let v2Judgment = admission.judgment() - let legacyJudgment = legacyCurrent.map { IrxListJudge(current: $0, journal: Self.journal).judgment() } - let judgment: IrxGrantJudgment = { grant, endpoint in - // A current legacy entry is authoritative for old peers. Modern - // endpoints are excluded from that list, so they can only pass the - // independent v2 authority and never gain legacy fallback. - if let legacyCurrent, legacyCurrent.current?.entries[endpoint] != nil, - let legacyJudgment { - return try legacyJudgment(grant, endpoint) - } - return try v2Judgment(grant, endpoint) + guard canStartNetworking else { return } + guard let endpointSupervisor, let brokerService, let registry, let localBinding, + let deviceListBox + else { return } + let journal = Self.journal + guard let acceptor = try? acceptorPeer(binding: localBinding) else { + journal.record("host-runtime", "activation-failed", ["reason": "acceptor-tuple"]) + return } - acceptLoop = Task { @MainActor [weak self] in + // LIST AUTH: irx admission judges the TLS key against the current + // device-list lease, synchronously and O(1) (an atomic box read; no + // actor, no disk, no network). The hello's grant is ignored. + let judge = IrxListJudge(current: deviceListBox, journal: journal) + // The legacy dialect (old phones) still verifies pair grants against + // the persisted trust snapshot, read through the broker's cache so + // the Release keychain migration cannot strand it. + let trustSnapshot = { brokerService.cachedTrustForAdmission() } + let brokerClient = brokerService.hostBrokerClient + acceptLoop = Task { [weak self] in + journal.record("host-runtime", "accept-loop-started") while !Task.isCancelled { - guard let self, self.isCurrent(token) else { return } - guard let inbound = await supervisor.acceptNextInbound() else { - guard self.isCurrent(token), !Task.isCancelled else { return } - self.acceptLoop = nil - await self.refreshListenerState(token: token) - guard self.isCurrent(token), !Task.isCancelled else { return } - self.requestEndpointReady(token: token) - return - } - guard self.isCurrent(token), !Task.isCancelled else { - if case .irx(let connection) = inbound { await connection.close(code: .hostShutdown, origin: .local) } - return + guard await self?.canStartNetworking == true else { return } + guard let inbound = await endpointSupervisor.acceptNextInbound() else { + // Endpoint closed or unbound: rebind with the freshest + // cached credentials and continue accepting. + guard await self?.generationToken == token, + await self?.canStartNetworking == true else { + return + } + do { + let credentials = await brokerService.cachedRelayCredentials() + guard await self?.generationToken == token, + await self?.canStartNetworking == true else { + return + } + _ = try await endpointSupervisor.readyEndpoint(credentials: credentials) + guard await self?.generationToken == token, + await self?.canStartNetworking == true else { + await endpointSupervisor.close() + return + } + } catch { + guard await self?.generationToken == token, + await self?.canStartNetworking == true else { + return + } + try? await Task.sleep(for: .seconds(1)) + } + continue } + guard await self?.canStartNetworking == true else { return } switch inbound { - case .irx(let connection): + case .irx(let irx): Task { [weak self] in - await self?.superviseConnection(connection, judgment: judgment, - admission: admission, legacyCurrent: legacyCurrent, - registry: registry, token: token) + await self?.superviseConnection( + irx, judge: judge, registry: registry, token: token) } case .foreign(let alpn, let connection): guard alpn == MobileHostIrxLegacyDialectServer.legacyALPN, - let legacyService, - let trust = legacyService.broker.cachedTrustForAdmission(), - let acceptor = self.legacyAcceptor(token: token) else { - try? connection.close(errorCode: 1, reason: Data("unsupported_alpn".utf8)) + await self?.canStartNetworking == true, + MobileHostIrxLegacyDialectServer.listenerEnabled, + let trust = trustSnapshot(), + let adopted = try? CmxIrohLibEndpointFactory + .adoptAcceptedConnection(connection) + else { + try? connection.close( + errorCode: 1, reason: Data("unsupported_alpn".utf8)) continue } - let adopted = try? CmxIrohLibEndpointFactory.adoptAcceptedConnection(connection) - guard let adopted else { continue } - Task { - await MobileHostIrxLegacyDialectServer.serve(adopted: adopted, - acceptor: acceptor, trust: trust, - brokerClient: legacyService.broker.hostBrokerClient, - listCurrent: legacyService.listCurrent, + Task { [weak self] in + guard let self else { return } + await MobileHostIrxLegacyDialectServer.serve( + adopted: adopted, + acceptor: acceptor, + trust: trust, + brokerClient: brokerClient, isCurrent: { [weak self] in - guard let self else { return false } - return await MainActor.run { self.isCurrent(token) } - }, journal: Self.journal) + let runtime = self + return await MainActor.run { + runtime?.generationToken == token + && runtime?.canStartNetworking == true + } + }, + journal: journal + ) } } } } } - private func legacyAcceptor(token: UUID) -> CmxIrohGrantPeer? { - guard isCurrent(token) else { return nil } - return legacyAcceptorPeer + private nonisolated func acceptorPeer(binding: IrxBindingSnapshot) throws -> CmxIrohGrantPeer { + CmxIrohGrantPeer( + bindingID: binding.bindingID, + deviceID: binding.deviceID, + tag: binding.tag, + platform: .mac, + endpointID: try CmxIrohPeerIdentity(endpointID: binding.endpointIDHex), + identityGeneration: binding.identityGeneration + ) } private func superviseConnection( _ irx: IrxConnection, - judgment: @escaping IrxGrantJudgment, - admission: V2InboundAdmissionAuthority, - legacyCurrent: IrxDeviceListCurrent?, + judge: IrxListJudge, registry: IrxServerSessionRegistry, token: UUID ) async { let journal = Self.journal + guard await canStartNetworking else { + await irx.close(code: .hostShutdown, origin: .local) + return + } guard let (peer, control, sessionID) = await IrxAdmission.performServer( connection: irx, - judgment: judgment, + judgment: judge.judgment(), journal: journal ) else { return } - let stillAuthorized: @Sendable (String) -> Bool = { endpoint in - if let list = legacyCurrent?.current, let entry = list.entries[endpoint] { - return list.isFresh(now: .now) && !entry.revoked - && entry.capabilities?.contains(LegacyCompatibilityService.v2Capability) != true - && (entry.deviceID == nil || entry.deviceID == peer.deviceID) - && (entry.bindingID == nil || entry.bindingID == peer.bindingID) - && (entry.tag == nil || entry.tag == peer.tag) - && (entry.identityGeneration == nil || entry.identityGeneration == peer.identityGeneration) - } - return admission.recheck(peer)(endpoint) - } let registered = await registry.admit( - deviceID: peer.bindingID, + deviceID: peer.deviceID, sessionID: sessionID, connection: irx, - stillAuthorized: stillAuthorized + stillAuthorized: { endpointIDHex in + do { + _ = try judge.judgment()(nil, endpointIDHex) + return true + } catch { + return false + } + } ) - guard registered, isCurrent(token) else { - await irx.close(code: .revoked, origin: .local) - return - } + guard registered else { return } // Automatic path mode: authorize NAT traversal so the admitted session // can upgrade to a direct/LAN path make-before-break. if !Self.forceRelayOnly { @@ -803,13 +1021,16 @@ final class MobileHostIrxRuntime: MobileHostPairingRuntime { authorization: .irohAdmission(admittedPeer), artifactTransfers: artifactRegistry, independentEventWriter: eventWriter, - // Admission has already authenticated this bounded pooled peer. - // It may wait for its first RPC while the client finishes setup; - // native Iroh owns its connection lifetime. - firstFrameTimeoutNanoseconds: 0, + // The bounded Iroh peer pool stays alive via transport keepalives. + // Control-idle timeout is for unowned legacy TCP connections and + // must not tear down a healthy multi-lane QUIC session. + idleTimeoutNanoseconds: 0, isCurrent: { [weak self] in let runtime = self - return await MainActor.run { runtime?.isCurrent(token) == true } + return await MainActor.run { + runtime?.generationToken == token + && runtime?.canStartNetworking == true + } } ) journal.record( @@ -823,7 +1044,7 @@ final class MobileHostIrxRuntime: MobileHostPairingRuntime { laneLoop.cancel() await eventWriter.close() await irx.close(code: .hostShutdown, origin: .local) - await registry.remove(deviceID: peer.bindingID, sessionID: sessionID) + await registry.remove(deviceID: peer.deviceID, sessionID: sessionID) } /// Post-admission lane dispatch: keepalive echo, terminal streams over @@ -947,6 +1168,19 @@ private actor MobileHostIrxTerminalLaneQuota { } } +/// Synchronous trust-snapshot reader for the admission path (no actor hop, +/// no network): reads the JSON the broker service persists. The caller passes +/// the per-bundle, per-broker state directory computed at activation so +/// admission never reads another build's (or another environment's) cache. +enum IrxDiskCacheTrustReader { + /// Reads the trust snapshot from the state directory selected at activation. + nonisolated static func read(stateDirectory: URL) -> IrxTrustSnapshot? { + return IrxDiskCache( + fileURL: stateDirectory.appendingPathComponent("trust.json") + ).load() + } +} + /// Server-events lane writer over irx: opened lazily at priority 50, reset on /// stall so the host service can renegotiate, mirroring the legacy contract. actor MobileHostIrxEventWriter: MobileHostIndependentEventWriting { diff --git a/Sources/Mobile/MobileHostService.swift b/Sources/Mobile/MobileHostService.swift index fc4d60cbca17..261adea33eb6 100644 --- a/Sources/Mobile/MobileHostService.swift +++ b/Sources/Mobile/MobileHostService.swift @@ -231,9 +231,6 @@ struct MobileHostServiceStatus { let routes: [CmxAttachRoute] let activeConnectionCount: Int let lastErrorDescription: String? - var pendingPortChange: Bool = false - var localSocketAddresses: [String] = [] - var isPairingReady = false var payload: [String: Any] { let now = Date() @@ -242,7 +239,6 @@ struct MobileHostServiceStatus { "port": port ?? NSNull(), "configured_port": configuredPort, "uses_ephemeral_fallback": usesEphemeralFallback, - "pending_port_change": pendingPortChange, "routes": routes.mobileHostJSONObjects(for: .authenticated, at: now), "active_connection_count": activeConnectionCount, "last_error": lastErrorDescription ?? NSNull() @@ -250,9 +246,33 @@ struct MobileHostServiceStatus { } } +/// What ``MobileHostService/syncToSettings()`` should do to reconcile +/// the live listener with the current settings. A pure value so the +/// restart-on-port-change logic is unit-testable without a real `NWListener`. +enum MobileHostSyncDecision: Equatable { + case noop + case start + case stop + case restart +} + +/// The single explicit opt-in controls every Mac-side iOS pairing transport. +struct MobileHostStartupPlan: Equatable { + let activatesIroh: Bool + let startsLegacyListener: Bool +} + +/// Outcome of an explicit "Apply port" request from settings. A pure value so +/// ``MobileHostService/portApplyDecision(enabled:currentBoundPort:requestedPort:isAvailable:)`` +/// is unit-testable without binding a real `NWListener`. enum MobileHostPortApplyOutcome: Equatable { + /// The port was accepted; the listener is (or will be) bound to it. case applied(Int) - case savedForLater + /// The port is in use by another process; the running listener was left untouched. + case portInUse + /// Pairing is off, so the port was saved and will bind when pairing is enabled. + case savedWhileDisabled + /// The requested port was outside the valid `1...65535` range. case invalid } @@ -293,7 +313,6 @@ final class MobileHostService { /// the connection, and which app instance owns its routes. nonisolated static func identityStatusPayload( routes: [CmxAttachRoute], - deviceID: String, additionalCapabilities: Set = [], phonePushDefaults: UserDefaults = .standard, phonePushAdmission: PhonePushAdmission = .unknown, @@ -315,7 +334,7 @@ final class MobileHostService { .sorted() ) payload["terminal_theme_revision_epoch"] = terminalThemeRevisionEpoch - payload["mac_device_id"] = deviceID + payload["mac_device_id"] = MobileHostIdentity.deviceID() payload["mac_instance_tag"] = MobileHostIdentity.instanceTag() if let clientNamespace = CmxIrohMacBundleNamespace( bundleIdentifier: Bundle.main.bundleIdentifier @@ -416,38 +435,107 @@ final class MobileHostService { } private let callbackQueue = DispatchQueue(label: "dev.cmux.mobile.host-listener") + private let routeResolver = MobileRouteResolver() private let ticketStore = MobileAttachTicketStore() + private var listener: NWListener? + private var listenerGeneration = UUID() + private var listenerUsesEphemeralFallback = false + private var listenerPort: Int? + /// The preferred port the active start-sequence targeted (regardless of an + /// ephemeral fallback). Used to decide whether a settings change needs a + /// restart. `nil` while stopped. + private var appliedPreferredPort: Int? + private var activeConnections: [UUID: MobileHostConnection] = [:] private var clientIDsByConnectionID: [UUID: Set] = [:] + private var lastErrorDescription: String? + /// Whether the managed-policy teardown already ran, so the frequent + /// `syncToSettings()` calls (every `UserDefaults` change) do not repeat + /// the full `stop()` while the policy stays enforced. + private var remoteControlPolicyStopApplied = false + /// Watches for network path changes while the listener is bound, so the + /// advertised route set (and the team device registry that + /// ``DeviceRegistryClient`` mirrors it into) refreshes when the Mac moves + /// networks or Tailscale flips, not only when the listener restarts. + /// `nil` while stopped. private var pathMonitor: MobileHostNetworkPathMonitor? /// Injected once via `configure(auth:)` at app startup, before the /// listener starts accepting connections. private var auth: AuthCoordinator? + private enum ConfiguredRuntime: Equatable { + case iroh + case irx + } + /// `nil` while iOS pairing is off. Keeping this state separate from the + /// persisted setting prevents sign-in and wake callbacks from configuring + /// a transport that the user never enabled. + private var configuredRuntime: ConfiguredRuntime? + private var readinessWaiters: [CheckedContinuation] = [] + private var readinessTimeoutTask: Task? let mobileBrowserStreamCoordinator = MobileBrowserStreamCoordinator() let mobileSimulatorStreamCoordinator = MobileSimulatorStreamCoordinator() #if DEBUG private var debugAcceptedStackAuthToken: String? #endif - private let defaults: UserDefaults - private let runtimeOverride: (any MobileHostPairingRuntime)? - private var pairingRuntime: any MobileHostPairingRuntime { runtimeOverride ?? MobileHostIrxRuntime.shared } + private init() {} - init(defaults: UserDefaults = .standard, runtime: (any MobileHostPairingRuntime)? = nil) { - self.defaults = defaults - runtimeOverride = runtime - } - - /// Inject the auth dependency. Call once at the composition root. - /// The v2 runtime owns the selected team's IROH device identity. + /// Inject the auth dependency. Call once at the composition root. The + /// transport runtime is configured only after the explicit iOS pairing + /// setting is on. func configure(auth: AuthCoordinator) { self.auth = auth - pairingRuntime.configure(auth: auth) + configureRuntimeIfNeeded() + } + + private func configureRuntimeIfNeeded() { + guard Self.shouldConfigurePairingRuntime( + pairingEnabled: Self.isListeningEnabled, + remoteControlEnabled: MobileRemoteControlPolicy.isEnabled, + runtimeAlreadyConfigured: configuredRuntime != nil + ), + let auth + else { return } + + if MobileHostIrxRuntime.isEnabled { + configuredRuntime = .irx + MobileHostIrxRuntime.shared.configure(auth: auth) + } else { + configuredRuntime = .iroh + MobileHostIrohRuntime.shared.configure(auth: auth) + } + } + + private func setRuntimeDesiredActive(_ active: Bool) { + switch configuredRuntime { + case .iroh: + MobileHostIrohRuntime.shared.setDesiredActive(active) + case .irx: + MobileHostIrxRuntime.shared.setDesiredActive(active) + case nil: + break + } + } + + private func beginRuntimeTeardown() { + guard let configuredRuntime else { return } + self.configuredRuntime = nil + MobileHostPublicStatusCache.update(irohIdentity: nil) + switch configuredRuntime { + case .iroh: + MobileHostIrohRuntime.shared.beginPairingOptOut() + case .irx: + MobileHostIrxRuntime.shared.beginPairingOptOut() + } } func updateIrohRoute( identity: CmxIrohPeerIdentity?, pathHints: [CmxIrohPathHint] = [] ) { + guard identity == nil || Self.isListeningEnabled else { + MobileHostPublicStatusCache.update(irohIdentity: nil) + return + } MobileHostPublicStatusCache.update( irohIdentity: identity, pathHints: pathHints @@ -455,6 +543,10 @@ final class MobileHostService { } func updateIrohBinding(_ binding: CmxIrohBrokerBindingMetadata) { + guard Self.isListeningEnabled else { + MobileHostPublicStatusCache.update(irohIdentity: nil) + return + } MobileHostPublicStatusCache.update(irohBinding: binding) } @@ -662,7 +754,17 @@ final class MobileHostService { if result.startDrain { Task { await connection.drainQueuedEvents() } } - + if result.shouldClose { + Task { + await connection.close( + reason: "event queue exceeded bounded capacity", + exit: CmxIrohAdmittedConnectionExit( + lifecycle: .controlWriteFailed, + failure: .sendQueueOverflow + ) + ) + } + } } if !resyncSurfaceIDs.isEmpty { MobileTerminalRenderObserver.requestRenderGridFullResync( @@ -678,6 +780,15 @@ final class MobileHostService { /// User-default key for the opt-in Mac-side iOS pairing listener. nonisolated static let listeningEnabledDefaultsKey = SettingCatalog().mobile.iOSPairingHost.userDefaultsKey + /// Key written by released builds before the setting moved into the + /// canonical settings catalog. Read only as a migration fallback. + nonisolated private static let legacyListeningEnabledDefaultsKey = "cmuxMobilePairingHostEnabled" + + /// Whether the mobile pairing host should bind a network listener at all. + /// + /// An explicit current or legacy Bool preference always wins. Without one, + /// every build stays off so sign-in and app lifecycle events cannot start + /// iOS or Iroh networking implicitly. nonisolated static var isListeningEnabled: Bool { isListeningEnabled(defaults: .standard) } @@ -693,14 +804,25 @@ final class MobileHostService { if let override = defaults.object(forKey: listeningEnabledDefaultsKey) as? Bool { return override } + if let legacyOverride = defaults.object(forKey: legacyListeningEnabledDefaultsKey) as? Bool { + return legacyOverride + } + _ = buildFlavor return false } /// User-default key for the preferred iOS pairing listener port. nonisolated static let portDefaultsKey = SettingCatalog().mobile.iOSPairingPort.userDefaultsKey - /// Preferred UDP port for the next IROH listener start. A busy port falls - /// back to an available port, which the runtime reports separately. + /// The preferred port read from settings. Both iOS listeners try to bind + /// it: the legacy TCP pairing listener here and the Iroh endpoint's UDP + /// socket (`MobileHostIrohRuntime` passes it as the endpoint bind + /// preference). + /// + /// Falls back to the catalog default (which mirrors + /// `CmxMobileDefaults.defaultHostPort`) when unset or outside the valid + /// `1...65535` range. Each listener still falls back independently to an + /// OS-assigned ephemeral port if this port is unavailable at bind time. nonisolated static func configuredPort(defaults: UserDefaults = .standard) -> Int { let fallback = SettingCatalog().mobile.iOSPairingPort.defaultValue guard let raw = defaults.object(forKey: portDefaultsKey) as? Int else { @@ -709,34 +831,361 @@ final class MobileHostService { return (1...65535).contains(raw) ? raw : fallback } - /// Saves a port preference without replacing an active IROH endpoint. - /// The native library applies it the next time pairing starts. + /// The port a settings change should reconcile the *running* listener to, or + /// `nil` when the stored value is present but out of range. + /// + /// Distinguished from ``configuredPort(defaults:)`` so an invalid value the + /// user is still editing (the field shows a warning) does not tear down a + /// running listener and silently rebind it to the default port. Returns the + /// catalog default when unset, the override when valid, and `nil` when the + /// stored value is out of range. + nonisolated static func resolvedDesiredPort(defaults: UserDefaults = .standard) -> Int? { + guard let raw = defaults.object(forKey: portDefaultsKey) as? Int else { + return SettingCatalog().mobile.iOSPairingPort.defaultValue + } + return (1...65535).contains(raw) ? raw : nil + } + + /// Pure reconciliation between the desired settings and the live listener + /// state. Factored out so the restart-on-port-change decision is unit + /// testable without binding a real `NWListener`. + /// + /// - Parameters: + /// - enabled: Whether the iOS pairing host is enabled in settings. + /// - listenerRunning: Whether a listener is currently bound. + /// - desiredPort: The preferred port from settings (``configuredPort(defaults:)``). + /// - appliedPort: The preferred port the running listener targeted, or + /// `nil` when stopped. + /// - Returns: The action ``syncToSettings()`` should take. + nonisolated static func syncDecision( + enabled: Bool, + listenerRunning: Bool, + desiredPort: Int, + appliedPort: Int? + ) -> MobileHostSyncDecision { + guard enabled else { return listenerRunning ? .stop : .noop } + guard listenerRunning else { return .start } + if appliedPort != desiredPort { return .restart } + return .noop + } + + /// An MDM-managed remote-control disable overrides the user's pairing opt-in: + /// no transport may host while the policy is enforced. + nonisolated static func startupPlan( + remoteControlDisabledByPolicy: Bool, + pairingEnabled: Bool, + legacyListenerRunning: Bool + ) -> MobileHostStartupPlan { + guard !remoteControlDisabledByPolicy else { + return MobileHostStartupPlan( + activatesIroh: false, + startsLegacyListener: false + ) + } + return MobileHostStartupPlan( + activatesIroh: pairingEnabled, + startsLegacyListener: pairingEnabled && !legacyListenerRunning + ) + } + + /// Pure pre-bind classification for an explicit "Apply port" request. Returns + /// the outcome for the cases that need no bind attempt, or `nil` when a real + /// bind must be tried (pairing on, valid port, different from the bound one). + /// Factored out so the decision is unit-testable without a real `NWListener`. + /// + /// - Parameters: + /// - enabled: Whether iOS pairing is enabled in settings. + /// - currentBoundPort: The port the listener is currently bound to, or `nil`. + /// - requestedPort: The port the user asked to apply. + nonisolated static func portApplyPreBindOutcome( + enabled: Bool, + currentBoundPort: Int?, + requestedPort: Int + ) -> MobileHostPortApplyOutcome? { + guard (1...65535).contains(requestedPort) else { return .invalid } + guard enabled else { return .savedWhileDisabled } + if currentBoundPort == requestedPort { return .applied(requestedPort) } + return nil + } + + /// Whether `error` means the address/port cannot be bound (in use, not + /// available, or permission denied) versus a transient waiting reason. + nonisolated static func isAddressUnavailable(_ error: NWError) -> Bool { + if case let .posix(code) = error { + return code == .EADDRINUSE || code == .EADDRNOTAVAIL || code == .EACCES + } + return false + } + + /// Applies an explicitly-requested pairing port. + /// + /// Make-before-break: when a running listener must move to a different port, a + /// candidate listener is bound on that port *first*; only if it actually binds + /// is the old listener torn down and the candidate adopted. So an in-use port + /// leaves the running listener and its connections untouched (no probe → + /// rebind gap that could drop connections). Operates on `UserDefaults.standard` + /// since it persists to and rebinds the live singleton listener. func applyConfiguredPort(_ port: Int) async -> MobileHostPortApplyOutcome { - guard (1...65535).contains(port) else { return .invalid } + let defaults = UserDefaults.standard + // Under a managed remote-control disable no listener may bind: + // classify as "saved while disabled" so the preference persists but + // no socket opens and no routes publish while the policy is enforced. + if let preBind = Self.portApplyPreBindOutcome( + enabled: Self.isListeningEnabled(defaults: defaults) + && MobileRemoteControlPolicy.isEnabled, + currentBoundPort: listenerPort, + requestedPort: port + ) { + switch preBind { + case .invalid, .portInUse: + break + case .savedWhileDisabled, .applied: + defaults.set(port, forKey: Self.portDefaultsKey) + } + return preBind + } + // A real bind is required (pairing on, valid port, different from bound). + guard let endpointPort = NWEndpoint.Port(rawValue: UInt16(port)) else { return .invalid } + guard let candidate = await bindReadyCandidate(on: endpointPort, generation: UUID()) else { + return .portInUse + } + adoptCandidateListener(candidate.listener, generation: candidate.generation, port: port) defaults.set(port, forKey: Self.portDefaultsKey) - NotificationCenter.default.post(name: .mobileHostStatusDidChange, object: nil) - let state = pairingRuntime.listenerState - if pairingRuntime.isNetworkingAllowed, state.isRunning, state.boundPort == port { - return .applied(port) + return .applied(port) + } + + /// Binds a candidate `NWListener` on `endpointPort` while the current listener + /// keeps running, returning it (with `generation`) once it reaches `.ready`, + /// or `nil` when the port is unavailable. A bounded, cancellable deadline + /// guarantees the call can't hang; on timeout/failure the candidate is torn + /// down and `nil` returned, leaving the live listener untouched. + private func bindReadyCandidate(on endpointPort: NWEndpoint.Port, generation: UUID) async -> (listener: NWListener, generation: UUID)? { + let tcpOptions = NWProtocolTCP.Options() + tcpOptions.noDelay = true + let candidate: NWListener + do { + candidate = try NWListener(using: NWParameters(tls: nil, tcp: tcpOptions), on: endpointPort) + } catch { + return nil + } + let queue = callbackQueue + let didBind: Bool = await withCheckedContinuation { (continuation: CheckedContinuation) in + // One-shot resume guard + deadline holder (lock carve-out): the state + // handler and the timeout race to resume the continuation exactly once. + let resumed = OSAllocatedUnfairLock(initialState: false) + let timeoutHolder = OSAllocatedUnfairLock?>(initialState: nil) + let finish: @Sendable (Bool) -> Void = { ready in + let alreadyResumed = resumed.withLock { state -> Bool in + if state { return true } + state = true + return false + } + guard !alreadyResumed else { return } + timeoutHolder.withLock { task in + task?.cancel() + task = nil + } + continuation.resume(returning: ready) + } + candidate.stateUpdateHandler = { state in + switch state { + case .ready: + finish(true) + case .failed, .cancelled: + finish(false) + case let .waiting(error): + if Self.isAddressUnavailable(error) { finish(false) } + default: + break + } + } + // NWListener needs a newConnectionHandler set before `start()` or it + // never reaches `.ready`; wiring the real accept path (with this + // generation) also means no connection is dropped once it's adopted. + candidate.newConnectionHandler = { connection in + Self.acceptConnectionOffMain(connection, generation: generation) + } + candidate.start(queue: queue) + // Bounded, cancellable safety deadline (check-timeout carve-out) so an + // unclassified/stuck listener state can never hang the Apply flow. + let timeout = Task { + try? await Task.sleep(for: .seconds(2)) + finish(false) + } + timeoutHolder.withLock { $0 = timeout } + } + guard didBind else { + candidate.stateUpdateHandler = nil + candidate.newConnectionHandler = nil + candidate.cancel() + return nil + } + return (candidate, generation) + } + + /// Cuts over to a freshly-bound `candidate`: tears down the old listener and + /// its connections (they reconnect on the new port), then adopts the candidate + /// as the live listener, routes future state changes through the normal + /// handler, and republishes routes. + private func adoptCandidateListener(_ candidate: NWListener, generation: UUID, port: Int) { + listener?.stateUpdateHandler = nil + listener?.newConnectionHandler = nil + listener?.cancel() + for connection in activeConnections.values { + Task { await connection.close(reason: "pairing port changed") } + } + for connection in MobileHostConnectionRegistry.shared.removeStackBearerConnections() { + Task { await connection.close(reason: "pairing port changed") } + } + activeConnections.removeAll() + + listener = candidate + listenerGeneration = generation + listenerUsesEphemeralFallback = false + listenerPort = port + appliedPreferredPort = port + lastErrorDescription = nil + // The candidate is already `.ready`; route only *future* states normally. + candidate.stateUpdateHandler = { state in + Task { @MainActor in + MobileHostService.shared.handleListenerState(state, generation: generation) + } } - return .savedForLater + routeResolver.refreshTailscaleRoutes(onResolvedHosts: { [weak self] hosts in + Task { @MainActor [weak self] in + self?.updatePublicStatusRoutes(port: port, generation: generation, tailscaleHosts: hosts) + } + }) + MobileHostPublicStatusCache.update(routes: routeResolver.routes(port: port).routes) + startNetworkPathMonitorIfNeeded() + drainReadinessWaiters() } func start() { - syncToSettings() + let pairingEnabled = Self.isListeningEnabled + if pairingEnabled { + configureRuntimeIfNeeded() + } + let plan = Self.startupPlan( + remoteControlDisabledByPolicy: MobileRemoteControlPolicy.isDisabled, + pairingEnabled: pairingEnabled, + legacyListenerRunning: listener != nil + ) + if MobileRemoteControlPolicy.isDisabled { + mobileHostLog.info("mobile host disabled by managed policy; not starting") + } + guard plan.startsLegacyListener else { + if !plan.activatesIroh { + beginRuntimeTeardown() + if listener != nil { + stopLegacyListener(reason: "iOS pairing disabled") + } + mobileHostLog.info("iOS pairing disabled; no mobile networking starts") + return + } + mobileHostLog.info("legacy mobile host listener disabled; starting Iroh only") + setRuntimeDesiredActive(true) + return + } + + CmxIrohTCPFirstActivation.start( + startTCP: { + guard Self.isListeningEnabled else { return } + startListener(usePreferredPort: true) + }, + scheduleIroh: { + guard Self.isListeningEnabled else { return } + self.setRuntimeDesiredActive(true) + } + ) + } + + private func startListener(usePreferredPort: Bool) { + guard Self.isListeningEnabled, MobileRemoteControlPolicy.isEnabled else { return } + let desiredPort = Self.configuredPort() + appliedPreferredPort = desiredPort + do { + let tcpOptions = NWProtocolTCP.Options() + tcpOptions.noDelay = true + let parameters = NWParameters(tls: nil, tcp: tcpOptions) + let nextListener = try makeListener( + parameters: parameters, + usePreferredPort: usePreferredPort, + port: desiredPort + ) + let generation = UUID() + listenerGeneration = generation + nextListener.stateUpdateHandler = { state in + Task { @MainActor in + MobileHostService.shared.handleListenerState(state, generation: generation) + } + } + nextListener.newConnectionHandler = { connection in + Self.acceptConnectionOffMain(connection, generation: generation) + } + listener = nextListener + listenerUsesEphemeralFallback = !usePreferredPort + listenerPort = nil + nextListener.start(queue: callbackQueue) + startNetworkPathMonitorIfNeeded() + } catch { + if usePreferredPort { + guard Self.isListeningEnabled, MobileRemoteControlPolicy.isEnabled else { return } + mobileHostLog.info("mobile host preferred port unavailable before listener start, falling back to an ephemeral port") + startListener(usePreferredPort: false) + return + } + lastErrorDescription = String(describing: error) + mobileHostLog.error("mobile host listener failed to start: \(String(describing: error), privacy: .public)") + // No listener was registered, so no state callback will fire to drain + // readiness waiters; resolve them now instead of waiting for the deadline. + drainReadinessWaiters() + } + } + + private func makeListener( + parameters: NWParameters, + usePreferredPort: Bool, + port: Int + ) throws -> NWListener { + if usePreferredPort, + let rawPort = UInt16(exactly: port), + let endpointPort = NWEndpoint.Port(rawValue: rawPort) { + return try NWListener(using: parameters, on: endpointPort) + } + return try NWListener(using: parameters, on: .any) } func stop() { - let runtime = pairingRuntime - runtime.prepareForStop() - Task { @MainActor in await runtime.stopHost() } - stopNetworkPathMonitor() + beginRuntimeTeardown() + stopLegacyListener(reason: "service stopped") for connection in MobileHostConnectionRegistry.shared.removeAll() { Task { await connection.close(reason: "service stopped") } } MobileHostEventSubscriptionTracker.reset() MobileHostPublicStatusCache.removeAll() TerminalController.shared.clearAllMobileViewportReports(reason: "mobile.host.stopped") + drainReadinessWaiters() + } + + private func stopLegacyListener(reason: String) { + stopNetworkPathMonitor() + listenerGeneration = UUID() + listenerUsesEphemeralFallback = false + listener?.stateUpdateHandler = nil + listener?.newConnectionHandler = nil + listener?.cancel() + listener = nil + listenerPort = nil + appliedPreferredPort = nil + for connection in activeConnections.values { + Task { await connection.close(reason: reason) } + } + for connection in MobileHostConnectionRegistry.shared.removeStackBearerConnections() { + Task { await connection.close(reason: reason) } + } + activeConnections.removeAll() + MobileHostPublicStatusCache.update(routes: []) } func statusSnapshot() -> MobileHostServiceStatus { @@ -766,12 +1215,11 @@ final class MobileHostService { signalContinuation.yield(()) } ) - let drainTask = Task { @MainActor [weak self] in - guard let self else { continuation.finish(); return } - continuation.yield(self.statusSnapshot()) + let drainTask = Task { @MainActor in + continuation.yield(MobileHostService.shared.statusSnapshot()) for await _ in signals { if Task.isCancelled { break } - continuation.yield(self.statusSnapshot()) + continuation.yield(MobileHostService.shared.statusSnapshot()) } continuation.finish() } @@ -783,57 +1231,165 @@ final class MobileHostService { } } - /// Waits for the IROH owner's actual readiness, with a bounded UI wait. - func ensureListeningAndReady(timeout: Duration = .seconds(6)) async -> MobileHostServiceStatus { - let runtime = pairingRuntime - if !runtime.isNetworkingAllowed { runtime.prepareForStop() } - await runtime.applyManagedNetworkingPolicy() - guard runtime.isNetworkingAllowed, !runtime.listenerState.isSettled else { return statusSnapshot() } - let updates = runtime.listenerStateUpdates() - await withTaskGroup(of: Void.self) { group in - group.addTask { - for await state in updates { - if Task.isCancelled || state.isSettled { return } + /// Starts the pairing listener (if enabled and not already bound) and + /// resolves once it can mint attach tickets, so the in-app pairing window + /// can render a QR code without polling the listener state machine. + /// + /// Resolves immediately when the listener is already ready, or when pairing + /// is disabled (the caller then renders an "off" state). Otherwise it awaits + /// the next listener-state transition (`ready`, terminal `failed`, or + /// `cancelled`) via a continuation, with a bounded safety deadline so the UI + /// never hangs on a listener that never settles. + func ensureListeningAndReady() async -> MobileHostServiceStatus { + start() + if listener == nil || listenerPort != nil { + return statusSnapshot() + } + return await withCheckedContinuation { continuation in + readinessWaiters.append(continuation) + if readinessTimeoutTask == nil { + // Bounded, cancellable deadline: a local NWListener normally + // reaches `.ready` within milliseconds; this only guards a + // never-settling listener. Cancelled on the normal drain path. + readinessTimeoutTask = Task { @MainActor [weak self] in + try? await ContinuousClock().sleep(for: .seconds(6)) + guard let self, !Task.isCancelled else { return } + self.drainReadinessWaiters() } } - group.addTask { try? await Task.sleep(for: timeout) } - _ = await group.next() - group.cancelAll() } - return statusSnapshot() + } + + /// Resumes every pending ``ensureListeningAndReady()`` caller with the + /// current status and clears the bounded readiness deadline. + private func drainReadinessWaiters() { + readinessTimeoutTask?.cancel() + readinessTimeoutTask = nil + guard !readinessWaiters.isEmpty else { return } + let snapshot = statusSnapshot() + let waiters = readinessWaiters + readinessWaiters.removeAll() + for waiter in waiters { + waiter.resume(returning: snapshot) + } } private func makeStatus(routes: [CmxAttachRoute]) -> MobileHostServiceStatus { - let runtime = pairingRuntime - let state = runtime.isNetworkingAllowed ? runtime.listenerState : MobileHostListenerState() - let desiredPort = Self.configuredPort(defaults: defaults) + let isRunning = (listener != nil && listenerPort != nil) + || MobileHostPublicStatusCache.hasIrohRoute() return MobileHostServiceStatus( - isRunning: state.isRunning, - port: state.boundPort, - configuredPort: desiredPort, - usesEphemeralFallback: state.usesEphemeralFallback, - routes: state.isRunning ? routes : [], + isRunning: isRunning, + port: listenerPort, + configuredPort: Self.configuredPort(), + // The actual bind outcome, not a recomputation from current defaults: + // editing the preferred port before a restart must not flip this. + usesEphemeralFallback: isRunning && listenerUsesEphemeralFallback, + routes: routes, activeConnectionCount: MobileHostConnectionRegistry.shared.count, - lastErrorDescription: state.failureDescription, - pendingPortChange: state.isRunning && state.preferredPort != desiredPort, - localSocketAddresses: state.localSocketAddresses, - isPairingReady: state.isRunning && state.hasAuthenticatedRegistration + lastErrorDescription: lastErrorDescription ) } - /// The runtime alone reconciles pairing policy. Ordinary settings writes - /// leave its endpoint and established sessions running. + /// Reconcile the live listener with current settings (enable/disable and + /// preferred-port changes). Safe to call on any settings change: it no-ops + /// unless the enabled state or the configured port actually changed, so an + /// unrelated `UserDefaults` write does not drop active iOS connections. + /// + /// Reads `UserDefaults.standard` because the live singleton listener binds + /// against the app's real store; `start`/`restart` do the same, so there is + /// no caller-supplied store to honor here. func syncToSettings() { - let runtime = pairingRuntime - if !runtime.isNetworkingAllowed { runtime.prepareForStop() } - Task { @MainActor in await runtime.applyManagedNetworkingPolicy() } - if runtime.isNetworkingAllowed { - startNetworkPathMonitorIfNeeded() + let defaults = UserDefaults.standard + let pairingEnabled = Self.isListeningEnabled(defaults: defaults) + if pairingEnabled { + configureRuntimeIfNeeded() } else { - stopNetworkPathMonitor() - for connection in MobileHostConnectionRegistry.shared.removeAll() { - Task { await connection.close(reason: "iOS pairing disabled") } + beginRuntimeTeardown() + } + // An MDM-managed remote-control disable overrides every transport: + // tear down the Iroh runtime, the legacy listener, and every live + // connection, and refuse to re-arm until the policy is lifted. + guard MobileRemoteControlPolicy.isEnabled else { + if !remoteControlPolicyStopApplied { + remoteControlPolicyStopApplied = true + mobileHostLog.info("remote control disabled by managed policy; stopping mobile host") + stop() } + return + } + remoteControlPolicyStopApplied = false + setRuntimeDesiredActive(pairingEnabled) + if pairingEnabled, configuredRuntime == .irx { + Task { @MainActor in + await MobileHostIrxRuntime.shared.applyManagedNetworkingPolicy() + } + } + // An invalid stored port (`resolvedDesiredPort == nil`, e.g. mid-edit) + // must not restart a running listener. Treat it as "no change" by + // reusing the applied port; a fresh start still binds the default via + // `configuredPort()`. + let desiredPort = Self.resolvedDesiredPort(defaults: defaults) + ?? appliedPreferredPort + ?? Self.configuredPort(defaults: defaults) + switch Self.syncDecision( + enabled: pairingEnabled, + listenerRunning: listener != nil, + desiredPort: desiredPort, + appliedPort: appliedPreferredPort + ) { + case .noop: + break + case .start: + start() + case .stop: + stopLegacyListener(reason: "legacy pairing listener disabled") + case .restart: + restart() + } + } + + private func restart() { + stopLegacyListener(reason: "pairing port changed") + start() + } + + nonisolated private static func acceptConnectionOffMain( + _ connection: NWConnection, + generation: UUID + ) { + Task.detached(priority: .userInitiated) { + let canAccept = await MobileHostService.shared.canAcceptConnection(generation: generation) + guard canAccept else { + mobileHostLog.info("mobile host rejected stale listener connection") + connection.cancel() + return + } + + #if !DEBUG + // Release builds never advertise a loopback route (the 127.0.0.1 + // `debugLoopback` route is DEBUG-only, see `MobileRouteResolver`), so a + // legitimate phone always reaches the Mac over the Tailscale interface. + // A connection arriving on loopback in release can only be a local + // process (or a browser that somehow framed the binary protocol), never + // the real client, so refuse it outright. DEBUG keeps loopback so the + // iOS Simulator (which reaches the Mac via 127.0.0.1) can still pair. + if Self.isLoopbackConnection(connection) { + mobileHostLog.error("mobile host rejected loopback connection in release build") + connection.cancel() + return + } + #endif + + let transport = CmxNetworkByteTransport(acceptedConnection: connection) + await Self.acceptTransport( + transport, + authorization: .legacyPrivateNetworkListener, + isCurrent: { + await MobileHostService.shared.canAcceptConnection( + generation: generation + ) + } + ) } } @@ -843,7 +1399,7 @@ final class MobileHostService { authorization: MobileHostConnectionAuthorizationContext, artifactTransfers: MobileHostIrohArtifactTransferRegistry? = nil, independentEventWriter: (any MobileHostIndependentEventWriting)? = nil, - firstFrameTimeoutNanoseconds: UInt64? = nil, + idleTimeoutNanoseconds: UInt64? = nil, promoteUsableSession: @escaping @Sendable () async -> Bool = { true }, remoteControlDisabledByPolicy: @escaping @Sendable () -> Bool = { MobileRemoteControlPolicy.isDisabled @@ -854,8 +1410,9 @@ final class MobileHostService { lifecycle: .explicitlyInvalidated, failure: .none ) - // Recheck managed policy at the RPC admission boundary to cover an - // accepted stream that raced with disabling remote control. + // Universal admission funnel for every transport (Iroh and the legacy + // TCP listener): refuse here too, so races and already-open listeners + // cannot admit a connection while the managed policy is enforced. guard !remoteControlDisabledByPolicy() else { mobileHostLog.info("mobile host refused transport: remote control disabled by managed policy") await transport.close() @@ -870,20 +1427,11 @@ final class MobileHostService { } let id = UUID() - let defaultFirstFrameTimeout: UInt64 = switch authorization { - case .irohAdmission: - // Iroh owns admission and native connection liveness. A delayed - // first control frame is valid while the admitted session is - // settling, so an application timer must not retire it. - 0 - case .stackBearer: - MobileHostConnection.defaultFirstFrameTimeoutNanoseconds - } let session = MobileHostConnection( id: id, transport: transport, - firstFrameTimeoutNanoseconds: firstFrameTimeoutNanoseconds - ?? defaultFirstFrameTimeout, + idleTimeoutNanoseconds: idleTimeoutNanoseconds + ?? MobileHostConnection.defaultIdleTimeoutNanoseconds, independentEventWriter: independentEventWriter, authorizeRequest: { request in await Self.connectionAuthorizationError( @@ -1007,6 +1555,10 @@ final class MobileHostService { } } + private func canAcceptConnection(generation: UUID) -> Bool { + listener != nil && generation == listenerGeneration + } + func createAttachTicket( workspaceID: String, terminalID: String?, @@ -1076,8 +1628,36 @@ final class MobileHostService { /// /// Used to refuse local connections in release builds, where no legitimate /// client ever connects via `127.0.0.1`/`::1`. + nonisolated static func isLoopbackConnection(_ connection: NWConnection) -> Bool { + isLoopbackEndpoint(connection.endpoint) || isLoopbackEndpoint(connection.currentPath?.remoteEndpoint) + } + + nonisolated static func isLoopbackEndpoint(_ endpoint: NWEndpoint?) -> Bool { + guard case let .hostPort(host, _)? = endpoint else { return false } + switch host { + case let .ipv4(address): + // 127.0.0.0/8 + return address.rawValue.first == 127 + case let .ipv6(address): + let bytes = Array(address.rawValue) + guard bytes.count == 16 else { return false } + // ::1 + let isV6Loopback = bytes[0..<15].allSatisfy { $0 == 0 } && bytes[15] == 1 + // IPv4-mapped loopback ::ffff:127.0.0.0/8 + let isV4MappedLoopback = bytes[0..<10].allSatisfy { $0 == 0 } + && bytes[10] == 0xff && bytes[11] == 0xff && bytes[12] == 127 + return isV6Loopback || isV4MappedLoopback + case let .name(name, _): + let lowered = name.lowercased() + return lowered == "localhost" || lowered.hasSuffix(".localhost") + @unknown default: + return false + } + } + private func removeConnection(id: UUID) { MobileHostConnectionRegistry.shared.remove(id: id) + activeConnections.removeValue(forKey: id) // Drop this connection's sticky viewport reports so a disconnected // device stops pinning the shared grid (and its macOS viewport border // clears) even though it never sent an explicit clear. @@ -1277,6 +1857,97 @@ final class MobileHostService { } } + private func handleListenerState(_ state: NWListener.State, generation: UUID) { + guard generation == listenerGeneration else { + return + } + + switch state { + case .ready: + listenerPort = listener?.port.map { Int($0.rawValue) } + lastErrorDescription = nil + if let listenerPort { + routeResolver.refreshTailscaleRoutes(onResolvedHosts: { [weak self] hosts in + Task { @MainActor [weak self] in + self?.updatePublicStatusRoutes( + port: listenerPort, + generation: generation, + tailscaleHosts: hosts + ) + } + }) + MobileHostPublicStatusCache.update(routes: routeResolver.routes(port: listenerPort).routes) + } else { + MobileHostPublicStatusCache.update(routes: []) + } + mobileHostLog.info("mobile host listener ready on port \(self.listenerPort ?? 0)") + drainReadinessWaiters() + case let .failed(error): + handleListenerBindFailure(error: error, context: "failed after start") + case .cancelled: + listenerGeneration = UUID() + listener = nil + listenerUsesEphemeralFallback = false + listenerPort = nil + MobileHostPublicStatusCache.update(routes: []) + drainReadinessWaiters() + case let .waiting(error): + // A preferred-port bind blocked by another listener surfaces as + // `.waiting(.posix(.EADDRINUSE))` rather than `.failed`, and NWListener + // would otherwise wait forever; treat address-unavailable the same as + // a failure so the ephemeral fallback (and bound-port warning) fire. + if Self.isAddressUnavailable(error) { + handleListenerBindFailure(error: error, context: "in use (waiting)") + } else { + listenerPort = nil + MobileHostPublicStatusCache.update(routes: []) + } + case .setup: + listenerPort = nil + MobileHostPublicStatusCache.update(routes: []) + @unknown default: + break + } + } + + /// Tears down a listener that could not bind its preferred port and, unless + /// it was already on the ephemeral fallback, retries on an OS-assigned port. + /// Shared by the `.failed` and `.waiting(addressUnavailable)` paths. + private func handleListenerBindFailure(error: NWError, context: String) { + lastErrorDescription = String(describing: error) + MobileHostPublicStatusCache.update(routes: []) + let shouldRetryWithEphemeralPort = !listenerUsesEphemeralFallback + listener?.stateUpdateHandler = nil + listener?.newConnectionHandler = nil + listener?.cancel() + listenerGeneration = UUID() + listener = nil + listenerUsesEphemeralFallback = false + listenerPort = nil + if shouldRetryWithEphemeralPort { + mobileHostLog.info("mobile host preferred port \(context, privacy: .public), falling back to an ephemeral port") + startListener(usePreferredPort: false) + } else { + mobileHostLog.error("mobile host listener bind failed on ephemeral port: \(String(describing: error), privacy: .public)") + // No retry left: unblock any readiness waiters (the retry path drains + // them when the ephemeral listener reaches `.ready`). + drainReadinessWaiters() + } + } + + private func updatePublicStatusRoutes( + port: Int, + generation: UUID, + tailscaleHosts: [String] + ) { + guard generation == listenerGeneration, listenerPort == port else { + return + } + MobileHostPublicStatusCache.update( + routes: routeResolver.routes(port: port, tailscaleHosts: tailscaleHosts).routes + ) + } + // MARK: - Network path monitoring /// Begin republishing routes on network path changes (observation and @@ -1297,15 +1968,58 @@ final class MobileHostService { } private func handleNetworkPathChange() { - let runtime = pairingRuntime - Task { @MainActor in await runtime.foreground() } + MobileHostIrohRuntime.shared.retryIfNeeded() + // The cached Tailscale hosts (and any in-flight resolution) may describe + // the previous network; drop them on EVERY path observation so no later + // refresh can be satisfied from, or raced by, old-path state. This must + // happen before the no-port early return: the monitor's first + // observation can land mid-bind, advancing its dedup baseline, and the + // `.ready` publish that follows would otherwise be free to reuse a + // TTL-fresh cache from the previous network with no further path + // callback coming to correct it. + routeResolver.invalidateResolvedTailscaleHostCache() + guard let port = listenerPort else { + // Mid-bind (no port yet): the `.ready` handler publishes against the + // current path when the bind completes, and the invalidation above + // guarantees it resolves freshly. + return + } + let generation = listenerGeneration + // Same two-phase publish as the listener-ready handler: immediate routes + // from interface scan now, DNS-resolved hosts when they land. + routeResolver.refreshTailscaleRoutes(onResolvedHosts: { [weak self] hosts in + Task { @MainActor [weak self] in + self?.updatePublicStatusRoutes(port: port, generation: generation, tailscaleHosts: hosts) + } + }) + MobileHostPublicStatusCache.update(routes: routeResolver.routes(port: port).routes) + } +} + +extension MobileHostService { + /// Pure gate for composition-root runtime setup. A signed-in account or a + /// wake event cannot configure the Iroh transport while pairing is off. + nonisolated static func shouldConfigurePairingRuntime( + pairingEnabled: Bool, + remoteControlEnabled: Bool, + runtimeAlreadyConfigured: Bool + ) -> Bool { + pairingEnabled && remoteControlEnabled && !runtimeAlreadyConfigured } } #if DEBUG extension MobileHostService { + func debugStopLegacyListenerForTesting() { + stopLegacyListener(reason: "test legacy listener restart") + } + func debugResetMobileLifecycleStateForTesting() { + listenerGeneration = UUID() + listenerUsesEphemeralFallback = false + listenerPort = nil + activeConnections.removeAll() clientIDsByConnectionID.removeAll() MobileHostRequestActivity.resetForTesting() MobileHostEventSubscriptionTracker.resetForTesting() @@ -1323,6 +2037,32 @@ extension MobileHostService { clientIDsByConnectionID[connectionID] } + func debugSetListenerStateForTesting( + generation: UUID, + usesEphemeralFallback: Bool, + port: Int? + ) { + listenerGeneration = generation + listenerUsesEphemeralFallback = usesEphemeralFallback + listenerPort = port + } + + func debugHandleListenerStateForTesting(_ state: NWListener.State, generation: UUID) { + handleListenerState(state, generation: generation) + } + + func debugListenerGenerationForTesting() -> UUID { + listenerGeneration + } + + func debugListenerPortForTesting() -> Int? { + listenerPort + } + + func debugListenerUsesEphemeralFallbackForTesting() -> Bool { + listenerUsesEphemeralFallback + } + func debugConfigureAcceptedStackAuthTokenForTesting(_ token: String?) { debugAcceptedStackAuthToken = MobileHostDevStackAuthPolicy.normalizedToken(token) } @@ -1342,7 +2082,15 @@ extension MobileHostService { #endif actor MobileHostConnection { - fileprivate static let defaultFirstFrameTimeoutNanoseconds: UInt64 = 15 * 1_000_000_000 + private static let maximumReceiveBufferByteCount = MobileSyncFrameCodec.defaultMaximumFrameByteCount + MobileSyncFrameCodec.headerByteCount + private static let defaultFirstFrameTimeoutNanoseconds: UInt64 = 15 * 1_000_000_000 + fileprivate static let defaultIdleTimeoutNanoseconds: UInt64 = 30 * 1_000_000_000 + /// Bounded deadline for one control-lane event write. A peer that accepted + /// the connection but stopped reading (TCP zero-window, QUIC flow-control + /// stall) would otherwise pin the drain — and with it this connection's + /// queue, transport, and tasks — indefinitely (issue #8842). + private static let defaultEventSendStallTimeoutNanoseconds: UInt64 = 30 * 1_000_000_000 + private struct EventSubscription: Sendable { let topics: Set let transport: MobileHostEventTransport @@ -1382,6 +2130,7 @@ actor MobileHostConnection { private let writer: MobileHostSerializedTransportWriter private let independentEventWriter: (any MobileHostIndependentEventWriting)? private let firstFrameTimeoutNanoseconds: UInt64 + private let idleTimeoutNanoseconds: UInt64 private let authorizeRequest: @Sendable (MobileHostRPCRequest) async -> MobileHostRPCResult? private let onAuthorizedRequest: @Sendable (MobileHostRPCRequest) async -> Void private let onUsableSession: @Sendable () async -> Bool @@ -1389,12 +2138,18 @@ actor MobileHostConnection { private let onClose: @Sendable (UUID) async -> Void private let requestSimulatorFrameReplay: @Sendable (UUID, Set) async -> Void private let responseWorkQuota = MobileHostRPCWorkQuota() - /// Pre-write mailbox with synchronous admission from the event + /// Bounded pre-write mailbox with synchronous admission from the event /// fan-out. Nonisolated so ``MobileHostService/emitEvent(topic:payload:)`` /// admits events without scheduling any per-event actor work. nonisolated let eventQueue: MobileHostConnectionEventQueue + private let eventSendStallTimeoutNanoseconds: UInt64 + /// Invalidates the pending event-send stall deadline: bumped when a send + /// starts and again when it settles, so a deadline armed for send N can + /// never close the connection after N completed. + private var eventSendGeneration: UInt64 = 0 private var receiveBuffer = Data() private var firstFrameTimeoutTask: Task? + private var idleTimeoutTask: Task? private var responseTasks: [UUID: ResponseTask] = [:] /// PTY-writing requests are ordered PER SURFACE: ordering is only a /// property of one terminal, and a connection-wide FIFO would let one @@ -1423,6 +2178,8 @@ actor MobileHostConnection { connection: NWConnection, eventQueue: MobileHostConnectionEventQueue = MobileHostConnectionEventQueue(), firstFrameTimeoutNanoseconds: UInt64 = MobileHostConnection.defaultFirstFrameTimeoutNanoseconds, + idleTimeoutNanoseconds: UInt64 = MobileHostConnection.defaultIdleTimeoutNanoseconds, + eventSendStallTimeoutNanoseconds: UInt64 = MobileHostConnection.defaultEventSendStallTimeoutNanoseconds, independentEventWriter: (any MobileHostIndependentEventWriting)? = nil, authorizeRequest: @escaping @Sendable (MobileHostRPCRequest) async -> MobileHostRPCResult?, onAuthorizedRequest: @escaping @Sendable (MobileHostRPCRequest) async -> Void, @@ -1437,6 +2194,8 @@ actor MobileHostConnection { self.writer = MobileHostSerializedTransportWriter(transport: transport) self.independentEventWriter = independentEventWriter self.firstFrameTimeoutNanoseconds = firstFrameTimeoutNanoseconds + self.idleTimeoutNanoseconds = idleTimeoutNanoseconds + self.eventSendStallTimeoutNanoseconds = eventSendStallTimeoutNanoseconds self.authorizeRequest = authorizeRequest self.onAuthorizedRequest = onAuthorizedRequest self.onUsableSession = onUsableSession @@ -1451,6 +2210,8 @@ actor MobileHostConnection { transport: any CmxByteTransport, eventQueue: MobileHostConnectionEventQueue = MobileHostConnectionEventQueue(), firstFrameTimeoutNanoseconds: UInt64 = MobileHostConnection.defaultFirstFrameTimeoutNanoseconds, + idleTimeoutNanoseconds: UInt64 = MobileHostConnection.defaultIdleTimeoutNanoseconds, + eventSendStallTimeoutNanoseconds: UInt64 = MobileHostConnection.defaultEventSendStallTimeoutNanoseconds, independentEventWriter: (any MobileHostIndependentEventWriting)? = nil, authorizeRequest: @escaping @Sendable (MobileHostRPCRequest) async -> MobileHostRPCResult?, onAuthorizedRequest: @escaping @Sendable (MobileHostRPCRequest) async -> Void, @@ -1464,6 +2225,8 @@ actor MobileHostConnection { self.writer = MobileHostSerializedTransportWriter(transport: transport) self.independentEventWriter = independentEventWriter self.firstFrameTimeoutNanoseconds = firstFrameTimeoutNanoseconds + self.idleTimeoutNanoseconds = idleTimeoutNanoseconds + self.eventSendStallTimeoutNanoseconds = eventSendStallTimeoutNanoseconds self.authorizeRequest = authorizeRequest self.onAuthorizedRequest = onAuthorizedRequest self.onUsableSession = onUsableSession @@ -1540,6 +2303,8 @@ actor MobileHostConnection { self.exit = exit firstFrameTimeoutTask?.cancel() firstFrameTimeoutTask = nil + idleTimeoutTask?.cancel() + idleTimeoutTask = nil receiveTask?.cancel() receiveTask = nil // Rejects all future admissions and releases every queued payload; the @@ -1573,40 +2338,56 @@ actor MobileHostConnection { private func handleReceive(data: Data) async { if !data.isEmpty { - // Message limits belong to individual frames. A receive chunk may - // contain the tail of a maximum-size frame followed by another. + idleTimeoutTask?.cancel() + idleTimeoutTask = nil + guard receiveBuffer.count + data.count <= Self.maximumReceiveBufferByteCount else { + _ = await sendResponse( + MobileHostRPCEnvelope.error( + id: nil, + code: "frame_decode_error", + message: "Invalid frame" + ) + ) + await close( + reason: "receive buffer exceeded frame limit", + exit: CmxIrohAdmittedConnectionExit( + lifecycle: .controlReadFailed, + failure: .protocolViolation + ) + ) + return + } receiveBuffer.append(data) do { - let batchLimit = responseWorkQuota.maximumConcurrentRequestCount - while !isClosed, !Task.isCancelled { - let frames = try MobileSyncFrameCodec.decodeFrames( - from: &receiveBuffer, - maximumDecodedFrameCount: batchLimit - ) - if !frames.isEmpty { - didDecodeFirstFrame = true - firstFrameTimeoutTask?.cancel() - firstFrameTimeoutTask = nil + let frames = try MobileSyncFrameCodec.decodeFrames( + from: &receiveBuffer, + maximumDecodedFrameCount: responseWorkQuota + .maximumConcurrentRequestCount + ) + if !frames.isEmpty { + didDecodeFirstFrame = true + firstFrameTimeoutTask?.cancel() + firstFrameTimeoutTask = nil + } + for frame in frames { + guard !isClosed else { + return } - for frame in frames { - guard !isClosed else { return } - if !startResponseTask(for: frame) { - // Work pressure fails this request explicitly; it - // does not invalidate the authenticated connection. - let request = try? MobileHostRPCEnvelope.decodeRequest(frame).get() - guard await sendResponse(MobileHostRPCEnvelope.error( - id: request?.id, - code: "server_busy", - message: "Too many requests are pending" - )) else { return } - } + guard startResponseTask(for: frame) else { + await close( + reason: "rpc work capacity exceeded", + exit: CmxIrohAdmittedConnectionExit( + lifecycle: .controlReadFailed, + failure: .protocolViolation + ) + ) + return } - guard frames.count == batchLimit else { break } - await Task.yield() } guard !isClosed else { return } + startIdleTimeout() } catch { _ = await sendResponse( MobileHostRPCEnvelope.error( @@ -1702,6 +2483,9 @@ actor MobileHostConnection { startOrderedRequestWorkerIfNeeded(surfaceKey: surfaceKey) } else { orderedRequestQueuesBySurfaceKey[surfaceKey] = nil + if !hasActiveResponseWork { + startIdleTimeout() + } } } @@ -1723,8 +2507,18 @@ actor MobileHostConnection { ) } + private var hasActiveResponseWork: Bool { + !responseTasks.isEmpty + || !orderedRequestWorkerTasksBySurfaceKey.isEmpty + || !orderedRequestRunningFrameByteCountsBySurfaceKey.isEmpty + || orderedRequestQueuesBySurfaceKey.values.contains { !$0.isEmpty } + } + private func finishResponseTask(_ taskID: UUID) { responseTasks[taskID] = nil + if !hasActiveResponseWork { + startIdleTimeout() + } } private func startFirstFrameTimeout() { @@ -1754,6 +2548,37 @@ actor MobileHostConnection { ) } + private func startIdleTimeout() { + guard idleTimeoutNanoseconds > 0, + didDecodeFirstFrame, + !isClosed, + subscriptions.isEmpty, + !hasActiveResponseWork else { + return + } + idleTimeoutTask?.cancel() + let timeoutNanoseconds = idleTimeoutNanoseconds + idleTimeoutTask = Task { [weak self] in + do { + try await Task.sleep(nanoseconds: timeoutNanoseconds) + await self?.closeIfIdleAfterFrame() + } catch {} + } + } + + private func closeIfIdleAfterFrame() async { + guard didDecodeFirstFrame, subscriptions.isEmpty, !hasActiveResponseWork else { + return + } + await close( + reason: "idle after frame timed out", + exit: CmxIrohAdmittedConnectionExit( + lifecycle: .controlReadFailed, + failure: .timedOut + ) + ) + } + private func respond( to decodedRequest: Result ) async { @@ -2062,6 +2887,8 @@ actor MobileHostConnection { previousTopics: previousTopics, nextTopics: topics ) + idleTimeoutTask?.cancel() + idleTimeoutTask = nil if currentSubscribedTopics().contains(MobileHostEventTopicPolicy.simulatorFrameTopic) { await dispatchPendingSimulatorFrameReplay() } @@ -2087,6 +2914,9 @@ actor MobileHostConnection { }) { await resetIndependentEventWriter() } + if subscriptions.isEmpty { + startIdleTimeout() + } return removed } @@ -2147,12 +2977,26 @@ actor MobileHostConnection { if result.startDrain { Task { await self.drainQueuedEvents() } } + if result.shouldClose { + // The bounded queue fills when the control stream stops draining + // (e.g. the peer's network path died mid-write) while terminal + // events keep arriving. The peer violated nothing; field host + // rings (2026-07-23 WiFi path flap) showed this close mislabeled + // protocolViolation seconds after admission. + await close( + reason: "event queue exceeded bounded capacity", + exit: CmxIrohAdmittedConnectionExit( + lifecycle: .controlWriteFailed, + failure: .sendQueueOverflow + ) + ) + } return result.admitted } /// Synchronous bounded admission from the fan-out path. Never blocks and /// never schedules per-event work; the caller acts on the returned - /// outcome (drain start, refresh shedding, render-grid resync). + /// outcome (drain start, overflow close, render-grid resync). nonisolated func enqueueEventFrame( _ frame: Data, topic: String, @@ -2210,7 +3054,7 @@ actor MobileHostConnection { /// (enforced by the queue's drain claim), pulling from the bounded queue /// and writing to the negotiated lane. Exits when the queue is empty, the /// connection closes, lane negotiation pauses delivery, or a delivery - /// fails (which closes the unusable control session). + /// fails or stalls (which closes the connection). func drainQueuedEvents() async { while true { if isClosed || independentEventNegotiationInProgress { @@ -2289,11 +3133,38 @@ actor MobileHostConnection { return await sendEventControlFrame(event.frame) } - /// Writes one serialized frame until the transport completes or fails. - /// An application deadline cannot cancel writeAll safely: it may already - /// have sent a prefix. Native transport failure still ends the drain. + /// Writes one event frame on the control lane under the bounded stall + /// deadline. On a stall the connection is closed — `transport.close()` + /// resolves the pending write — converting a half-dead subscriber into + /// deterministic teardown instead of a forever-pinned drain. private func sendEventControlFrame(_ frame: Data) async -> Bool { - await sendControlFrame(frame) + guard !isClosed else { return false } + let timeoutNanoseconds = eventSendStallTimeoutNanoseconds + guard timeoutNanoseconds > 0 else { + return await sendControlFrame(frame) + } + eventSendGeneration &+= 1 + let generation = eventSendGeneration + let deadlineTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: timeoutNanoseconds) + guard !Task.isCancelled else { return } + await self?.closeIfEventSendStillInFlight(generation: generation) + } + let delivered = await sendControlFrame(frame) + eventSendGeneration &+= 1 + deadlineTask.cancel() + return delivered && !isClosed + } + + private func closeIfEventSendStillInFlight(generation: UInt64) async { + guard eventSendGeneration == generation, !isClosed else { return } + await close( + reason: "event send stalled past the bounded deadline", + exit: CmxIrohAdmittedConnectionExit( + lifecycle: .controlWriteFailed, + failure: .timedOut + ) + ) } private func downgradeIndependentSubscriptionsToControl() { @@ -2375,6 +3246,11 @@ extension MobileHostConnection { startFirstFrameTimeout() } + func debugStartIdleTimeoutAfterFrameForTesting() { + didDecodeFirstFrame = true + startIdleTimeout() + } + func debugHandleReceiveDataForTesting(_ data: Data) async { await handleReceive(data: data) } diff --git a/Sources/Mobile/Pairing/MobilePairingModel.swift b/Sources/Mobile/Pairing/MobilePairingModel.swift index a73a0aaa106b..1bca0c32785a 100644 --- a/Sources/Mobile/Pairing/MobilePairingModel.swift +++ b/Sources/Mobile/Pairing/MobilePairingModel.swift @@ -5,9 +5,10 @@ import Foundation import Observation /// Drives the in-app iOS pairing window. Gates pairing on the Mac being signed -/// in, then turns on the explicitly requested v2 IROH pairing host. v2 pairing -/// uses the signed-in account and device identity, so it has no QR or address -/// to display. +/// in and on the explicit iOS pairing setting, then mints a Tailscale pairing +/// code. Automatic Iroh discovery needs no QR. The displayed Tailscale code +/// never expires and is never regenerated on a timer; Refresh Code re-mints on +/// demand. /// /// Reads auth state from the app's shared ``CmuxAuthRuntime/AuthCoordinator`` /// (via `AppDelegate`); sign-in routes through the shared ``HostAccountFlow`` @@ -21,61 +22,51 @@ final class MobilePairingModel { case loading /// The Mac is not signed in; pairing can't be authorized yet. case signedOut - /// Signed in; bringing the v2 IROH listener up. + /// iOS pairing is disabled in Mac Settings. + case pairingDisabled + /// Signed in; bringing the listener up and minting the first ticket. case preparing - /// The v2 IROH listener is ready for the signed-in iPhone. + /// A ticket is ready to display. case ready(Ready) - /// A phone has attached to the listener; show a paired/success state. - /// Carries the state to restore when the connection count falls back to - /// the baseline. + /// A phone has attached to the listener; show a paired/success state + /// instead of the QR + spinner. Carries the state to restore when the + /// connection count falls back to the baseline (the QR waiting state, + /// or the Iroh-only waiting state when no Tailscale route exists). indirect case connected(from: State) - /// Compatibility state retained for old callers while the v2 window is - /// active. The v2 path does not publish direct or Tailscale routes. + /// No phone-reachable Tailscale route is available yet. Carries the + /// live Iroh registration state so the window's Iroh tab keeps + /// working while Tailscale QR pairing is unavailable. case needsReachableTransport(reachableViaIroh: Bool) - /// The listener could not be started. + /// The listener could not be started or no ticket could be minted. case failed(String) } - /// Pairing status. Legacy fields remain for source compatibility with old - /// previews; v2 always leaves them empty and sets ``v2Only``. + /// A minted ticket ready for display. struct Ready: Equatable { - /// Legacy attach URL. Empty for v2. + /// The `cmux-ios://attach?...` URL encoded into the QR code. let attachURL: String - /// Legacy Tailscale routes. Empty for v2. + /// Reachable Tailscale `host:port` routes represented by the code. let tailscaleLines: [String] - /// Legacy manual route. `nil` for v2. + /// The best route for manual phone entry, behind the "Copy IP" and + /// "Copy Port" buttons. `nil` when no phone-dialable route exists. let manualEntry: CmxManualPairingEntry? - /// Whether this Mac's IROH endpoint is ready for the signed-in iPhone. + /// Whether this Mac's Iroh endpoint is registered, so signed-in + /// iPhones can discover it automatically without any QR. let reachableViaIroh: Bool - /// v2 pairing uses the authenticated IROH bootstrap and has no QR. - let v2Only: Bool - init( - attachURL: String, - tailscaleLines: [String], - manualEntry: CmxManualPairingEntry?, - reachableViaIroh: Bool, - v2Only: Bool = false - ) { - self.attachURL = attachURL - self.tailscaleLines = tailscaleLines - self.manualEntry = manualEntry - self.reachableViaIroh = reachableViaIroh - self.v2Only = v2Only - } - - /// Whether a legacy Tailscale route resolved. + /// Whether at least one Tailscale route resolved. var reachableViaTailscale: Bool { !tailscaleLines.isEmpty } - /// Recomputes compatibility route diagnostics from host status. The v2 - /// pairing view does not use these legacy fields. + /// The same ticket with its route-derived diagnostics recomputed from + /// a fresh host status. The displayed `attachURL` is intentionally + /// kept: the code on screen is never regenerated behind the user's + /// back; Refresh Code re-mints on demand. func updatingRoutes(_ routes: [CmxAttachRoute]) -> Ready { Ready( attachURL: attachURL, tailscaleLines: MobilePairingModel.tailscaleLines(routes), manualEntry: CmxManualPairingEntry.best(in: routes), - reachableViaIroh: MobilePairingModel.hasIrohRoute(routes), - v2Only: v2Only + reachableViaIroh: MobilePairingModel.hasIrohRoute(routes) ) } } @@ -146,7 +137,7 @@ final class MobilePairingModel { private var coordinator: AuthCoordinator? { AppDelegate.shared?.auth?.coordinator } - /// Selects one exact iOS app for legacy compatibility previews. + /// Selects one exact iOS app and regenerates the pairing code for it. func selectIOSAppTarget(_ target: MobileIOSAppTarget) async { guard availableIOSAppTargets.contains(target), selectedIOSAppTarget != target, @@ -163,8 +154,9 @@ final class MobilePairingModel { await refresh() } - /// Re-evaluates sign-in state and, when signed in, brings the v2 listener - /// up. Safe to call repeatedly when auth state settles. + /// Re-evaluates sign-in and pairing settings, then mints a fresh attach + /// ticket when both gates allow it. Safe to call repeatedly (Refresh button, + /// auth changes, or a settings change). func refresh() async { connectionObservationTask?.cancel() connectionObservationTask = nil @@ -188,8 +180,11 @@ final class MobilePairingModel { return } signedInEmail = coordinator.currentUser?.primaryEmail + guard MobileHostService.isListeningEnabled else { + state = .pairingDisabled + return + } state = .preparing - enablePairingHost() let status = await host.ensureListeningAndReady() guard generation == refreshGeneration else { return } guard status.isRunning else { @@ -202,9 +197,55 @@ final class MobilePairingModel { ) return } - guard generation == refreshGeneration else { return } - state = Self.v2StatusTransition(status, baselineConnectionCount: status.activeConnectionCount) - observeHostStatus() + guard let routePlan = PairingRoutePlan.make(routes: status.routes) else { + state = .needsReachableTransport( + reachableViaIroh: Self.hasIrohRoute(status.routes) + ) + observeHostStatus() + return + } + do { + let payload = try await host.createAttachTicket( + workspaceID: "", + terminalID: nil, + ttl: ticketTTL, + routeDisclosureMode: routePlan.disclosureMode, + pairingURLScheme: selectedIOSAppTarget.pairingURLScheme + ) + guard generation == refreshGeneration else { return } + guard let attachURL = payload["attach_url"] as? String, !attachURL.isEmpty else { + state = .failed( + String( + localized: "mobile.pairing.error.noTicket", + defaultValue: "Could not generate a pairing code. Try again." + ) + ) + return + } + state = .ready( + Ready( + attachURL: attachURL, + tailscaleLines: Self.tailscaleLines(status.routes), + manualEntry: CmxManualPairingEntry.best(in: status.routes), + reachableViaIroh: Self.hasIrohRoute(status.routes) + ) + ) + observeHostStatus() + } catch MobileAttachTicketStoreError.noRoutes, + MobileAttachTicketStoreError.routeUnavailable, + MobileAttachTicketStoreError.invalidAttachURL { + state = .needsReachableTransport( + reachableViaIroh: Self.hasIrohRoute(host.statusSnapshot().routes) + ) + observeHostStatus() + } catch { + state = .failed( + String( + localized: "mobile.pairing.error.noTicket", + defaultValue: "Could not generate a pairing code. Try again." + ) + ) + } } private static func targetDisplayName( @@ -242,6 +283,11 @@ final class MobilePairingModel { } /// Cancels the connection observation. Call when the window closes. + /// + /// There is deliberately no timer to cancel: the displayed code never + /// expires and is never regenerated behind the user's back. If a + /// Tailscale address changes while the window sits open, the Refresh Code + /// button re-mints on demand. func stopObserving() { // Invalidate any pending generation-guarded work (e.g. the observer's // spawned re-mint) so nothing revives the pairing host after close. @@ -250,46 +296,70 @@ final class MobilePairingModel { connectionObservationTask = nil } - /// Watches the mobile host's status while the window is open and flips - /// waiting states to `.connected` as phones attach and detach. + /// Watches the mobile host's status while the window is open: flips + /// waiting states to `.connected` (and back) as phones attach and detach, + /// keeps the route-derived transport diagnostics fresh, and re-mints when + /// a Tailscale route first appears in the no-route state. Cancelled and + /// superseded on each ``refresh()`` via the generation guard, and on + /// ``stopObserving()``. private func observeHostStatus() { connectionObservationTask?.cancel() let generation = refreshGeneration - // Connections already present when this code is displayed establish the - // baseline. Only a new connection above it changes the waiting state. + // Connections already present when this code is displayed (another phone + // is attached, or we are pairing an additional device). Only a NEW + // connection above this baseline means "this freshly minted QR was + // scanned"; without the baseline, opening the window while a phone is + // already connected would falsely jump to "connected" before the new + // ticket is ever used, which also makes pairing an additional device + // impossible (the QR would hide immediately). let baseline = host.statusSnapshot().activeConnectionCount connectionObservationTask = Task { [weak self] in guard let self else { return } for await status in self.host.statusUpdates() { if Task.isCancelled { return } guard generation == self.refreshGeneration else { return } - let next = Self.v2StatusTransition(status, baselineConnectionCount: baseline) + guard MobileHostService.isListeningEnabled else { + self.state = .pairingDisabled + return + } + let next = Self.statusTransition( + from: self.state, + routes: status.routes, + activeConnectionCount: status.activeConnectionCount, + baselineConnectionCount: baseline + ) if next != self.state { self.state = next } + // A Tailscale route appearing in the no-route state is the one + // change a state edit can't express: the QR needs a fresh mint. + if case .needsReachableTransport = self.state, + PairingRoutePlan.make(routes: status.routes) != nil { + Task { @MainActor [weak self] in + // Re-check the generation at execution time: a window + // close (stopObserving) or a newer refresh must not + // let this pending re-mint revive the pairing host. + guard let self, generation == self.refreshGeneration else { return } + await self.refresh() + } + return + } } } } - /// Relay binding may finish from cache before v2 setup. Keep preparing until - /// the runtime confirms registration for the current account and team. - static func v2StatusTransition( - _ status: MobileHostServiceStatus, - baselineConnectionCount: Int - ) -> State { - guard status.isRunning, status.isPairingReady else { return .preparing } - let ready = State.ready(Ready( - attachURL: "", tailscaleLines: [], manualEntry: nil, - reachableViaIroh: true, v2Only: true - )) - return status.activeConnectionCount > baselineConnectionCount ? .connected(from: ready) : ready - } - /// Computes the next render state from a host status event. Pure, so the /// transitions are unit tested without a live host. /// - /// A connection above the captured baseline flips the waiting state to - /// `.connected`; dropping back restores the prior waiting state. + /// A connection *above* the `baselineConnectionCount` captured when the + /// waiting state was entered (a phone that attached afterwards) flips + /// `.ready` and `.needsReachableTransport` to `.connected`; dropping back + /// to the baseline restores the prior waiting state. Waiting states also + /// absorb route changes so the transport diagnostics stay live: the Iroh + /// flag, the Tailscale lines, and the manual entry follow `routes`, while + /// the displayed `attachURL` is deliberately never regenerated here (the + /// code on screen never changes behind the user's back; Refresh Code + /// re-mints on demand). static func statusTransition( from current: State, routes: [CmxAttachRoute], @@ -324,12 +394,6 @@ final class MobilePairingModel { } } - private func enablePairingHost() { - // Never force the listener on under a managed remote-control disable. - guard MobileRemoteControlPolicy.isEnabled else { return } - UserDefaults.standard.set(true, forKey: MobileHostService.listeningEnabledDefaultsKey) - } - /// Whether this Mac's Iroh endpoint is registered in `routes`. private nonisolated static func hasIrohRoute(_ routes: [CmxAttachRoute]) -> Bool { routes.contains { $0.kind == .iroh } diff --git a/Sources/Mobile/Pairing/MobilePairingView.swift b/Sources/Mobile/Pairing/MobilePairingView.swift index 9c10204d5233..187b9f42b98b 100644 --- a/Sources/Mobile/Pairing/MobilePairingView.swift +++ b/Sources/Mobile/Pairing/MobilePairingView.swift @@ -2,6 +2,7 @@ import CmuxFoundation import AppKit import CMUXMobileCore import CmuxAuthRuntime +import Foundation import SwiftUI /// The macOS window for pairing an iPhone with this Mac. @@ -61,6 +62,14 @@ struct MobilePairingView: View { } .task { await model.refresh() } .onDisappear { model.stopObserving() } + .onReceive( + NotificationCenter.default.publisher( + for: UserDefaults.didChangeNotification, + object: UserDefaults.standard + ) + ) { _ in + Task { await model.refresh() } + } .onChange(of: coordinator?.isAuthenticated ?? false) { _, _ in Task { await model.refresh() } } @@ -129,6 +138,8 @@ struct MobilePairingView: View { loadingContent case .signedOut: AccountSignInView(model: signInModel, automaticallyStartsSignIn: false) + case .pairingDisabled: + pairingDisabledContent case .preparing: centered { ProgressView().controlSize(.small) @@ -159,6 +170,36 @@ struct MobilePairingView: View { } } + private var pairingDisabledContent: some View { + VStack(spacing: 12) { + Image(systemName: "iphone.slash") + .cmuxFont(size: 28) + .foregroundStyle(.secondary) + Text(String( + localized: "mobile.pairing.disabled.title", + defaultValue: "Enable iOS pairing" + )) + .cmuxFont(.headline) + Text(String( + localized: "mobile.pairing.disabled.body", + defaultValue: "iOS pairing is off on this Mac. Open Settings and enable iOS pairing to discover this Mac from cmux on your iPhone." + )) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Button(String( + localized: "mobile.pairing.disabled.openSettings", + defaultValue: "Open Settings" + )) { + AppDelegate.shared?.openPreferencesWindow( + debugSource: "mobilePairingDisabled" + ) + } + .buttonStyle(.borderedProminent) + } + .frame(maxWidth: .infinity, minHeight: 200) + } + private func failure(message: String) -> some View { VStack(spacing: 12) { Image(systemName: "exclamationmark.triangle") diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index dbb708387971..b5862bef06b3 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2120,6 +2120,9 @@ B8B056D80000000000000001 /* MobileHostIdentityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B056D80000000000000002 /* MobileHostIdentityTests.swift */; }; C1A071000000000000000001 /* MobileHostIrohAdmissionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A071000000000000000011 /* MobileHostIrohAdmissionTests.swift */; }; A17070900000000000000002 /* MobileHostIrohApplicationLaneRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A17070900000000000000001 /* MobileHostIrohApplicationLaneRouter.swift */; }; + C1B1810000000000000005 /* MobileHostIrohRuntime+Activation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B1810000000000000025 /* MobileHostIrohRuntime+Activation.swift */; }; + C1B1810000000000000006 /* MobileHostIrohRuntime+Lifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B1810000000000000026 /* MobileHostIrohRuntime+Lifecycle.swift */; }; + C1B1810000000000000007 /* MobileHostIrohRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B1810000000000000027 /* MobileHostIrohRuntime.swift */; }; C1A070000000000000000004 /* MobileHostIrohServerEventWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A070000000000000000014 /* MobileHostIrohServerEventWriter.swift */; }; C1B1810000000000000003 /* MobileHostIrxLegacyDialectServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B1810000000000000013 /* MobileHostIrxLegacyDialectServer.swift */; }; C1B1810000000000000004 /* MobileHostIrxRuntime+SettingsControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B1810000000000000014 /* MobileHostIrxRuntime+SettingsControl.swift */; }; @@ -5886,6 +5889,9 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostIdentityTests.swift; sourceTree = ""; }; C1A071000000000000000011 /* MobileHostIrohAdmissionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostIrohAdmissionTests.swift; sourceTree = ""; }; A17070900000000000000001 /* MobileHostIrohApplicationLaneRouter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostIrohApplicationLaneRouter.swift; sourceTree = ""; }; + C1B1810000000000000025 /* MobileHostIrohRuntime+Activation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "MobileHostIrohRuntime+Activation.swift"; sourceTree = ""; }; + C1B1810000000000000026 /* MobileHostIrohRuntime+Lifecycle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "MobileHostIrohRuntime+Lifecycle.swift"; sourceTree = ""; }; + C1B1810000000000000027 /* MobileHostIrohRuntime.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostIrohRuntime.swift; sourceTree = ""; }; C1A070000000000000000014 /* MobileHostIrohServerEventWriter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostIrohServerEventWriter.swift; sourceTree = ""; }; C1B1810000000000000013 /* MobileHostIrxLegacyDialectServer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostIrxLegacyDialectServer.swift; sourceTree = ""; }; C1B1810000000000000014 /* MobileHostIrxRuntime+SettingsControl.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "MobileHostIrxRuntime+SettingsControl.swift"; sourceTree = ""; }; @@ -7906,6 +7912,9 @@ C1A070000000000000000011 /* MobileHostAuthorizationSupport.swift */, A17070900000000000000001 /* MobileHostIrohApplicationLaneRouter.swift */, C1B1810000000000000011 /* MobileHostIrxRuntime.swift */, + C1B1810000000000000025 /* MobileHostIrohRuntime+Activation.swift */, + C1B1810000000000000026 /* MobileHostIrohRuntime+Lifecycle.swift */, + C1B1810000000000000027 /* MobileHostIrohRuntime.swift */, C1A070000000000000000014 /* MobileHostIrohServerEventWriter.swift */, C1B1810000000000000013 /* MobileHostIrxLegacyDialectServer.swift */, C1B1810000000000000018 /* MobileHostV2Installation.swift */, @@ -13318,6 +13327,9 @@ C1B1810000000000000009 /* MobileHostDiagnostics.swift in Sources */, B8B056D90000000000000001 /* MobileHostIdentity.swift in Sources */, A17070900000000000000002 /* MobileHostIrohApplicationLaneRouter.swift in Sources */, + C1B1810000000000000005 /* MobileHostIrohRuntime+Activation.swift in Sources */, + C1B1810000000000000006 /* MobileHostIrohRuntime+Lifecycle.swift in Sources */, + C1B1810000000000000007 /* MobileHostIrohRuntime.swift in Sources */, C1A070000000000000000004 /* MobileHostIrohServerEventWriter.swift in Sources */, C1B1810000000000000003 /* MobileHostIrxLegacyDialectServer.swift in Sources */, C1B1810000000000000004 /* MobileHostIrxRuntime+SettingsControl.swift in Sources */, diff --git a/cmuxTests/MobileHostIrohAdmissionTests.swift b/cmuxTests/MobileHostIrohAdmissionTests.swift index 81ead03f0eec..796eb734b6dc 100644 --- a/cmuxTests/MobileHostIrohAdmissionTests.swift +++ b/cmuxTests/MobileHostIrohAdmissionTests.swift @@ -132,6 +132,49 @@ extension MobileHostAuthorizationTests { #expect(!attachURL.contains("private@example.com")) } + @Test func testBindingPublicationDoesNotWaitForPersistence() async { + let queue = MobileHostIrohPersistenceQueue() + let gate = MobileHostIrohPersistenceGate() + var published = false + + queue.publishAndEnqueue( + publish: { published = true }, + persist: { await gate.wait() } + ) + await gate.waitUntilStarted() + + #expect(published) + await queue.cancel() + await gate.resume() + } + + #if DEBUG + @Test func testMacIrohVerificationModeIgnoresTheRetiredReleaseRelayOnlyPreference() throws { + let suiteName = "MobileHostIrohAdmissionTests.transport-mode.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + #expect(MobileHostIrohRuntime.debugTransportVerificationMode(defaults: defaults) == .automatic) + defaults.set( + CmxIrohPathPreference.relayOnly.rawValue, + forKey: CmxIrohPathPreference.defaultsKey + ) + #expect(MobileHostIrohRuntime.debugTransportVerificationMode(defaults: defaults) == .automatic) + defaults.set( + CmxIrohTransportVerificationMode.directOnly.rawValue, + forKey: CmxIrohTransportVerificationMode.debugDefaultsKey + ) + #expect(MobileHostIrohRuntime.debugTransportVerificationMode(defaults: defaults) == .directOnly) + defaults.removeObject(forKey: CmxIrohTransportVerificationMode.debugDefaultsKey) + defaults.set( + CmxIrohPathPreference.automatic.rawValue, + forKey: CmxIrohPathPreference.defaultsKey + ) + defaults.set(true, forKey: MobileHostIrohRuntime.debugRelayOnlyDefaultsKey) + #expect(MobileHostIrohRuntime.debugTransportVerificationMode(defaults: defaults) == .relayOnly) + } + #endif + @Test func testIrohAdmissionReplacesPerRequestStackAuthorization() async throws { let recorder = MobileHostAuthorizationInvocationRecorder() let request = MobileHostRPCRequest( @@ -182,12 +225,13 @@ struct IrohTailscaleVersionSkewMacGateTests { let request = Data( #"{"id":"iroh-rpc-inventory","method":"mobile.rpc.methods","params":{}}"#.utf8 ) - let transport = MobileHostFramedTestTransport() + let transport = LegacyIOSCompatibilityByteTransport() let authorization = try irohAdmissionContext() let session = MobileHostConnection( id: UUID(), transport: transport, firstFrameTimeoutNanoseconds: 0, + idleTimeoutNanoseconds: 0, authorizeRequest: { request in await MobileHostService.connectionAuthorizationError( for: request, @@ -231,6 +275,80 @@ struct IrohTailscaleVersionSkewMacGateTests { } #endif + @Test func testReleasedIOSWireFrameRemainsAcceptedByLegacyTCPAuthorization() async throws { + let legacyPayload = Data( + #""" + { + "id": "legacy-workspace-list", + "method": "workspace.list", + "params": {}, + "auth": { "stack_access_token": "legacy-stack-token" } + } + """#.utf8 + ) + let transport = LegacyIOSCompatibilityByteTransport() + let stackAuthorization = LegacyStackAuthorizationRecorder() + let session = MobileHostConnection( + id: UUID(), + transport: transport, + firstFrameTimeoutNanoseconds: 0, + idleTimeoutNanoseconds: 0, + authorizeRequest: { request in + await MobileHostService.connectionAuthorizationError( + for: request, + authorization: .legacyPrivateNetworkListener, + stackAuthorization: { decoded in + await stackAuthorization.record(decoded) + guard decoded.auth?.stackAccessToken == "legacy-stack-token" else { + return .failure(MobileHostRPCError( + code: "unauthorized", + message: "Legacy Stack bearer was not preserved" + )) + } + return nil + } + ) + }, + onAuthorizedRequest: { _ in }, + handleRequest: { request in + .ok([ + "method": request.method, + "authorization": "stack_bearer", + ]) + }, + onClose: { _ in } + ) + let runTask = Task { await session.run() } + await transport.enqueue(try MobileSyncFrameCodec.encodeFrame(legacyPayload)) + + var responseBuffer = await transport.waitForSentBuffer() + let responsePayloads = try MobileSyncFrameCodec.decodeFrames(from: &responseBuffer) + let responsePayload = try #require(responsePayloads.first) + let response = try #require( + JSONSerialization.jsonObject( + with: responsePayload + ) as? [String: Any] + ) + let result = try #require(response["result"] as? [String: Any]) + + #expect(response["id"] as? String == "legacy-workspace-list") + #expect(response["ok"] as? Bool == true) + #expect(result["method"] as? String == "workspace.list") + #expect(result["authorization"] as? String == "stack_bearer") + #expect(await stackAuthorization.invocationCount() == 1) + #expect(await stackAuthorization.lastToken() == "legacy-stack-token") + + await transport.finishReceiving() + await runTask.value + } + + @Test func testLegacyCompatibilityPolicyCannotBecomeIrohAdmission() { + #expect( + MobileHostConnectionAuthorizationContext.legacyPrivateNetworkListener + == .stackBearer + ) + } + @Test func testLegacyCompatibilityRouteIsNumericTailscaleAndNeverLoopback() throws { let snapshot = MobileRouteResolver().routes( port: 58_465, @@ -252,6 +370,46 @@ struct IrohTailscaleVersionSkewMacGateTests { #expect(host != "127.0.0.1") } + @Test func testStableExplicitSettingStartsIrohAndLegacyCompatibilityListener() throws { + let suiteName = "IrohTailscaleVersionSkewMacGateTests.Current.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + defaults.set(true, forKey: MobileHostService.listeningEnabledDefaultsKey) + + let enabled = MobileHostService.isListeningEnabled( + defaults: defaults, + buildFlavor: .stable + ) + let plan = MobileHostService.startupPlan( + remoteControlDisabledByPolicy: false, + pairingEnabled: enabled, + legacyListenerRunning: false + ) + + #expect(plan.activatesIroh) + #expect(plan.startsLegacyListener) + } + + @Test func testStableHistoricalSettingStartsIrohAndLegacyCompatibilityListener() throws { + let suiteName = "IrohTailscaleVersionSkewMacGateTests.Historical.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + defaults.set(true, forKey: "cmuxMobilePairingHostEnabled") + + let enabled = MobileHostService.isListeningEnabled( + defaults: defaults, + buildFlavor: .stable + ) + let plan = MobileHostService.startupPlan( + remoteControlDisabledByPolicy: false, + pairingEnabled: enabled, + legacyListenerRunning: false + ) + + #expect(plan.activatesIroh) + #expect(plan.startsLegacyListener) + } + private func irohAdmissionContext() throws -> MobileHostConnectionAuthorizationContext { let endpointID = try CmxIrohPeerIdentity( endpointID: String(repeating: "a", count: 64) @@ -272,8 +430,6 @@ struct IrohTailscaleVersionSkewMacGateTests { extension MobileHostAuthorizationTests { @Test func testIrohAdmittedStatusIncludesIdentityWhileTCPPublicStatusDoesNot() async throws { - MobileHostPublicStatusCache.updateV2DeviceID("v2-mac-fixture") - defer { MobileHostPublicStatusCache.removeAll() } let request = MobileHostRPCRequest( id: "host-status", method: "mobile.host.status", @@ -566,13 +722,7 @@ extension MobileHostAuthorizationTests { @Test func testIrohApplicationLaneQuotasReserveArtifactCapacity() { #expect(MobileHostIrohApplicationLaneRouter.maximumConcurrentTerminalLaneCount == 4) #expect(MobileHostIrohApplicationLaneRouter.maximumConcurrentArtifactLaneCount == 1) - #expect(MobileHostIrohApplicationLaneRouter.maximumConcurrentSimulatorStreamLaneCount == 2) - #expect( - MobileHostIrohApplicationLaneRouter.maximumConcurrentLaneCount - == MobileHostIrohApplicationLaneRouter.maximumConcurrentTerminalLaneCount - + MobileHostIrohApplicationLaneRouter.maximumConcurrentArtifactLaneCount - + MobileHostIrohApplicationLaneRouter.maximumConcurrentSimulatorStreamLaneCount - ) + #expect(MobileHostIrohApplicationLaneRouter.maximumConcurrentLaneCount == 5) var quota = MobileHostIrohApplicationLaneQuota() let terminalIDs = (0..<5).map { _ in UUID() } @@ -587,18 +737,8 @@ extension MobileHostAuthorizationTests { #expect(didReserveArtifact) let didReserveSecondArtifact = quota.reserve(UUID(), laneClass: .artifact) #expect(!didReserveSecondArtifact) - let simulatorStreamIDs = (0..<3).map { _ in UUID() } - for id in simulatorStreamIDs.prefix(2) { - let didReserve = quota.reserve(id, laneClass: .simulatorStream) - #expect(didReserve) - } - let didReserveThirdSimulatorStream = quota.reserve( - simulatorStreamIDs[2], laneClass: .simulatorStream - ) - #expect(!didReserveThirdSimulatorStream) #expect(quota.terminalCount == 4) #expect(quota.artifactCount == 1) - #expect(quota.simulatorStreamCount == 2) quota.release(terminalIDs[0]) let didReuseTerminalCredit = quota.reserve(terminalIDs[4], laneClass: .terminal) @@ -606,11 +746,6 @@ extension MobileHostAuthorizationTests { quota.release(artifactID) let didReuseArtifactCredit = quota.reserve(UUID(), laneClass: .artifact) #expect(didReuseArtifactCredit) - quota.release(simulatorStreamIDs[0]) - let didReuseSimulatorStreamCredit = quota.reserve( - simulatorStreamIDs[2], laneClass: .simulatorStream - ) - #expect(didReuseSimulatorStreamCredit) } private func irohPeer( @@ -758,8 +893,34 @@ private actor MutatingMobileHostIrohArtifactSendStream: CmxIrohSendStream { func resetCodes() -> [UInt64] { observedResetCodes } } -/// In-memory framed transport for testing the application RPC stream. -private actor MobileHostFramedTestTransport: CmxByteTransport { +private actor MobileHostIrohPersistenceGate { + private var started = false + private var startWaiters: [CheckedContinuation] = [] + private var continuation: CheckedContinuation? + + func wait() async { + started = true + let waiters = startWaiters + startWaiters.removeAll(keepingCapacity: false) + for waiter in waiters { waiter.resume() } + await withCheckedContinuation { continuation = $0 } + } + + func waitUntilStarted() async { + guard !started else { return } + await withCheckedContinuation { startWaiters.append($0) } + } + + func resume() { + continuation?.resume() + continuation = nil + } +} + +/// In-memory framed transport for the released-iOS compatibility contract. +/// It deliberately has no host, port, loopback socket, or Iroh endpoint, so the +/// test can only pass through the explicitly selected legacy authorization lane. +private actor LegacyIOSCompatibilityByteTransport: CmxByteTransport { private var receiveQueue: [Data?] = [] private var receiveWaiter: CheckedContinuation? private var sentBuffer: Data? @@ -818,3 +979,14 @@ private actor MobileHostFramedTestTransport: CmxByteTransport { return await withCheckedContinuation { sentWaiters.append($0) } } } + +private actor LegacyStackAuthorizationRecorder { + private var tokens: [String?] = [] + + func record(_ request: MobileHostRPCRequest) { + tokens.append(request.auth?.stackAccessToken) + } + + func invocationCount() -> Int { tokens.count } + func lastToken() -> String? { tokens.last ?? nil } +} diff --git a/cmuxTests/MobileHostNetworkPathRefreshTests.swift b/cmuxTests/MobileHostNetworkPathRefreshTests.swift index 2a6481684891..f8df888a4a3f 100644 --- a/cmuxTests/MobileHostNetworkPathRefreshTests.swift +++ b/cmuxTests/MobileHostNetworkPathRefreshTests.swift @@ -1,4 +1,5 @@ import CMUXMobileCore +import CmuxIrohTransport import Foundation import Testing @@ -185,3 +186,268 @@ import Testing #expect(!tailscaleHosts(in: afterStaleStore).contains("stale-old-net.tail1234.ts.net")) } } + +@Suite(.serialized) +@MainActor +struct MobileHostIrohStartupRetryTests { + @Test + func bindingRemainsUnavailableUntilMatchingHostRuntimeIsActive() throws { + let defaults = UserDefaults.standard + let previousPairingValue = defaults.object( + forKey: MobileHostService.listeningEnabledDefaultsKey + ) + defaults.set(true, forKey: MobileHostService.listeningEnabledDefaultsKey) + let runtime = MobileHostIrohRuntime.shared + let originalRevision = runtime.lifecycleRevision + let revision: UInt64 = 4_200 + let binding = try CmxIrohBrokerBindingMetadata( + bindingID: "123e4567-e89b-42d3-a456-426614174010", + deviceID: "123e4567-e89b-42d3-a456-426614174011", + appInstanceID: "123e4567-e89b-42d3-a456-426614174012", + tag: "route-ready", + platform: .mac, + endpointID: CmxIrohPeerIdentity( + endpointID: String(repeating: "a", count: 64) + ), + identityGeneration: 1 + ) + defer { + runtime.lifecycleRevision = originalRevision + runtime.clearIrohRoutePublication() + MobileHostPublicStatusCache.removeAll() + if let previousPairingValue { + defaults.set( + previousPairingValue, + forKey: MobileHostService.listeningEnabledDefaultsKey + ) + } else { + defaults.removeObject(forKey: MobileHostService.listeningEnabledDefaultsKey) + } + } + MobileHostPublicStatusCache.removeAll() + runtime.lifecycleRevision = revision + + runtime.beginIrohRouteActivation(revision: revision) + runtime.stageIrohRoute(binding, pathHints: [], revision: revision) + + #expect(!MobileHostPublicStatusCache.hasIrohRoute()) + #expect(runtime.routePublicationPhase == .starting(revision: revision)) + #expect(!runtime.publishIrohRouteIfActive(revision: revision - 1)) + #expect(!MobileHostPublicStatusCache.hasIrohRoute()) + #expect(runtime.publishIrohRouteIfActive(revision: revision)) + #expect(MobileHostPublicStatusCache.hasIrohRoute()) + + runtime.lifecycleRevision = revision + 1 + runtime.beginIrohRouteActivation(revision: revision + 1) + + #expect(!MobileHostPublicStatusCache.hasIrohRoute()) + #expect(runtime.routePublicationPhase == .starting(revision: revision + 1)) + } + + @Test + func disabledPairingRejectsLateIrohRoutePublication() throws { + let defaults = UserDefaults.standard + let previousPairingValue = defaults.object( + forKey: MobileHostService.listeningEnabledDefaultsKey + ) + defer { + if let previousPairingValue { + defaults.set( + previousPairingValue, + forKey: MobileHostService.listeningEnabledDefaultsKey + ) + } else { + defaults.removeObject(forKey: MobileHostService.listeningEnabledDefaultsKey) + } + MobileHostIrohRuntime.shared.clearIrohRoutePublication() + MobileHostPublicStatusCache.removeAll() + } + defaults.set(false, forKey: MobileHostService.listeningEnabledDefaultsKey) + + let runtime = MobileHostIrohRuntime.shared + let revision = runtime.lifecycleRevision &+ 1 + runtime.lifecycleRevision = revision + runtime.beginIrohRouteActivation(revision: revision) + + let binding = try CmxIrohBrokerBindingMetadata( + bindingID: "123e4567-e89b-42d3-a456-426614174020", + deviceID: "123e4567-e89b-42d3-a456-426614174021", + appInstanceID: "123e4567-e89b-42d3-a456-426614174022", + tag: "route-disabled", + platform: .mac, + endpointID: CmxIrohPeerIdentity( + endpointID: String(repeating: "b", count: 64) + ), + identityGeneration: 1 + ) + runtime.stageIrohRoute(binding, pathHints: [], revision: revision) + + #expect(!runtime.publishIrohRouteIfActive(revision: revision)) + #expect(!MobileHostPublicStatusCache.hasIrohRoute()) + } + + @Test + func sameAccountAuthObservationDoesNotSupersedeActivationInFlight() { + #expect(!MobileHostIrohRuntime.shouldReconcileAuthObservation( + accountID: "same-account", + previousAccountID: "same-account", + activeAccountID: nil, + hasRuntime: false, + transitionInFlight: true, + preparedSignOutNeedsPersistence: false + )) + } + + @Test + func sameAccountAuthObservationDoesNotRestartActiveRuntime() { + #expect(!MobileHostIrohRuntime.shouldReconcileAuthObservation( + accountID: "same-account", + previousAccountID: "same-account", + activeAccountID: "same-account", + hasRuntime: true, + transitionInFlight: false, + preparedSignOutNeedsPersistence: false + )) + } + + @Test + func sameAccountAuthObservationRetriesAfterFailedActivation() { + #expect(MobileHostIrohRuntime.shouldReconcileAuthObservation( + accountID: "same-account", + previousAccountID: "same-account", + activeAccountID: nil, + hasRuntime: false, + transitionInFlight: false, + preparedSignOutNeedsPersistence: false + )) + } + + @Test + func accountChangeStillSupersedesActivationInFlight() { + #expect(MobileHostIrohRuntime.shouldReconcileAuthObservation( + accountID: "next-account", + previousAccountID: "previous-account", + activeAccountID: nil, + hasRuntime: false, + transitionInFlight: true, + preparedSignOutNeedsPersistence: false + )) + } + + @Test + func networkPathRetryDoesNotSupersedeActivationInFlight() async { + let runtime = MobileHostIrohRuntime.shared + let originalDesiredActive = runtime.desiredActive + let originalObservedAccountID = runtime.observedAccountID + let originalPreparedSignOut = runtime.preparedSignOut + let originalSignOutIntentActive = runtime.signOutIntentActive + let originalRuntime = runtime.runtime + let originalTransitionTask = runtime.transitionTask + let originalRevision = runtime.lifecycleRevision + let gate = MobileHostIrohStartupRetryGate() + let activation = Task { await gate.suspend() } + runtime.desiredActive = true + runtime.observedAccountID = "network-path-race-account" + runtime.preparedSignOut = nil + runtime.signOutIntentActive = false + runtime.runtime = nil + runtime.transitionTask = activation + + runtime.retryIfNeeded() + + #expect(runtime.lifecycleRevision == originalRevision) + + let scheduled = runtime.transitionTask + scheduled?.cancel() + await gate.resume() + await scheduled?.value + runtime.transitionTask = originalTransitionTask + runtime.runtime = originalRuntime + runtime.desiredActive = originalDesiredActive + runtime.observedAccountID = originalObservedAccountID + runtime.preparedSignOut = originalPreparedSignOut + runtime.signOutIntentActive = originalSignOutIntentActive + runtime.lifecycleRevision = originalRevision + } + + @Test + func staleDeactivationCannotClearReplacementRuntimeState() async { + let runtime = MobileHostIrohRuntime.shared + let originalDesiredActive = runtime.desiredActive + let originalSignOutIntentActive = runtime.signOutIntentActive + let originalRevision = runtime.lifecycleRevision + let probe = MobileHostIrohDeactivationProbe() + runtime.desiredActive = true + runtime.signOutIntentActive = false + runtime.lifecycleRevision = 1_000 + + await runtime.handleActiveRuntimeDeactivation( + revision: 999, + stopLANPublication: { + probe.didStopLAN = true + }, + clearHostRuntime: { + probe.didClearHost = true + } + ) + + #expect(!probe.didStopLAN) + #expect(!probe.didClearHost) + runtime.desiredActive = originalDesiredActive + runtime.signOutIntentActive = originalSignOutIntentActive + runtime.lifecycleRevision = originalRevision + } + + @Test + func deactivationRechecksOwnershipAfterSuspendingCleanup() async { + let runtime = MobileHostIrohRuntime.shared + let originalDesiredActive = runtime.desiredActive + let originalSignOutIntentActive = runtime.signOutIntentActive + let originalRevision = runtime.lifecycleRevision + let probe = MobileHostIrohDeactivationProbe() + runtime.desiredActive = true + runtime.signOutIntentActive = false + runtime.lifecycleRevision = 2_000 + + await runtime.handleActiveRuntimeDeactivation( + revision: 2_000, + stopLANPublication: { + probe.didStopLAN = true + // A replacement activation took ownership while the old + // callback was suspended in LAN cleanup. + runtime.lifecycleRevision = 2_001 + }, + clearHostRuntime: { + probe.didClearHost = true + } + ) + + #expect(probe.didStopLAN) + #expect(!probe.didClearHost) + runtime.desiredActive = originalDesiredActive + runtime.signOutIntentActive = originalSignOutIntentActive + runtime.lifecycleRevision = originalRevision + } +} + +@MainActor +private final class MobileHostIrohDeactivationProbe { + var didStopLAN = false + var didClearHost = false +} + +private actor MobileHostIrohStartupRetryGate { + private var isOpen = false + private var waiter: CheckedContinuation? + + func suspend() async { + guard !isOpen else { return } + await withCheckedContinuation { waiter = $0 } + } + + func resume() { + isOpen = true + waiter?.resume() + waiter = nil + } +} diff --git a/cmuxTests/MobileHostServiceSettingsTests.swift b/cmuxTests/MobileHostServiceSettingsTests.swift index 8e61883f4bbf..7b49a9722123 100644 --- a/cmuxTests/MobileHostServiceSettingsTests.swift +++ b/cmuxTests/MobileHostServiceSettingsTests.swift @@ -1,6 +1,5 @@ import CMUXMobileCore import CmuxIrohTransport -import CmuxAuthRuntime import CmuxSettings import Foundation import Testing @@ -12,19 +11,178 @@ import Testing #endif struct MobileHostServiceSettingsTests { - @Test(arguments: [BuildFlavor.dev, .nightly, .stable]) - func pairingRequiresExplicitCurrentOptIn(buildFlavor: BuildFlavor) throws { - let suiteName = "MobileHostServiceSettingsTests.v2.\(UUID().uuidString)" + @Test func mobileHostPairingDefaultsOffUntilIOSPairingIsOverridden() throws { + let suiteName = "MobileHostServiceSettingsTests.\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } - #expect(!MobileHostService.isListeningEnabled(defaults: defaults, buildFlavor: buildFlavor)) - defaults.set(true, forKey: "cmuxMobilePairingHostEnabled") - #expect(!MobileHostService.isListeningEnabled(defaults: defaults, buildFlavor: buildFlavor)) + + #expect(!MobileHostService.isListeningEnabled(defaults: defaults, buildFlavor: .dev)) + defaults.set(true, forKey: MobileHostService.listeningEnabledDefaultsKey) - #expect(MobileHostService.isListeningEnabled(defaults: defaults, buildFlavor: buildFlavor)) + #expect(MobileHostService.isListeningEnabled(defaults: defaults, buildFlavor: .dev)) + + defaults.set(false, forKey: MobileHostService.listeningEnabledDefaultsKey) + #expect(!MobileHostService.isListeningEnabled(defaults: defaults, buildFlavor: .dev)) + + defaults.set("true", forKey: MobileHostService.listeningEnabledDefaultsKey) + #expect(!MobileHostService.isListeningEnabled(defaults: defaults, buildFlavor: .dev)) + } + + @Test func pairingOptInControlsBothIrohAndTheLegacyListener() { + let automatic = MobileHostService.startupPlan( + remoteControlDisabledByPolicy: false, + pairingEnabled: false, + legacyListenerRunning: false + ) + #expect(!automatic.activatesIroh) + #expect(!automatic.startsLegacyListener) + + let tailscaleCompatible = MobileHostService.startupPlan( + remoteControlDisabledByPolicy: false, + pairingEnabled: true, + legacyListenerRunning: false + ) + #expect(tailscaleCompatible.activatesIroh) + #expect(tailscaleCompatible.startsLegacyListener) + + let alreadyListening = MobileHostService.startupPlan( + remoteControlDisabledByPolicy: false, + pairingEnabled: true, + legacyListenerRunning: true + ) + #expect(alreadyListening.activatesIroh) + #expect(!alreadyListening.startsLegacyListener) + + let staleListener = MobileHostService.startupPlan( + remoteControlDisabledByPolicy: false, + pairingEnabled: false, + legacyListenerRunning: true + ) + #expect(!staleListener.activatesIroh) + #expect(!staleListener.startsLegacyListener) + } + + @Test func managedRemoteControlPolicyOverridesEveryTransport() { + // Even a user who explicitly enabled the legacy listener gets no + // transport while the MDM policy is enforced. + let disabled = MobileHostService.startupPlan( + remoteControlDisabledByPolicy: true, + pairingEnabled: true, + legacyListenerRunning: false + ) + #expect(!disabled.activatesIroh) + #expect(!disabled.startsLegacyListener) + } + + @Test func pairingOffPreventsCompositionRootRuntimeSetup() { + #expect(!MobileHostService.shouldConfigurePairingRuntime( + pairingEnabled: false, + remoteControlEnabled: true, + runtimeAlreadyConfigured: false + )) + #expect(MobileHostService.shouldConfigurePairingRuntime( + pairingEnabled: true, + remoteControlEnabled: true, + runtimeAlreadyConfigured: false + )) + #expect(!MobileHostService.shouldConfigurePairingRuntime( + pairingEnabled: true, + remoteControlEnabled: false, + runtimeAlreadyConfigured: false + )) + #expect(!MobileHostService.shouldConfigurePairingRuntime( + pairingEnabled: true, + remoteControlEnabled: true, + runtimeAlreadyConfigured: true + )) + } + + @Test func mobileHostListenerPreservesHistoricalExplicitOptIn() throws { + let suiteName = "MobileHostServiceSettingsTests.Legacy.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(true, forKey: "cmuxMobilePairingHostEnabled") + #expect(MobileHostService.isListeningEnabled(defaults: defaults)) + defaults.set(false, forKey: MobileHostService.listeningEnabledDefaultsKey) - #expect(!MobileHostService.isListeningEnabled(defaults: defaults, buildFlavor: buildFlavor)) - #expect(SettingCatalog().mobile.iOSPairingHost.defaultValue == false) + #expect(!MobileHostService.isListeningEnabled(defaults: defaults)) + } + + @Test func nightlyDefaultsToPairingOffWhenNoSettingWasEverWritten() throws { + let suiteName = "MobileHostServiceSettingsTests.NightlyCompatibility.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + #expect(!MobileHostService.isListeningEnabled(defaults: defaults, buildFlavor: .nightly)) + } + + @Test func explicitDisableWinsOverNightlyCompatibility() throws { + let suiteName = "MobileHostServiceSettingsTests.NightlyDisabled.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(false, forKey: MobileHostService.listeningEnabledDefaultsKey) + + #expect(!MobileHostService.isListeningEnabled(defaults: defaults, buildFlavor: .nightly)) + } + + @Test func legacyExplicitDisableWinsOverNightlyCompatibility() throws { + let suiteName = "MobileHostServiceSettingsTests.LegacyNightlyDisabled.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(false, forKey: "cmuxMobilePairingHostEnabled") + + #expect(!MobileHostService.isListeningEnabled(defaults: defaults, buildFlavor: .nightly)) + } + + @Test func stableWithoutExplicitOptInKeepsLegacyListenerOff() throws { + let suiteName = "MobileHostServiceSettingsTests.StableDefault.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + #expect(!MobileHostService.isListeningEnabled(defaults: defaults, buildFlavor: .stable)) + } + + @Test func stablePreservesExplicitTailscaleCompatibilityRequest() throws { + let suiteName = "MobileHostServiceSettingsTests.StableOptIn.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + defaults.set(true, forKey: MobileHostService.listeningEnabledDefaultsKey) + + let enabled = MobileHostService.isListeningEnabled( + defaults: defaults, + buildFlavor: .stable + ) + let plan = MobileHostService.startupPlan( + remoteControlDisabledByPolicy: false, + pairingEnabled: enabled, + legacyListenerRunning: false + ) + + #expect(plan.activatesIroh) + #expect(plan.startsLegacyListener) + } + + @Test func stablePreservesHistoricalTailscaleCompatibilityRequest() throws { + let suiteName = "MobileHostServiceSettingsTests.StableLegacyOptIn.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + defaults.set(true, forKey: "cmuxMobilePairingHostEnabled") + + let enabled = MobileHostService.isListeningEnabled( + defaults: defaults, + buildFlavor: .stable + ) + let plan = MobileHostService.startupPlan( + remoteControlDisabledByPolicy: false, + pairingEnabled: enabled, + legacyListenerRunning: false + ) + + #expect(plan.activatesIroh) + #expect(plan.startsLegacyListener) } @Test func configuredPortDefaultsToCatalogDefaultWhenUnset() throws { @@ -56,20 +214,119 @@ struct MobileHostServiceSettingsTests { #expect(MobileHostService.configuredPort(defaults: defaults) == expected) } - @Test func settingsUseObservedLocalAddressesAndIgnoreLegacyRouteHints() throws { + @Test func resolvedDesiredPortIsNilForInvalidSoRunningListenerIsNotDisturbed() throws { + let suiteName = "MobileHostServiceSettingsTests.Port.Resolved.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + // Unset → catalog default (a valid desired port). + #expect(MobileHostService.resolvedDesiredPort(defaults: defaults) + == SettingCatalog().mobile.iOSPairingPort.defaultValue) + + // Valid override → that port. + defaults.set(58_470, forKey: MobileHostService.portDefaultsKey) + #expect(MobileHostService.resolvedDesiredPort(defaults: defaults) == 58_470) + + // Invalid override → nil, so syncToSettings keeps the running listener + // on its applied port instead of restarting onto the default. + defaults.set(70_000, forKey: MobileHostService.portDefaultsKey) + #expect(MobileHostService.resolvedDesiredPort(defaults: defaults) == nil) + } + + @Test func portApplyPreBindClassifiesNonBindCases() { + // Out of range → invalid, regardless of anything else. + #expect(MobileHostService.portApplyPreBindOutcome(enabled: true, currentBoundPort: nil, requestedPort: 0) == .invalid) + #expect(MobileHostService.portApplyPreBindOutcome(enabled: true, currentBoundPort: nil, requestedPort: 70000) == .invalid) + // Pairing off → saved for when it's enabled. + #expect(MobileHostService.portApplyPreBindOutcome(enabled: false, currentBoundPort: nil, requestedPort: 58465) == .savedWhileDisabled) + // Already bound to the requested port → applied, no bind attempt. + #expect(MobileHostService.portApplyPreBindOutcome(enabled: true, currentBoundPort: 58465, requestedPort: 58465) == .applied(58465)) + } + + @Test func portApplyPreBindReturnsNilWhenABindIsNeeded() { + // Enabled, valid, different from the bound port → needs a real bind + // attempt (make-before-break), signalled by nil. + #expect(MobileHostService.portApplyPreBindOutcome(enabled: true, currentBoundPort: 58465, requestedPort: 58470) == nil) + // Not running yet, enabled, valid → also needs a bind. + #expect(MobileHostService.portApplyPreBindOutcome(enabled: true, currentBoundPort: nil, requestedPort: 58470) == nil) + } + + @Test func syncDecisionStartsStopsAndNoOpsForEnabledState() { + // Disabled: stop only when something is running, otherwise no-op. + #expect(MobileHostService.syncDecision(enabled: false, listenerRunning: false, desiredPort: 58465, appliedPort: nil) == .noop) + #expect(MobileHostService.syncDecision(enabled: false, listenerRunning: true, desiredPort: 58465, appliedPort: 58465) == .stop) + // Enabled but not running: start. + #expect(MobileHostService.syncDecision(enabled: true, listenerRunning: false, desiredPort: 58465, appliedPort: nil) == .start) + } + + @Test func syncDecisionRestartsOnlyWhenPortChanges() { + // Running on the desired port: nothing to do (does not drop connections + // on unrelated UserDefaults writes). + #expect(MobileHostService.syncDecision(enabled: true, listenerRunning: true, desiredPort: 58465, appliedPort: 58465) == .noop) + // Running on a different port than desired: restart to rebind. + #expect(MobileHostService.syncDecision(enabled: true, listenerRunning: true, desiredPort: 9000, appliedPort: 58465) == .restart) + // Running but the applied port is unknown: restart to reconcile. + #expect(MobileHostService.syncDecision(enabled: true, listenerRunning: true, desiredPort: 58465, appliedPort: nil) == .restart) + } + + @Test func mobilePairingSnapshotShowsIrohDirectAddressesAlongsideHostPortRoutes() throws { + let now = Date(timeIntervalSince1970: 1_756_000_000) + let identity = try CmxIrohPeerIdentity( + endpointID: String(repeating: "a", count: 64) + ) + let ipv4 = try CmxIrohPathHint( + kind: .directAddress, + value: "93.184.216.34:58465", + source: .native, + privacyScope: .publicInternet + ) + let ipv6 = try CmxIrohPathHint( + kind: .directAddress, + value: "[2606:4700::6810:1]:60001", + source: .native, + privacyScope: .publicInternet + ) + let expired = try CmxIrohPathHint( + kind: .directAddress, + value: "93.184.216.35:58465", + source: .native, + privacyScope: .publicInternet, + observedAt: now.addingTimeInterval(-200), + expiresAt: now.addingTimeInterval(-100) + ) let status = MobileHostServiceStatus( - isRunning: true, port: 58465, configuredPort: 60000, - usesEphemeralFallback: false, routes: [try CmxAttachRoute( - id: "retired-tcp", kind: .tailscale, - endpoint: .hostPort(host: "100.64.0.1", port: 1234))], - activeConnectionCount: 2, lastErrorDescription: nil, - pendingPortChange: true, - localSocketAddresses: ["192.168.1.2:58465", "[fd00::2]:58465", "192.168.1.2:58465"]) - let snapshot = HostSettingsActions.mobilePairingSnapshot(from: status) - #expect(snapshot.routes.map(\.endpoint) == ["192.168.1.2:58465", "[fd00::2]:58465"]) - #expect(snapshot.boundPort == 58465) - #expect(snapshot.configuredPort == 60000) - #expect(snapshot.pendingPortChange) + isRunning: true, + port: 58_465, + configuredPort: 58_465, + usesEphemeralFallback: false, + routes: [ + try CmxAttachRoute( + id: "tailscale", + kind: .tailscale, + endpoint: .hostPort(host: "100.64.0.1", port: 58_465), + priority: 10 + ), + try CmxAttachRoute( + id: "iroh", + kind: .iroh, + endpoint: .peer(identity: identity, pathHints: [ipv4, ipv6, expired, ipv4]), + priority: 0 + ), + ], + activeConnectionCount: 0, + lastErrorDescription: nil + ) + + let snapshot = HostSettingsActions.mobilePairingSnapshot(from: status, now: now) + + // TCP listener routes keep their row; the Iroh endpoint contributes one + // row per usable direct-address hint, deduplicated, expired hints dropped. + #expect(snapshot.routes.map(\.endpoint) == [ + "100.64.0.1:58465", + "93.184.216.34:58465", + "[2606:4700::6810:1]:60001", + ]) + #expect(Set(snapshot.routes.map(\.id)).count == snapshot.routes.count) } @Test func splitSocketAddressParsesSocketLiteralsOnly() throws { @@ -87,6 +344,122 @@ struct MobileHostServiceSettingsTests { } #if DEBUG +@Suite(.serialized) +struct MobileHostTransportRouteCompositionTests { + @Test func tcpRouteRefreshDoesNotRemoveTheActiveIrohRoute() throws { + defer { MobileHostPublicStatusCache.removeAll() } + MobileHostPublicStatusCache.removeAll() + let binding = try JSONDecoder().decode( + CmxIrohBrokerBinding.self, + from: Data( + """ + { + "binding_id":"123e4567-e89b-42d3-a456-426614174010", + "device_id":"123e4567-e89b-42d3-a456-426614174011", + "app_instance_id":"123e4567-e89b-42d3-a456-426614174012", + "tag":"dev", + "platform":"mac", + "display_name":"Test Mac", + "endpoint_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "identity_generation":1, + "pairing_enabled":true, + "capabilities":["mobile-rpc-v1","multistream-v1"], + "path_hints":[], + "last_seen_at":"2026-07-09T12:00:00.000Z" + } + """.utf8 + ) + ) + let tailscale = try CmxAttachRoute( + id: "tailscale", + kind: .tailscale, + endpoint: .hostPort(host: "100.64.0.1", port: 58_465), + priority: 10 + ) + + MobileHostPublicStatusCache.update( + irohIdentity: binding.endpointID, + pathHints: binding.pathHints + ) + MobileHostPublicStatusCache.update(routes: [tailscale]) + #expect(MobileHostPublicStatusCache.snapshot().map(\.kind) == [.iroh, .tailscale]) + + MobileHostPublicStatusCache.update(routes: []) + #expect(MobileHostPublicStatusCache.snapshot().map(\.kind) == [.iroh]) + } + + @MainActor + @Test func tcpListenerRestartDoesNotEraseIrohClientState() { + let service = MobileHostService.shared + let irohConnectionID = UUID() + service.debugResetMobileLifecycleStateForTesting() + defer { service.debugResetMobileLifecycleStateForTesting() } + service.debugRecordClientIDForTesting( + "iroh-client", + connectionID: irohConnectionID + ) + + service.debugStopLegacyListenerForTesting() + + #expect( + service.debugTrackedClientIDsForTesting(connectionID: irohConnectionID) + == ["iroh-client"] + ) + } + + @Test func irohBindingLifecycleDoesNotRemoveTailscaleRoute() throws { + defer { MobileHostPublicStatusCache.removeAll() } + MobileHostPublicStatusCache.removeAll() + let binding = try JSONDecoder().decode( + CmxIrohBrokerBinding.self, + from: Data( + """ + { + "binding_id":"123e4567-e89b-42d3-a456-426614174010", + "device_id":"123e4567-e89b-42d3-a456-426614174011", + "app_instance_id":"123e4567-e89b-42d3-a456-426614174012", + "tag":"dev", + "platform":"mac", + "display_name":"Test Mac", + "endpoint_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "identity_generation":1, + "pairing_enabled":true, + "capabilities":["mobile-rpc-v1","multistream-v1"], + "path_hints":[{ + "kind":"relay_url", + "value":"https://relay.example.com/", + "source":"native", + "privacy_scope":"public_internet" + }], + "last_seen_at":"2026-07-09T12:00:00.000Z" + } + """.utf8 + ) + ) + let tailscale = try CmxAttachRoute( + id: "tailscale", + kind: .tailscale, + endpoint: .hostPort(host: "100.64.0.1", port: 58_465), + priority: 10 + ) + + MobileHostPublicStatusCache.update(routes: [tailscale]) + MobileHostPublicStatusCache.update( + irohBinding: CmxIrohBrokerBindingMetadata(binding: binding) + ) + let routes = MobileHostPublicStatusCache.snapshot() + #expect(routes.map(\.kind) == [.iroh, .tailscale]) + guard case let .peer(_, pathHints) = routes.first?.endpoint else { + Issue.record("Expected the cached Iroh route to retain broker path hints") + return + } + #expect(pathHints == binding.pathHints) + + MobileHostPublicStatusCache.update(irohIdentity: nil) + #expect(MobileHostPublicStatusCache.snapshot().map(\.kind) == [.tailscale]) + } +} + @Suite(.serialized) @MainActor struct MobileHostMacScopedMutationAuthorizationTests { @@ -108,134 +481,3 @@ struct MobileHostMacScopedMutationAuthorizationTests { } #endif - -@Suite(.serialized, .timeLimit(.minutes(1))) -@MainActor -struct MobileHostV2LifecycleTests { - private func withDefaults(_ body: (UserDefaults) async throws -> Void) async throws { - let name = "MobileHostV2LifecycleTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: name)) - defer { defaults.removePersistentDomain(forName: name) } - try await body(defaults) - } - - private func waitForSubscription(_ runtime: MobileHostRuntimeProbe) async throws { - let deadline = ContinuousClock.now.advanced(by: .seconds(2)) - while runtime.subscriberCount == 0, ContinuousClock.now < deadline { - try await Task.sleep(for: .milliseconds(5)) - } - try #require(runtime.subscriberCount > 0) - } - - @Test func savingPortLeavesTheCurrentEndpointRunning() async throws { - try await withDefaults { defaults in - let runtime = MobileHostRuntimeProbe(state: .init(phase: .ready, boundPort: 58465, preferredPort: 58465)) - let service = MobileHostService(defaults: defaults, runtime: runtime) - #expect(await service.applyConfiguredPort(60001) == .savedForLater) - #expect(MobileHostService.configuredPort(defaults: defaults) == 60001) - let status = service.statusSnapshot() - #expect(status.isRunning) - #expect(status.port == 58465) - #expect(status.pendingPortChange) - #expect(!status.usesEphemeralFallback) - #expect(runtime.startCount == 0) - #expect(runtime.stopCount == 0) - #expect(await service.applyConfiguredPort(58465) == .applied(58465)) - #expect(!service.statusSnapshot().pendingPortChange) - } - } - - @Test func disabledAndInvalidPortEditsCannotStartNetworking() async throws { - try await withDefaults { defaults in - let runtime = MobileHostRuntimeProbe(allowed: false) - let service = MobileHostService(defaults: defaults, runtime: runtime) - #expect(await service.applyConfiguredPort(60001) == .savedForLater) - #expect(await service.applyConfiguredPort(0) == .invalid) - #expect(MobileHostService.configuredPort(defaults: defaults) == 60001) - #expect(!service.statusSnapshot().isRunning) - #expect(runtime.startCount == 0) - #expect(runtime.stopCount == 0) - } - } - - @Test func readinessWaitsForTheActualIROHReadyEvent() async throws { - try await withDefaults { defaults in - let runtime = MobileHostRuntimeProbe() - let service = MobileHostService(defaults: defaults, runtime: runtime) - let waiting = Task { await service.ensureListeningAndReady() } - try await waitForSubscription(runtime) - #expect(!service.statusSnapshot().isRunning) - runtime.emit(.init(phase: .ready, boundPort: 60002, preferredPort: 58465)) - let status = await waiting.value - #expect(status.isRunning) - #expect(status.port == 60002) - #expect(status.usesEphemeralFallback) - #expect(runtime.startCount == 1) - } - } - - @Test func cancellingOrTimingOutReadinessDoesNotStopTheListenerOwner() async throws { - try await withDefaults { defaults in - let runtime = MobileHostRuntimeProbe() - let service = MobileHostService(defaults: defaults, runtime: runtime) - let waiting = Task { await service.ensureListeningAndReady() } - try await waitForSubscription(runtime) - waiting.cancel() - #expect(await waiting.value.isRunning == false) - #expect(runtime.stopCount == 0) - #expect(await service.ensureListeningAndReady(timeout: .milliseconds(10)).isRunning == false) - #expect(runtime.stopCount == 0) - } - } - - @Test func disabledPolicyHidesAnOldReadySnapshotImmediately() async throws { - try await withDefaults { defaults in - let runtime = MobileHostRuntimeProbe(allowed: false, - state: .init(phase: .ready, boundPort: 58465, preferredPort: 58465)) - let service = MobileHostService(defaults: defaults, runtime: runtime) - let status = service.statusSnapshot() - #expect(!status.isRunning) - #expect(status.port == nil) - #expect(status.routes.isEmpty) - #expect(!status.pendingPortChange) - } - } -} - -@MainActor -private final class MobileHostRuntimeProbe: MobileHostPairingRuntime { - var isNetworkingAllowed: Bool - var listenerState: MobileHostListenerState - private(set) var startCount = 0 - private(set) var stopCount = 0 - private var subscribers: [UUID: AsyncStream.Continuation] = [:] - var subscriberCount: Int { subscribers.count } - - init(allowed: Bool = true, state: MobileHostListenerState = .init()) { - isNetworkingAllowed = allowed - listenerState = state - } - - func configure(auth: AuthCoordinator) {} - func applyManagedNetworkingPolicy() async { - startCount += 1 - if isNetworkingAllowed, listenerState.phase == .stopped { emit(.init(phase: .starting)) } - } - func prepareForStop() { emit(.init()) } - func stopHost() async { stopCount += 1; emit(.init()) } - func foreground() async {} - func emit(_ state: MobileHostListenerState) { - listenerState = state - for subscriber in subscribers.values { subscriber.yield(state) } - } - func listenerStateUpdates() -> AsyncStream { - let id = UUID() - return AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in - subscribers[id] = continuation - continuation.yield(listenerState) - continuation.onTermination = { @Sendable [weak self] _ in - Task { @MainActor in self?.subscribers.removeValue(forKey: id) } - } - } - } -} diff --git a/cmuxTests/MobilePairingConnectionTransitionTests.swift b/cmuxTests/MobilePairingConnectionTransitionTests.swift index dbe0ab4ad41c..0b0587c7caf1 100644 --- a/cmuxTests/MobilePairingConnectionTransitionTests.swift +++ b/cmuxTests/MobilePairingConnectionTransitionTests.swift @@ -192,6 +192,17 @@ struct MobilePairingConnectionTransitionTests { #expect(next == .signedOut) } + @Test("Pairing-disabled empty state is unaffected by connection-count changes") + func pairingDisabledIsUnaffected() throws { + let next = MobilePairingModel.statusTransition( + from: .pairingDisabled, + routes: try matchingRoutes(), + activeConnectionCount: 1, + baselineConnectionCount: 0 + ) + #expect(next == .pairingDisabled) + } + @Test("Tailscale is the only Mac pairing QR when Iroh is also available") func tailscaleRouteWinsWhenIrohIsAvailable() throws { let plan = try #require(MobilePairingModel.PairingRoutePlan.make(routes: [ diff --git a/cmuxTests/PresenceHeartbeatClientTests.swift b/cmuxTests/PresenceHeartbeatClientTests.swift index 06623bd92d57..b27f471953b9 100644 --- a/cmuxTests/PresenceHeartbeatClientTests.swift +++ b/cmuxTests/PresenceHeartbeatClientTests.swift @@ -48,8 +48,9 @@ import Testing } @Test func emptyRoutesAreStatedNotOmitted() throws { - // Pairing off: the wire must carry [] ("no routes"), never an absent - // field (which the service reads as "keep the previous set"). + // While the host is enabled, the wire must carry [] ("no routes"), + // never an absent field (which the service reads as "keep the previous + // set"). Pairing off suppresses the heartbeat before this body exists. let body = PresenceHeartbeatClient.heartbeatBody( deviceID: "11111111-2222-4333-8444-555555555555", tag: "default", @@ -142,4 +143,16 @@ import Testing defaults: defaults )?.absoluteString == PresenceSettings.productionServiceURL) } + + @MainActor + @Test func explicitPresenceEnableCannotOverridePairingOptOut() { + let suiteName = "presence-pairing-gate-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(true, forKey: PresenceSettings.enabledKey) + defaults.set(false, forKey: MobileHostService.listeningEnabledDefaultsKey) + + #expect(!PresenceSettings.isEnabled(defaults: defaults)) + } } diff --git a/cmuxUITests/SettingsAppBehaviorUITests.swift b/cmuxUITests/SettingsAppBehaviorUITests.swift index dbb667d4817e..9c1c7913f5ab 100644 --- a/cmuxUITests/SettingsAppBehaviorUITests.swift +++ b/cmuxUITests/SettingsAppBehaviorUITests.swift @@ -1,3 +1,4 @@ +import AppKit import XCTest /// Behavioral XCUITests for the Settings **App** section. @@ -93,6 +94,51 @@ import XCTest /// and nothing should hit the network from a test. Verify via the /// telemetry client's unit tests instead. final class SettingsAppBehaviorUITests: SettingsUITestCase { + func testMobilePairingSettingsLightAndDarkCaptures() throws { + for appearance in ["light", "dark"] { + let app = XCUIApplication.cmuxTestApplication() + app.launchArguments += settingsLaunchArguments + ["-appearanceMode", appearance] + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + launchAndActivate(app) + defer { app.terminate() } + let window = openSettings(app) + navigate(window, to: "Mobile") + let pairingToggle = window.checkBoxes["SettingsMobileIOSPairingHostToggle"].firstMatch + XCTAssertTrue(pairingToggle.waitForExistence(timeout: 5)) + let detail = window.staticTexts["Allows iOS pairing and Iroh networking for this Mac."].firstMatch + if !detail.exists { + pairingToggle.click() + } + let title = window.staticTexts["Enable iOS pairing"].firstMatch + XCTAssertTrue(title.waitForExistence(timeout: 5)) + XCTAssertTrue(detail.waitForExistence(timeout: 5)) + let header = try XCTUnwrap(window.staticTexts.matching(identifier: "Mobile").allElementsBoundByIndex.first { + $0.frame.minX > window.frame.minX + 150 + }) + XCTAssertLessThan(title.frame.minY, window.staticTexts["Forward Notifications to iPhone"].firstMatch.frame.minY) + let crop = header.frame.union(title.frame).union(detail.frame).insetBy(dx: -12, dy: -12) + let source = try XCTUnwrap(window.screenshot().image.cgImage(forProposedRect: nil, context: nil, hints: nil)) + let scale = CGFloat(source.width) / window.frame.width + let pixels = CGRect( + x: (crop.minX - window.frame.minX) * scale, + y: (crop.minY - window.frame.minY) * scale, + width: crop.width * scale, + height: crop.height * scale + ).integral + let cropped = try XCTUnwrap(source.cropping(to: pixels)) + let capture = XCTAttachment(image: NSImage(cgImage: cropped, size: crop.size)) + capture.name = "MacSettingsMobilePairing-\(appearance)" + capture.lifetime = .keepAlways + add(capture) + let full = XCTAttachment(screenshot: window.screenshot()) + full.name = "Mac Settings - \(appearance)" + full.lifetime = .keepAlways + add(full) + pairingToggle.click() + app.terminate() + } + } + func testGermanSettingsNavigationAndSearchUseTranslations() { assertLocalizedNavigation( language: "de", account: "Konto", shortcuts: "Tastaturkurzbefehle", diff --git a/ios/CHANGELOG.md b/ios/CHANGELOG.md index 0a2ba087cd0f..b817ca060da9 100644 --- a/ios/CHANGELOG.md +++ b/ios/CHANGELOG.md @@ -48,6 +48,7 @@ for a different version), so bump the beta version with ### Internal +- Mac+iOS pairing is now opt-in. Turn on Enable iOS pairing in Settings > Mobile for each cmux Mac app before testing discovery. - Version-sync bump: checked-in beta marketing version catches up to `1.0.4`, the version already live to external founders (an earlier upload under that version shipped stale, pre-#7636 code; the CI reship on 2026-07-09 replaced it with current `main`). - Fix iOS surface-teardown deadlock behind external-beta watchdog kills (#7666). - Add iOS account deletion and legal links (#7645). @@ -64,6 +65,7 @@ for a different version), so bump the beta version with ### External +- Each cmux Mac app now requires Enable iOS pairing in Settings > Mobile before that Mac can appear on iPhone. - Fixed an issue where the app could freeze or crash in the background. - Fixed an issue that could sign you out unexpectedly after updating. - Terminal connections recover more reliably after switching apps or losing network. diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 3e26956baf6a..69921c54c354 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -83194,179 +83194,325 @@ } } }, - "mobile.connectionsUpdate.releaseLabel": { + "mobile.pairingOptInUpdate.detail": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "1.0.5 · August 2026" + "value": "Before this iPhone can find a cmux Mac, open Settings > Mobile on that Mac and turn on Enable iOS pairing. While it is off, the Mac stays hidden and starts no iOS pairing networking." } }, "ja": { "stringUnit": { "state": "translated", - "value": "1.0.5 · 2026年8月" + "value": "この iPhone で cmux Mac を見つけるには、その Mac で「設定」>「Mobile」を開き、「iOS ペアリングを有効にする」をオンにしてください。オフの間、その Mac は非表示になり、iOS ペアリングのネットワーク通信を開始しません。" } }, "de": { "stringUnit": { "state": "translated", - "value": "1.0.5 · August 2026" + "value": "Damit dieses iPhone einen cmux Mac findet, öffnen Sie auf dem Mac „Einstellungen“ > „Mobile“ und aktivieren Sie die iOS-Kopplung. Solange sie deaktiviert ist, bleibt der Mac verborgen und startet kein iOS-Kopplungsnetzwerk." } }, "fr": { "stringUnit": { "state": "translated", - "value": "1.0.5 · août 2026" + "value": "Pour que cet iPhone trouve un Mac cmux, ouvrez Réglages > Mobile sur ce Mac et activez le jumelage iOS. Tant qu’il est désactivé, le Mac reste masqué et ne démarre aucun réseau de jumelage iOS." } }, "ar": { "stringUnit": { "state": "translated", - "value": "1.0.5 · أغسطس 2026" + "value": "لكي يعثر هذا الـ iPhone على Mac يعمل بتطبيق cmux، افتح الإعدادات > الهاتف المحمول على جهاز Mac هذا وفعّل اقتران iOS. أثناء إيقافه، سيبقى Mac مخفيًا ولن يبدأ أي اتصال شبكي لاقتران iOS." } }, "es": { "stringUnit": { "state": "translated", - "value": "1.0.5 · Agosto 2026" + "value": "Para que este iPhone encuentre un Mac con cmux, abre Ajustes > Móvil en ese Mac y activa el enlace con iOS. Mientras esté desactivado, el Mac permanece oculto y no inicia la red de enlace con iOS." } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "1.0.5 · 2026 年 8 月" + "value": "若要讓此 iPhone 找到 cmux Mac,請在該 Mac 上開啟「設定」>「行動裝置」,然後開啟 iOS 配對。關閉時,該 Mac 會保持隱藏,且不會啟動 iOS 配對網路。" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "1.0.5 · 2026 年 8 月" + "value": "要让此 iPhone 找到 cmux Mac,请在该 Mac 上打开“设置”>“移动”,然后开启 iOS 配对。关闭时,该 Mac 会保持隐藏,也不会启动 iOS 配对网络。" } }, "ko": { "stringUnit": { "state": "translated", - "value": "1.0.5 · 2026년 8월" + "value": "이 iPhone에서 cmux Mac을 찾으려면 해당 Mac에서 설정 > 모바일을 열고 iOS 페어링을 켜세요. 꺼져 있는 동안 Mac은 숨겨지고 iOS 페어링 네트워크를 시작하지 않습니다." } } } }, - "mobile.connectionsUpdate.tailscale.detail": { + "mobile.pairingOptInUpdate.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Choosing Tailscale Only shows exactly what's missing and offers the pairing-code scan right there. Nothing opens on its own." + "value": "Required: Enable iOS pairing on Mac" } }, "ja": { "stringUnit": { "state": "translated", - "value": "「Tailscaleのみ」を選ぶと、足りない設定とペアリングコードのスキャンをその場で案内します。勝手に画面が開くことはありません。" + "value": "必須: Mac で iOS ペアリングを有効にする" } }, "de": { "stringUnit": { "state": "translated", - "value": "Wenn Sie sich für „Nur Tailscale“ entscheiden, wird genau angezeigt, was fehlt, und der Pairing-Code-Scan wird direkt dort angeboten. Nichts öffnet sich von alleine." + "value": "Erforderlich: iOS-Kopplung auf dem Mac aktivieren" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Choisir Tailscale Only montre exactement ce qui manque et propose l'analyse du code d'appariement directement sur place. Rien ne s'ouvre tout seul." + "value": "Requis : activez le jumelage iOS sur le Mac" } }, "ar": { "stringUnit": { "state": "translated", - "value": "يؤدي اختيار Tailscale فقط إلى عرض ما هو مفقود بالضبط ويوفر فحص رمز الاقتران هناك. لا شيء يفتح من تلقاء نفسه." + "value": "مطلوب: فعّل اقتران iOS على Mac" } }, "es": { "stringUnit": { "state": "translated", - "value": "Elegir Tailscale Only muestra exactamente lo que falta y ofrece el escaneo del código de emparejamiento allí mismo. Nada se abre por sí solo." + "value": "Obligatorio: activa el enlace con iOS en el Mac" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "選擇“Tailscale Only”會準確顯示缺少的內容,並在此處提供配對碼掃描。没有什么是可以自行打开的。" + "value": "必要:在 Mac 上開啟 iOS 配對" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "选择“Tailscale Only”会准确显示缺少的内容,并在此处提供配对码扫描。没有什么是可以自行打开的。" + "value": "必需:在 Mac 上开启 iOS 配对" } }, "ko": { "stringUnit": { "state": "translated", - "value": "Tailscale Only를 선택하면 누락된 내용이 정확히 표시되고 바로 페어링 코드 스캔이 제공됩니다. 저절로 열리는 것은 없습니다." + "value": "필수: Mac에서 iOS 페어링 켜기" } } } }, "mobile.connectionsUpdate.tailscale.title": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Tailscale, on your terms"}}, + "ja": {"stringUnit": {"state": "translated", "value": "Tailscaleは自分のペースで"}}, + "de": {"stringUnit": {"state": "translated", "value": "Tailscale, zu Ihren Bedingungen"}}, + "fr": {"stringUnit": {"state": "translated", "value": "Tailscale, selon vos conditions"}}, + "ar": {"stringUnit": {"state": "translated", "value": "Tailscale ، وفقًا لشروطك"}}, + "es": {"stringUnit": {"state": "translated", "value": "Tailscale, según tus condiciones"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "Tailscale,依照您的條件"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "Tailscale,按照您的条件"}}, + "ko": {"stringUnit": {"state": "translated", "value": "Tailscale, 원하는 대로"}} + } + }, + "mobile.connectionsUpdate.tailscale.detail": { + "extractionState": "manual", + "localizations": { + "en": {"stringUnit": {"state": "translated", "value": "Choosing Tailscale Only shows exactly what's missing and offers the pairing-code scan right there. Nothing opens on its own."}}, + "ja": {"stringUnit": {"state": "translated", "value": "「Tailscaleのみ」を選ぶと、足りない設定とペアリングコードのスキャンをその場で案内します。勝手に画面が開くことはありません。"}}, + "de": {"stringUnit": {"state": "translated", "value": "Wenn Sie sich für „Nur Tailscale“ entscheiden, wird genau angezeigt, was fehlt, und der Pairing-Code-Scan wird direkt dort angeboten. Nichts öffnet sich von alleine."}}, + "fr": {"stringUnit": {"state": "translated", "value": "Choisir Tailscale Only montre exactement ce qui manque et propose l'analyse du code d'appariement directement sur place. Rien ne s'ouvre tout seul."}}, + "ar": {"stringUnit": {"state": "translated", "value": "يؤدي اختيار Tailscale فقط إلى عرض ما هو مفقود بالضبط ويوفر فحص رمز الاقتران هناك. لا شيء يفتح من تلقاء نفسه."}}, + "es": {"stringUnit": {"state": "translated", "value": "Elegir Tailscale Only muestra exactamente lo que falta y ofrece el escaneo del código de emparejamiento allí mismo. Nada se abre por sí solo."}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "選擇“Tailscale Only”會準確顯示缺少的內容,並在此處提供配對碼掃描。没有什么是可以自行打开的。"}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "选择“Tailscale Only”会准确显示缺少的内容,并在此处提供配对码扫描。没有什么是可以自行打开的。"}}, + "ko": {"stringUnit": {"state": "translated", "value": "Tailscale Only를 선택하면 누락된 내용이 정확히 표시되고 바로 페어링 코드 스캔이 제공됩니다. 저절로 열리는 것은 없습니다."}} + } + }, + "mobile.pairingOptInUpdate.requirement": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open cmux Settings > Mobile on your Mac and turn on Enable iOS pairing before connecting." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続する前に必須: iPhone で使う各 cmux Mac で「設定」>「Mobile」を開き、「iOS ペアリングを有効にする」をオンにしてください。" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vor dem Verbinden erforderlich: Öffnen Sie auf jedem cmux Mac, den Sie mit dem iPhone verwenden möchten, „Einstellungen“ > „Mobile“ und aktivieren Sie die iOS-Kopplung." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Requis avant la connexion : sur chaque Mac cmux que vous voulez utiliser avec l’iPhone, ouvrez Réglages > Mobile et activez le jumelage iOS." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مطلوب قبل الاتصال: على كل Mac يعمل بتطبيق cmux وتريد استخدامه مع iPhone، افتح الإعدادات > الهاتف المحمول وفعّل اقتران iOS." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Obligatorio antes de conectar: en cada Mac con cmux que quieras usar con iPhone, abre Ajustes > Móvil y activa el enlace con iOS." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "連線前必須完成:在每個要搭配 iPhone 使用的 cmux Mac 上,開啟「設定」>「行動裝置」,然後開啟 iOS 配對。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "连接前必须完成:在每个要搭配 iPhone 使用的 cmux Mac 上,打开“设置”>“移动”,然后开启 iOS 配对。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "연결하기 전에 필수: iPhone과 함께 사용할 각 cmux Mac에서 설정 > 모바일을 열고 iOS 페어링을 켜세요." + } + } + } + }, + "mobile.pairingOptInUpdate.releaseLabel": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Tailscale, on your terms" + "value": "1.0.4 · September 2026" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Tailscaleは自分のペースで" + "value": "1.0.4 · 2026年9月" } }, "de": { "stringUnit": { "state": "translated", - "value": "Tailscale, zu Ihren Bedingungen" + "value": "1.0.4 · September 2026" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Tailscale, selon vos conditions" + "value": "1.0.4 · septembre 2026" } }, "ar": { "stringUnit": { "state": "translated", - "value": "Tailscale ، وفقًا لشروطك" + "value": "1.0.4 · سبتمبر 2026" } }, "es": { "stringUnit": { "state": "translated", - "value": "Tailscale, según tus condiciones" + "value": "1.0.4 · septiembre de 2026" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "Tailscale,依照您的條件" + "value": "1.0.4 · 2026 年 9 月" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "1.0.4 · 2026 年 9 月" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "1.0.4 · 2026년 9월" + } + } + } + }, + "mobile.connectionsUpdate.releaseLabel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "1.0.5 · August 2026" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1.0.5 · 2026年8月" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "1.0.5 · August 2026" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "1.0.5 · août 2026" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "1.0.5 · أغسطس 2026" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "1.0.5 · Agosto 2026" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "1.0.5 · 2026 年 8 月" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Tailscale,按照您的条件" + "value": "1.0.5 · 2026 年 8 月" } }, "ko": { "stringUnit": { "state": "translated", - "value": "Tailscale, 원하는 대로" + "value": "1.0.5 · 2026년 8월" } } } @@ -83430,6 +83576,127 @@ } } }, + "mobile.whatsNew.pairing.pageTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Action Required: Enable iOS pairing on your Mac" + } + } + } + }, + "mobile.whatsNew.pairing.macSettingsLabel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mac Settings" + } + } + } + }, + "mobile.whatsNew.pairing.compatibilityTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mac compatibility" + } + } + } + }, + "mobile.whatsNew.pairing.screenshotLabel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux Mac Settings, Mobile section, showing Enable iOS pairing." + } + } + } + }, + "mobile.whatsNew.pairing.stableLabel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stable Mac" + } + } + } + }, + "mobile.whatsNew.pairing.nightlyLabel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Nightly Mac" + } + } + } + }, + "mobile.whatsNew.pairing.macVersionFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux %@ or later" + } + } + } + }, + "mobile.whatsNew.pairing.nightlyVersionFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux NIGHTLY %@ or later" + } + } + } + }, + "mobile.whatsNew.pairing.noStableMinimum": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No stable minimum listed" + } + } + } + }, + "mobile.whatsNew.pairing.noNightlyMinimum": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No separate minimum" + } + } + } + }, + "mobile.whatsNew.pairing.connectionChoicesTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connection choices" + } + } + } + }, "mobile.settings.whatsNew": { "extractionState": "manual", "localizations": { @@ -83548,297 +83815,533 @@ } } }, - "mobile.whatsNew.cta": { + "mobile.whatsNew.debug.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Continue" + "value": "Replay What's New" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neuigkeiten erneut anzeigen" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Revoir les nouveautés" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض المستجدات مجددًا" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Volver a ver las novedades" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "再次顯示新功能" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "再次显示新功能" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새로운 기능 다시 보기" } }, "ja": { "stringUnit": { "state": "translated", - "value": "続ける" + "value": "新機能をもう一度表示" + } + } + } + }, + "mobile.whatsNew.debug.first": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "First Update" } }, "de": { "stringUnit": { "state": "translated", - "value": "Weitermachen" + "value": "Erstes Update" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Continuer" + "value": "Première mise à jour" } }, "ar": { "stringUnit": { "state": "translated", - "value": "يكمل" + "value": "أول تحديث" } }, "es": { "stringUnit": { "state": "translated", - "value": "Continuar" + "value": "Primera actualización" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "繼續" + "value": "第一個更新" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "继续" + "value": "第一个更新" } }, "ko": { "stringUnit": { "state": "translated", - "value": "계속하다" + "value": "첫 번째 업데이트" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最初のアップデート" } } } }, - "mobile.whatsNew.webRetry": { + "mobile.whatsNew.debug.last": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Try Again" + "value": "Last Update" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Letztes Update" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Dernière mise à jour" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "آخر تحديث" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Última actualización" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "最後一個更新" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "最后一个更新" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "마지막 업데이트" } }, "ja": { "stringUnit": { "state": "translated", - "value": "再試行" + "value": "最後のアップデート" + } + } + } + }, + "mobile.whatsNew.debug.show": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Sheets" } }, "de": { "stringUnit": { "state": "translated", - "value": "Versuchen Sie es erneut" + "value": "Infoseiten anzeigen" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Essayer à nouveau" + "value": "Afficher les fiches" } }, "ar": { "stringUnit": { "state": "translated", - "value": "حاول ثانية" + "value": "عرض الصفحات" } }, "es": { "stringUnit": { "state": "translated", - "value": "Intentar otra vez" + "value": "Mostrar las páginas" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "再試一次" + "value": "顯示頁面" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "再试一次" + "value": "显示页面" } }, "ko": { "stringUnit": { "state": "translated", - "value": "다시 시도" + "value": "시트 표시" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "シートを表示" } } } }, - "mobile.whatsNew.webUnavailable": { + "mobile.whatsNew.debug.empty": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "This page needs an internet connection." + "value": "No updates available" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Updates verfügbar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune mise à jour disponible" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا توجد تحديثات متاحة" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No hay actualizaciones disponibles" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沒有可用的更新" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有可用的更新" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트 없음" } }, "ja": { "stringUnit": { "state": "translated", - "value": "このページの表示にはインターネット接続が必要です。" + "value": "アップデートはありません" + } + } + } + }, + "mobile.whatsNew.cta": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Continue" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "続ける" } }, "de": { "stringUnit": { "state": "translated", - "value": "Diese Seite benötigt eine Internetverbindung." + "value": "Weitermachen" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Cette page nécessite une connexion Internet." + "value": "Continuer" } }, "ar": { "stringUnit": { "state": "translated", - "value": "هذه الصفحة تحتاج إلى اتصال بالإنترنت." + "value": "يكمل" } }, "es": { "stringUnit": { "state": "translated", - "value": "Esta página necesita una conexión a Internet." + "value": "Continuar" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "此頁面需要網路連線。" + "value": "繼續" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "此页面需要互联网连接。" + "value": "继续" } }, "ko": { "stringUnit": { "state": "translated", - "value": "이 페이지는 인터넷 연결이 필요합니다." + "value": "계속하다" } } } }, - "mobile.connectionsUpdate.macUpdate.requiredVersion": { + "mobile.whatsNew.webRetry": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "the latest cmux NIGHTLY or cmux RELEASE" + "value": "Try Again" } }, "ja": { "stringUnit": { "state": "translated", - "value": "最新の cmux NIGHTLY または cmux RELEASE" + "value": "再試行" } }, "de": { "stringUnit": { "state": "translated", - "value": "die neueste cmux NIGHTLY oder cmux RELEASE" + "value": "Versuchen Sie es erneut" } }, "fr": { "stringUnit": { "state": "translated", - "value": "la dernière version cmux NIGHTLY ou cmux" + "value": "Essayer à nouveau" } }, "ar": { "stringUnit": { "state": "translated", - "value": "أحدث إصدار من cmux NIGHTLY أو cmux RELEASE" + "value": "حاول ثانية" } }, "es": { "stringUnit": { "state": "translated", - "value": "el último cmux NIGHTLY o cmux RELEASE" + "value": "Intentar otra vez" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "最新的 cmux NIGHTLY 或 cmux RELEASE" + "value": "再試一次" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "最新的 cmux NIGHTLY 或 cmux RELEASE" + "value": "再试一次" } }, "ko": { "stringUnit": { "state": "translated", - "value": "최신 cmux NIGHTLY 또는 cmux RELEASE" + "value": "다시 시도" } } } }, - "mobile.connectionsUpdate.macUpdate.title": { + "mobile.whatsNew.webUnavailable": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Action required: update your Mac" + "value": "This page needs an internet connection." } }, "ja": { "stringUnit": { "state": "translated", - "value": "要対応:Mac をアップデートしてください" + "value": "このページの表示にはインターネット接続が必要です。" } }, "de": { "stringUnit": { "state": "translated", - "value": "Aktion erforderlich: Aktualisieren Sie Ihren Mac" + "value": "Diese Seite benötigt eine Internetverbindung." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Action requise : mettez à jour votre Mac" + "value": "Cette page nécessite une connexion Internet." } }, "ar": { "stringUnit": { "state": "translated", - "value": "الإجراء المطلوب: قم بتحديث جهاز Mac الخاص بك" + "value": "هذه الصفحة تحتاج إلى اتصال بالإنترنت." } }, "es": { "stringUnit": { "state": "translated", - "value": "Acción requerida: actualice su Mac" + "value": "Esta página necesita una conexión a Internet." } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "需要採取的行動:更新您的 Mac" + "value": "此頁面需要網路連線。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "此页面需要互联网连接。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 페이지는 인터넷 연결이 필요합니다." + } + } + } + }, + "mobile.connectionsUpdate.macUpdate.requiredVersion": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "the latest cmux NIGHTLY or cmux RELEASE" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最新の cmux NIGHTLY または cmux RELEASE" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "die neueste cmux NIGHTLY oder cmux RELEASE" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "la dernière version cmux NIGHTLY ou cmux" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أحدث إصدار من cmux NIGHTLY أو cmux RELEASE" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "el último cmux NIGHTLY o cmux RELEASE" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "最新的 cmux NIGHTLY 或 cmux RELEASE" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "需要采取的行动:更新您的 Mac" + "value": "最新的 cmux NIGHTLY 或 cmux RELEASE" } }, "ko": { "stringUnit": { "state": "translated", - "value": "필요한 조치: Mac 업데이트" + "value": "최신 cmux NIGHTLY 또는 cmux RELEASE" } } } @@ -83849,7 +84352,7 @@ "en": { "stringUnit": { "state": "translated", - "value": "This iPhone update speaks a new connection protocol and only pairs with an updated Mac. Update cmux on your Mac to %@ before connecting. Not ready to update your Mac? Stay on (or revert to) cmux BETA TestFlight version 1.0.4 (20260817224846), the last version that works with older Macs." + "value": "Use cmux %@ or later. Older Macs: use BETA 1.0.4 (20260817224846)." } }, "ja": { @@ -84262,7 +84765,7 @@ "en": { "stringUnit": { "state": "translated", - "value": "This iPhone update speaks a new connection protocol and only pairs with an updated Mac. Update cmux on your Mac to %@ before connecting." + "value": "Use cmux %@ or later on your Mac before connecting." } }, "ja": { diff --git a/ios/cmux/cmux-ui.xctestplan b/ios/cmux/cmux-ui.xctestplan index eccabc3b72b1..c5cf24bac365 100644 --- a/ios/cmux/cmux-ui.xctestplan +++ b/ios/cmux/cmux-ui.xctestplan @@ -9,6 +9,8 @@ } ], "defaultOptions" : { + "preferredScreenCaptureFormat" : "video", + "uiTestingScreenshotsLifetime" : "keepAlways", "targetForVariableExpansion" : { "containerPath" : "container:cmux-ios.xcodeproj", "identifier" : "8B41F6442DEDD0D5001A66F9", diff --git a/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift b/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift index df79be8d58d0..64baf42b8115 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift @@ -375,6 +375,8 @@ public struct CMUXMobileRootScene: View { TaskComposerAccessibilityPreviewView() } else if UITestConfig.notificationFeedPreviewEnabled { NotificationFeedPreviewView() + } else if UITestConfig.whatsNewPreviewEnabled { + MobileWhatsNewPreviewView() } else if UITestConfig.workspaceListLayoutPreviewEnabled { WorkspaceListLayoutPreviewView() } else if let recoveryStress = MobileRecoveryStressConfiguration.parse(arguments: ProcessInfo.processInfo.arguments) { diff --git a/ios/cmuxUITests/cmuxUITests.swift b/ios/cmuxUITests/cmuxUITests.swift index ad39b7b5ada6..90b8f54014e0 100644 --- a/ios/cmuxUITests/cmuxUITests.swift +++ b/ios/cmuxUITests/cmuxUITests.swift @@ -11,6 +11,188 @@ final class cmuxUITests: XCTestCase { continueAfterFailure = false } + @MainActor + func testDeveloperSettingsReplaysWhatsNewRange() throws { + let app = launchApp( + mockData: false, + environment: ["CMUX_UITEST_WORKSPACE_LIST_PREVIEW": "1"] + ) + defer { app.terminate() } + let settings = app.buttons["MobileWorkspaceSettingsMenu"] + XCTAssertTrue(settings.waitForExistence(timeout: 8)) + tap(settings, in: app) + + let replayRow = app.buttons["MobileSettingsReplayWhatsNew"] + for _ in 0..<10 where !replayRow.exists || !replayRow.isHittable { + app.swipeUp(velocity: .slow) + } + XCTAssertTrue(replayRow.isHittable) + tap(replayRow, in: app) + let show = app.buttons["MobileWhatsNewReplayShow"] + XCTAssertTrue(show.waitForExistence(timeout: 5)) + + func capture(_ name: String) { + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } + + capture("Developer What's New range picker") + tap(show, in: app) + let continueButton = app.buttons["MobileWhatsNewContinue"] + XCTAssertTrue(continueButton.waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts["On this iPhone"].exists) + capture("Developer replay - pairing sheet") + app.staticTexts["On this iPhone"].swipeLeft() + XCTAssertTrue(app.staticTexts["Per-computer methods"].waitForExistence(timeout: 5)) + capture("Developer replay - older sheet after swipe") + tap(continueButton, in: app) + XCTAssertTrue(show.waitForExistence(timeout: 5)) + + let first = app.buttons["MobileWhatsNewReplayFirst"] + tap(first, in: app) + tap(app.buttons["connections.v1"].firstMatch, in: app) + tap(show, in: app) + XCTAssertTrue(app.staticTexts["Per-computer methods"].waitForExistence(timeout: 5)) + XCTAssertFalse(app.staticTexts["On this iPhone"].exists) + tap(continueButton, in: app) + XCTAssertTrue(show.waitForExistence(timeout: 5)) + tap(show, in: app) + XCTAssertTrue(app.staticTexts["Per-computer methods"].waitForExistence(timeout: 5)) + tap(continueButton, in: app) + XCTAssertTrue(show.waitForExistence(timeout: 5)) + } + + @MainActor + func testWhatsNewSheetFitsSwipedPageAndMatchesAppearance() throws { + let app = XCUIApplication() + defer { app.terminate() } + + for appearance in ["light", "dark"] { + app.launchArguments = ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + app.launchEnvironment = [ + "CMUX_UITEST_WHATS_NEW_PREVIEW": "1", + "CMUX_UITEST_WHATS_NEW_APPEARANCE": appearance + ] + app.launch() + let image = app.images.matching(NSPredicate( + format: "label == %@", + "cmux Mac Settings, Mobile section, showing Enable iOS pairing." + )).firstMatch + XCTAssertTrue(image.waitForExistence(timeout: 15)) + let sheet = app.collectionViews["MobileWhatsNewSheet"].firstMatch + let title = sheet.staticTexts["Action Required: Enable iOS pairing on your Mac"].firstMatch + + func assertFitted(_ lastText: XCUIElement) { + XCTAssertTrue(lastText.exists) + let gap = app.buttons["Continue"].frame.minY - lastText.frame.maxY + XCTAssertGreaterThan(gap, 0) + XCTAssertLessThan(gap, 100, "The footer must follow this page's content") + } + + let compatibilityDetail = app.staticTexts.matching(NSPredicate( + format: "label BEGINSWITH %@", "Use cmux 0.64.0 or later." + )).firstMatch + assertFitted(compatibilityDetail) + XCTAssertGreaterThanOrEqual(title.frame.minY - sheet.frame.minY, 28) + let pairingTop = title.frame.minY + let pixels = try XCTUnwrap(image.screenshot().image.cgImage) + var rgba = [UInt8](repeating: 0, count: 4) + let context = try XCTUnwrap(CGContext( + data: &rgba, width: 1, height: 1, bitsPerComponent: 8, bytesPerRow: 4, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )) + context.draw(pixels, in: CGRect(x: 0, y: 0, width: 1, height: 1)) + let brightness = Double(Int(rgba[0]) + Int(rgba[1]) + Int(rgba[2])) / (3 * 255) + if appearance == "light" { + XCTAssertGreaterThan(brightness, 0.65, "Use the light Mac Settings capture") + } else { + XCTAssertLessThan(brightness, 0.35, "Use the dark Mac Settings capture") + } + let before = XCTAttachment(screenshot: app.screenshot()) + before.name = "Fitted pairing page - \(appearance)" + before.lifetime = .keepAlways + add(before) + + image.swipeLeft() + let olderTitle = sheet.staticTexts["What's New in cmux"].firstMatch + XCTAssertTrue(olderTitle.waitForExistence(timeout: 5)) + let olderDetail = sheet.staticTexts.matching(NSPredicate( + format: "label BEGINSWITH %@", "Use cmux 0.64.0 or later." + )).firstMatch + assertFitted(olderDetail) + XCTAssertGreaterThan(abs(olderTitle.frame.minY - pairingTop), 20, "Swiping must resize the sheet") + let after = XCTAttachment(screenshot: app.screenshot()) + after.name = "Fitted connections page - \(appearance)" + after.lifetime = .keepAlways + add(after) + olderTitle.swipeRight() + XCTAssertTrue(image.waitForExistence(timeout: 5)) + assertFitted(compatibilityDetail) + XCTAssertEqual(title.frame.minY, pairingTop, accuracy: 2) + app.terminate() + } + try testWhatsNewSeparateUpdatesScreenshotCropAndLeadingAlignment() + } + + @MainActor + func testWhatsNewSeparateUpdatesScreenshotCropAndLeadingAlignment() throws { + let app = XCUIApplication() + app.launchArguments = ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + app.launchEnvironment = ["CMUX_UITEST_WHATS_NEW_PREVIEW": "1"] + app.launch() + defer { app.terminate() } + + func verifyPairingPage(_ name: String) throws { + let screenshot = app.images.matching(NSPredicate( + format: "label == %@", + "cmux Mac Settings, Mobile section, showing Enable iOS pairing." + )).firstMatch + XCTAssertTrue(screenshot.waitForExistence(timeout: 15)) + let title = app.staticTexts["On this iPhone"].firstMatch + let detail = app.staticTexts["Sign in to the same cmux account"].firstMatch + XCTAssertTrue(title.exists) + XCTAssertTrue(detail.exists) + XCTAssertEqual(title.frame.minX, screenshot.frame.minX, accuracy: 2) + XCTAssertEqual(detail.frame.minX, screenshot.frame.minX, accuracy: 2) + XCTAssertEqual(screenshot.frame.width / screenshot.frame.height, 642.0 / 95.0, accuracy: 0.05) + + let request = VNRecognizeTextRequest() + request.recognitionLevel = .accurate + request.recognitionLanguages = ["en-US"] + try VNImageRequestHandler(data: screenshot.screenshot().pngRepresentation).perform([request]) + let text = (request.results ?? []).compactMap { $0.topCandidates(1).first?.string }.joined(separator: " ") + XCTAssertTrue(text.contains("Mobile"), text) + XCTAssertTrue(text.contains("Enable iOS pairing"), text) + XCTAssertTrue(text.contains("for this Mac"), text) + XCTAssertFalse(text.contains("Notifications"), text) + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } + + try verifyPairingPage("Pairing update sheet") + app.buttons["Continue"].tap() + XCTAssertTrue(app.staticTexts["Per-computer methods"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts["Auto-Connect is now Iroh"].exists) + app.buttons["Continue"].tap() + let newer = app.buttons["MobileWhatsNewEntry-connections.v2"] + let older = app.buttons["MobileWhatsNewEntry-connections.v1"] + XCTAssertTrue(newer.waitForExistence(timeout: 5)) + XCTAssertTrue(older.exists) + newer.tap() + try verifyPairingPage("Pairing update in Settings") + let heading = app.staticTexts["Action Required: Enable iOS pairing on your Mac"].firstMatch + let request = VNRecognizeTextRequest() + request.recognitionLevel = .accurate + request.recognitionLanguages = ["en-US"] + try VNImageRequestHandler(data: heading.screenshot().pngRepresentation).perform([request]) + let headingText = (request.results ?? []).compactMap { $0.topCandidates(1).first?.string }.joined(separator: " ") + XCTAssertTrue(headingText.contains("on your Mac"), "The complete title must render: \(headingText)") + } + func testMockHostInstanceTagFollowsTargetBuildScope() { XCTAssertEqual( mockHostInstanceTag( diff --git a/web/data/whats-new.ts b/web/data/whats-new.ts index b1751db3e87b..402afbead450 100644 --- a/web/data/whats-new.ts +++ b/web/data/whats-new.ts @@ -80,13 +80,9 @@ export interface WhatsNewList { } export const whatsNewList: WhatsNewList = { - // Binary catalog ids the app may show. "connections.v1" ships in the iOS - // binary catalog, so only binaries that carry the page can render it; the - // list needs no extra version gating for binary pages. Remove an id here - // to hide its page remotely. With no `entryChannels` override, every id - // keeps its compiled-in audience — team lanes only — so none of this - // renders on the official App Store app. To show connections.v1 there: - // entryChannels: { "connections.v1": ["dev", "beta", "internal", "prod"] }. - visibleEntryIds: ["connections.v1"], + // One bespoke page now carries the Mac-side opt-in, the screenshot, the + // compatibility floors, and the connection notes. The earlier standalone + // pairing page is intentionally absent and can never be shown again. + visibleEntryIds: ["connections.v2", "connections.v1"], announcements: [], }; diff --git a/web/tests/whats-new-route.test.ts b/web/tests/whats-new-route.test.ts index 6328fa1aaae2..4b48adc314cc 100644 --- a/web/tests/whats-new-route.test.ts +++ b/web/tests/whats-new-route.test.ts @@ -14,7 +14,7 @@ const { GET, validateList } = await import("../app/api/whats-new/route"); */ describe("whats-new route channel targeting", () => { const base: WhatsNewList = { - visibleEntryIds: ["connections.v1"], + visibleEntryIds: ["connections.v2"], announcements: [], }; const announcement = { @@ -30,6 +30,7 @@ describe("whats-new route channel targeting", () => { expect(response.status).toBe(200); const payload = (await response.json()) as WhatsNewList; expect(payload).toEqual(whatsNewList); + expect(payload.visibleEntryIds).toEqual(["connections.v2", "connections.v1"]); // The rejection-driven contract: no checked-in entry or announcement may // silently target the official app; reaching "prod" must be a reviewed, // explicit channel list. If this assertion fails, someone opted content @@ -50,7 +51,7 @@ describe("whats-new route channel targeting", () => { test("accepts a valid per-entry channel override including prod", () => { const list: WhatsNewList = { ...base, - entryChannels: { "connections.v1": ["dev", "beta", "internal", "prod"] }, + entryChannels: { "connections.v2": ["dev", "beta", "internal", "prod"] }, }; expect(validateList(list)).toEqual(list); }); @@ -66,17 +67,17 @@ describe("whats-new route channel targeting", () => { test("rejects an entryChannels key that is not a visible entry", () => { const list = { ...base, - entryChannels: { "connections.v2": ["beta"] }, + entryChannels: { "connections.v1": ["beta"] }, } as WhatsNewList; expect(() => validateList(list)).toThrow( - "entryChannels key connections.v2 is not in visibleEntryIds", + "entryChannels key connections.v1 is not in visibleEntryIds", ); }); test("rejects unknown channel tokens (they fail closed on device)", () => { const list = { ...base, - entryChannels: { "connections.v1": ["official"] }, + entryChannels: { "connections.v2": ["official"] }, } as unknown as WhatsNewList; expect(() => validateList(list)).toThrow(/must be one of dev, beta, internal, demo, prod/); }); @@ -84,10 +85,10 @@ describe("whats-new route channel targeting", () => { test("rejects an empty channel list (hidden everywhere is a retraction, not targeting)", () => { const list: WhatsNewList = { ...base, - entryChannels: { "connections.v1": [] }, + entryChannels: { "connections.v2": [] }, }; expect(() => validateList(list)).toThrow( - "entryChannels[connections.v1] must list at least one channel", + "entryChannels[connections.v2] must list at least one channel", ); const announcementList: WhatsNewList = { ...base, @@ -101,10 +102,10 @@ describe("whats-new route channel targeting", () => { test("rejects duplicate channels", () => { const list: WhatsNewList = { ...base, - entryChannels: { "connections.v1": ["beta", "beta"] }, + entryChannels: { "connections.v2": ["beta", "beta"] }, }; expect(() => validateList(list)).toThrow( - "entryChannels[connections.v1] contains duplicate channel beta", + "entryChannels[connections.v2] contains duplicate channel beta", ); }); diff --git a/workers/presence/src/controlPlane.ts b/workers/presence/src/controlPlane.ts index 447cf3ae435e..249169499fbd 100644 --- a/workers/presence/src/controlPlane.ts +++ b/workers/presence/src/controlPlane.ts @@ -999,8 +999,12 @@ export class ControlPlaneCore { // receive directory revisions and each client can tear down the other's // session while trying to recover. for (const candidate of this.deps.sockets()) { - if (candidate === socket) continue; const candidateAttachment = candidate.getAttachment(); + // The Cloudflare adapter recreates the transport wrapper while + // enumerating hibernating sockets, so wrapper identity is not stable. + // The session id is assigned at accept time and is the stable identity + // for this connection. + if (candidateAttachment?.sessionId === attachment.sessionId) continue; if (!candidateAttachment?.helloed || candidateAttachment.endpointId !== payload.endpointId) continue; try { diff --git a/workers/presence/test/controlPlaneListAuth.test.ts b/workers/presence/test/controlPlaneListAuth.test.ts index 46853e6db0d5..8d92b5d496e5 100644 --- a/workers/presence/test/controlPlaneListAuth.test.ts +++ b/workers/presence/test/controlPlaneListAuth.test.ts @@ -128,6 +128,29 @@ class FakeSocket implements CtlSocket { } } +/// The Durable Object adapter creates a fresh transport wrapper whenever the +/// core enumerates hibernating sockets. Keep the test harness honest about that +/// ownership boundary instead of making wrapper identity accidentally stable. +class FreshSocketView implements CtlSocket { + constructor(private readonly base: FakeSocket) {} + + send(data: string): void { + this.base.send(data); + } + + close(code?: number, reason?: string): void { + this.base.close(code, reason); + } + + getAttachment(): CtlAttachment | null { + return this.base.getAttachment(); + } + + setAttachment(attachment: CtlAttachment): void { + this.base.setAttachment(attachment); + } +} + type UpstreamHandler = (init: CtlUpstreamInit) => CtlUpstreamResult; class Harness { @@ -162,7 +185,7 @@ class Harness { scheduleAlarmAt: async (atMs) => { this.alarms.push(atMs); }, - sockets: () => [...this.socketList], + sockets: () => this.socketList.map((socket) => new FreshSocketView(socket)), }); serveDiscovery(response: () => unknown): void { @@ -232,6 +255,17 @@ describe("listv2 seeded overlay", () => { }); describe("confirm-on-hello", () => { + it("keeps the current socket when the adapter returns fresh socket wrappers", async () => { + const harness = new Harness(); + harness.serveDiscovery(() => discoveryResponse(42)); + + const socket = await harness.connect("s1"); + await harness.hello(socket, { endpointId: ENDPOINT_A, haveRev: null, wantPasses: false }); + + expect(socket.closes).toEqual([]); + expect(socket.types()).toEqual(["hello_ack", "directory", "snapshot_complete"]); + }); + it("flips seeded -> active, records version/track/capabilities, bumps rev, and broadcasts", async () => { const harness = new Harness(); harness.serveDiscovery(() => discoveryResponse(42));