diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/WorkspaceMacSelection.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/WorkspaceMacSelection.swift new file mode 100644 index 000000000000..7bc4dea816c6 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/WorkspaceMacSelection.swift @@ -0,0 +1,31 @@ +/// The computer scope retained between workspace-screen launches. +public enum WorkspaceMacSelection: Hashable, RawRepresentable, Sendable { + public static let storageKey = "cmux.workspaces.macSelection" + + case automatic + case all + /// A pairing id for saved app instances, or a bare device id for an + /// unpaired workspace-only computer. + case machine(String) + + /// Decodes a stored scope without normalizing the computer's exact identity. + public init?(rawValue: String) { + switch rawValue { + case "automatic": self = .automatic + case "all": self = .all + default: + guard rawValue.hasPrefix("machine:") else { return nil } + let id = String(rawValue.dropFirst("machine:".count)) + guard !id.isEmpty else { return nil } + self = .machine(id) + } + } + + public var rawValue: String { + switch self { + case .automatic: "automatic" + case .all: "all" + case .machine(let id): "machine:\(id)" + } + } +} diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/WorkspaceMacSelectionTests.swift b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/WorkspaceMacSelectionTests.swift new file mode 100644 index 000000000000..b943ce6bbea4 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/WorkspaceMacSelectionTests.swift @@ -0,0 +1,23 @@ +import CmuxMobileShellModel +import Testing + +struct WorkspaceMacSelectionTests { + @Test(arguments: [ + (WorkspaceMacSelection.automatic, "automatic"), + (.all, "all"), + (.machine("mac-a"), "machine:mac-a"), + (.machine("mac-a\u{1F}nightly"), "machine:mac-a\u{1F}nightly"), + ]) + func storedFormatPreservesScopeAndExactIdentity( + selection: WorkspaceMacSelection, + storedValue: String + ) { + #expect(selection.rawValue == storedValue) + #expect(WorkspaceMacSelection(rawValue: storedValue) == selection) + } + + @Test(arguments: ["", "unknown", "machine:"]) + func rejectsInvalidStoredSelection(storedValue: String) { + #expect(WorkspaceMacSelection(rawValue: storedValue) == nil) + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/ComputerPickerPersistencePreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/ComputerPickerPersistencePreviewView.swift new file mode 100644 index 000000000000..9e4f794c3277 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/ComputerPickerPersistencePreviewView.swift @@ -0,0 +1,56 @@ +#if os(iOS) && DEBUG +import CmuxMobileBrowser +import CmuxMobileBrowserStream +import CmuxMobileShell +import CmuxMobileShellModel +import SwiftUI + +/// Exercises the production shell's picker and preferences with fixed Mac snapshots. +/// Selection is never seeded: relaunches read the same preferences as the real app. +public struct ComputerPickerPersistencePreviewView: View { + @State private var store = CMUXMobileShellStore( + isSignedIn: true, + connectionState: .disconnected, + workspaces: [ + MobileWorkspacePreview( + id: "workspace-main", + macDeviceID: "picker-mac", + macDisplayName: "MacBook Pro", + name: "cmux", + terminals: [] + ), + MobileWorkspacePreview( + id: "workspace-other", + macDeviceID: "picker-studio", + macDisplayName: "Mac Studio", + name: "Docs", + terminals: [] + ), + ] + ) + private let browserStore = BrowserSurfaceStore() + private let browserStreamStore = BrowserStreamStore() + private let simulatorStreamStore = MobileSimulatorStreamStore() + + /// Creates an isolated source of computer snapshots without network discovery. + public init() {} + + /// Renders the same picker, filtering, and preference owner as the app. + public var body: some View { + WorkspaceShellView( + store: store, + signOut: {}, + isInitialConnectionLoading: false, + initialConnectionTimedOut: false, + retryInitialConnection: nil, + showAddDevice: nil, + showPairingScanner: nil, + showSettings: {}, + showComputers: {} + ) + .environment(browserStore) + .environment(browserStreamStore) + .environment(simulatorStreamStore) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView+MacSelection.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView+MacSelection.swift index 69f0c1351051..fbbfe5df8d8e 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView+MacSelection.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView+MacSelection.swift @@ -5,14 +5,6 @@ import CmuxMobileShellModel import CmuxMobileSupport import SwiftUI -enum WorkspaceMacSelection: Hashable { - case automatic - case all - /// A pairing id for saved app instances, or a bare device id for an - /// unpaired workspace-only computer. - case machine(String) -} - extension WorkspaceListView { var displayPairedMacsForPicker: [MobilePairedMac] { if let store { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceMacTitlePickerActions.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceMacTitlePickerActions.swift index eff0b0f1d228..4b505b521dbb 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceMacTitlePickerActions.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceMacTitlePickerActions.swift @@ -1,3 +1,5 @@ +import CmuxMobileShellModel + struct WorkspaceMacTitlePickerActions { let select: (WorkspaceMacSelection) -> Void let addDevice: (() -> Void)? diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceMacTitlePickerValue.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceMacTitlePickerValue.swift index d2db14e353c8..9c7c5d8337bb 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceMacTitlePickerValue.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceMacTitlePickerValue.swift @@ -1,3 +1,4 @@ +import CmuxMobileShellModel import CoreGraphics struct WorkspaceMacTitlePickerValue: Equatable { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift index b19f64f2d734..2b0067d1f19d 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift @@ -273,7 +273,7 @@ struct WorkspaceShellView: View { /// sidebar it actually renders in, not the full screen. @State private var splitSidebarWidth: CGFloat = 0 #endif - @State private var macSelection: WorkspaceMacSelection = .all + @AppStorage(WorkspaceMacSelection.storageKey) private var macSelection: WorkspaceMacSelection = .all /// Legacy fallback while the toast presenter is disabled: the old /// dismissible bottom banner for workspace-action failures. @State var workspaceActionToast: WorkspaceActionToastContent? diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceMacSelectionPairedMacStore.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceMacSelectionPairedMacStore.swift index 614f9c4c4518..a0c61dc6f256 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceMacSelectionPairedMacStore.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceMacSelectionPairedMacStore.swift @@ -1,3 +1,4 @@ +import CmuxMobileShellModel import CMUXMobileCore import CmuxMobilePairedMac import Foundation diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceMacSelectionPersistenceTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceMacSelectionPersistenceTests.swift new file mode 100644 index 000000000000..3ccfc4ba6e44 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceMacSelectionPersistenceTests.swift @@ -0,0 +1,94 @@ +import CmuxMobileShellModel +import CmuxMobilePairedMac +import Foundation +import SwiftUI +import Testing +@testable import CmuxMobileShellUI + +@MainActor +struct WorkspaceMacSelectionPersistenceTests { + @Test(arguments: [ + WorkspaceMacSelection.all, + .automatic, + .machine("mac-a"), + .machine(MobilePairedMac.pairingID(macDeviceID: "mac-a", instanceTag: "nightly")), + ]) + func restoresSelectionFromFreshPreferences(selection: WorkspaceMacSelection) throws { + let suite = "WorkspaceMacSelectionPersistenceTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let storage = AppStorage( + wrappedValue: WorkspaceMacSelection.all, + WorkspaceMacSelection.storageKey, + store: defaults + ) + storage.wrappedValue = selection + + let restored = AppStorage( + wrappedValue: WorkspaceMacSelection.all, + WorkspaceMacSelection.storageKey, + store: try #require(UserDefaults(suiteName: suite)) + ) + #expect(restored.wrappedValue == selection) + } + + @Test func allComputersReplacesPreviousMachineSelection() throws { + let suite = "WorkspaceMacSelectionPersistenceTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let storage = AppStorage( + wrappedValue: WorkspaceMacSelection.all, + WorkspaceMacSelection.storageKey, + store: defaults + ) + storage.wrappedValue = .machine("mac-a") + storage.wrappedValue = .all + + let restored = AppStorage( + wrappedValue: WorkspaceMacSelection.all, + WorkspaceMacSelection.storageKey, + store: try #require(UserDefaults(suiteName: suite)) + ) + #expect(restored.wrappedValue == .all) + } + + @Test(arguments: [nil, "", "unknown", "machine:"] as [String?]) + func missingOrInvalidPreferenceDefaultsToAllComputers(rawValue: String?) throws { + let suite = "WorkspaceMacSelectionPersistenceTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(rawValue, forKey: WorkspaceMacSelection.storageKey) + + let storage = AppStorage( + wrappedValue: WorkspaceMacSelection.all, + WorkspaceMacSelection.storageKey, + store: defaults + ) + #expect(storage.wrappedValue == .all) + } + + @Test func discoveryDoesNotDiscardRememberedSelection() throws { + let suite = "WorkspaceMacSelectionPersistenceTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let storage = AppStorage( + wrappedValue: WorkspaceMacSelection.all, + WorkspaceMacSelection.storageKey, + store: defaults + ) + storage.wrappedValue = .machine("mac-a") + + func scope(foregroundMacDeviceID: String?) -> WorkspaceMacSelectionScope { + WorkspaceMacSelectionScope( + selection: storage.wrappedValue, + workspaces: [], + displayPairedMacs: [], + foregroundMacDeviceID: foregroundMacDeviceID, + aliasesFor: { _ in [] } + ) + } + #expect(scope(foregroundMacDeviceID: nil).visibleSelection == .all) + #expect(storage.wrappedValue == .machine("mac-a")) + #expect(scope(foregroundMacDeviceID: "mac-a").visibleSelection == .machine("mac-a")) + } +} diff --git a/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift b/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift index d49c9f1cdaca..67ddf8339490 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift @@ -387,7 +387,9 @@ public struct CMUXMobileRootScene: View { private var content: some View { #if os(iOS) #if DEBUG - if UITestConfig.taskComposerPreviewEnabled { + if ProcessInfo.processInfo.environment["CMUX_UITEST_COMPUTER_PICKER_PERSISTENCE"] == "1" { + ComputerPickerPersistencePreviewView() + } else if UITestConfig.taskComposerPreviewEnabled { TaskComposerAccessibilityPreviewView() } else if UITestConfig.pushTabNavigationPreviewEnabled { PushTabNavigationPreviewView() diff --git a/ios/cmuxUITests/cmuxUITests.swift b/ios/cmuxUITests/cmuxUITests.swift index 3dd6017f8962..97eab77a54e4 100644 --- a/ios/cmuxUITests/cmuxUITests.swift +++ b/ios/cmuxUITests/cmuxUITests.swift @@ -1909,6 +1909,107 @@ final class cmuxUITests: XCTestCase { XCTAssertTrue(app.staticTexts["refreshPreservedEmptyList=true"].exists) } + @MainActor + func testComputerPickerSelectionSurvivesAppRelaunch() async throws { + let app = launchApp(mockData: false, environment: [ + "CMUX_UITEST_COMPUTER_PICKER_PERSISTENCE": "1", + ]) + defer { app.terminate() } + + func picker() throws -> XCUIElement { + let whatsNewSheet = app.collectionViews["MobileWhatsNewSheet"].firstMatch + if whatsNewSheet.waitForExistence(timeout: 4) { + // The sheet identifier is inherited by its footer on iOS 26. + // Finish every page rather than tapping the obscured toolbar. + let continueButton = app.buttons.matching( + NSPredicate(format: "label == %@", "Continue") + ).firstMatch + for _ in 0..<4 where whatsNewSheet.exists { + _ = try XCTUnwrap( + continueButton.waitForExistence(timeout: 4) ? continueButton : nil + ) + continueButton.tap() + } + _ = try XCTUnwrap( + whatsNewSheet.waitForNonExistence(timeout: 5) ? true : nil, + "Finish the launch sheet before using the picker behind it" + ) + } + let picker = app.buttons["MobileWorkspaceMacPicker"] + return try XCTUnwrap( + picker.waitForExistence(timeout: 15) ? picker : nil, + "The production computer picker must appear before interacting" + ) + } + + func expectTitle(_ title: String) throws { + let control = try picker() + let restored = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "label == %@", title), + object: control + ) + _ = try XCTUnwrap( + XCTWaiter.wait(for: [restored], timeout: 15) == .completed ? control : nil, + "Expected picker title \(title), got \(control.label)" + ) + } + + func capture(_ name: String) { + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } + + func openPicker() throws { + let control = try picker() + control.tap() + } + + // Start through the real picker, without seeding its saved preference. + try openPicker() + let allComputers = try XCTUnwrap(waitForVisibleElement( + identifier: "MobileWorkspaceMacPickerAll", in: app, timeout: 5 + )) + tapMenuItem(allComputers, in: app) + try expectTitle("All Computers") + try openPicker() + let computer = app.buttons.matching(NSPredicate( + format: "identifier BEGINSWITH %@", + "MobileWorkspaceMacPickerMachine-picker-mac" + )).firstMatch + _ = try XCTUnwrap( + computer.waitForExistence(timeout: 5) ? computer : nil, + "The computer menu must be open before selecting its Mac" + ) + let computerName = computer.label + XCTAssertNotEqual(computerName, "All Computers") + tapMenuItem(computer, in: app) + try expectTitle(computerName) + XCTAssertTrue(app.buttons["MobileWorkspaceRow-workspace-main"].exists) + XCTAssertFalse(app.buttons["MobileWorkspaceRow-workspace-other"].exists) + capture("computer-selected-before-termination") + + app.terminate() + app.launch() + try expectTitle(computerName) + XCTAssertTrue(app.buttons["MobileWorkspaceRow-workspace-main"].exists) + XCTAssertFalse(app.buttons["MobileWorkspaceRow-workspace-other"].exists) + capture("computer-restored-after-relaunch") + + try openPicker() + tapMenuItem(app.buttons["MobileWorkspaceMacPickerAll"], in: app) + try expectTitle("All Computers") + capture("all-computers-selected-before-termination") + + app.terminate() + app.launch() + try expectTitle("All Computers") + XCTAssertTrue(app.buttons["MobileWorkspaceRow-workspace-main"].exists) + XCTAssertTrue(app.buttons["MobileWorkspaceRow-workspace-other"].exists) + capture("all-computers-restored-after-relaunch") + } + @MainActor func testWorkspaceMacPickerUsesComputerCopyAndAnnouncesConnectionStatus() throws { let app = launchApp(mockData: false, environment: [