Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)"
}
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import CmuxMobileShellModel

struct WorkspaceMacTitlePickerActions {
let select: (WorkspaceMacSelection) -> Void
let addDevice: (() -> Void)?
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import CmuxMobileShellModel
import CoreGraphics

struct WorkspaceMacTitlePickerValue: Equatable {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import CmuxMobileShellModel
import CMUXMobileCore
import CmuxMobilePairedMac
import Foundation
Expand Down
Original file line number Diff line number Diff line change
@@ -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"))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
101 changes: 101 additions & 0 deletions ios/cmuxUITests/cmuxUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
Loading