Skip to content
Open
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
24a3c08
Improve Simulator discoverability
azooz2003-bit Aug 10, 2026
445870a
Merge remote-tracking branch 'origin/main' into task-improve-simulato…
azooz2003-bit Aug 10, 2026
901915e
Fix Simulator button accessibility label
azooz2003-bit Aug 10, 2026
290955b
Address simulator discoverability review findings
azooz2003-bit Aug 10, 2026
2cec093
Fix simulator picker getter return
azooz2003-bit Aug 10, 2026
7a84690
Unify simulator stream state ownership
azooz2003-bit Aug 10, 2026
9e77288
Stabilize simulator stream environment lifetime
azooz2003-bit Aug 10, 2026
0efe264
Own mobile terminal runtime before SwiftUI mounting
azooz2003-bit Aug 10, 2026
db6f334
Expose borrowed mobile session view
azooz2003-bit Aug 10, 2026
62fc0aa
Allow process-owned terminal runtime retry
azooz2003-bit Aug 10, 2026
4968ed8
Import terminal theme in renderer recovery view
azooz2003-bit Aug 10, 2026
282d6aa
Load iOS Ghostty config from owned path
azooz2003-bit Aug 10, 2026
e356c7f
Resolve renderer recovery strings from package
azooz2003-bit Aug 10, 2026
9b65abd
Add renderer recovery preview fixture
azooz2003-bit Aug 10, 2026
24c437f
Stabilize Simulator discoverability UI tests
azooz2003-bit Aug 10, 2026
7144595
Instrument Simulator selection lifecycle
azooz2003-bit Aug 10, 2026
ba45abe
Expose Simulator lifecycle diagnostic timeline
azooz2003-bit Aug 10, 2026
c2fc815
Fix Simulator discoverability UI assertions
azooz2003-bit Aug 10, 2026
3a60ad2
Avoid quadratic simulator picker snapshots
azooz2003-bit Aug 10, 2026
a4fb70f
Test macOS tab bar omits Simulator button
azooz2003-bit Aug 10, 2026
48c2c7c
Remove Simulator from macOS tab defaults
azooz2003-bit Aug 10, 2026
3d83481
Test crowded tab bar keeps default actions visible
azooz2003-bit Aug 11, 2026
f7097c9
Remove unrelated crowded tab bar test
azooz2003-bit Aug 11, 2026
ee2df25
Test Simulator toolbar returns from active panel
azooz2003-bit Aug 11, 2026
08cc2f7
Add Simulator tab navigation on iOS
azooz2003-bit Aug 11, 2026
9eedc37
Merge remote-tracking branch 'origin/main' into task-improve-simulato…
azooz2003-bit Aug 11, 2026
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
16 changes: 16 additions & 0 deletions Packages/iOS/CmuxMobileShell/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ let package = Package(
name: "CmuxMobileShellReleaseGateSupport",
targets: ["CmuxMobileShellReleaseGateSupport"]
),
.library(
name: "CmuxMobileShellDebugSupport",
targets: ["CmuxMobileShellDebugSupport"]
),
],
dependencies: [
.package(path: "../../Shared/CMUXMobileCore"),
Expand Down Expand Up @@ -66,6 +70,18 @@ let package = Package(
.enableUpcomingFeature("InternalImportsByDefault"),
]
),
.target(
name: "CmuxMobileShellDebugSupport",
dependencies: [
"CmuxMobileShell",
"CMUXMobileCore",
],
swiftSettings: [
.swiftLanguageMode(.v6),
.enableUpcomingFeature("ExistentialAny"),
.enableUpcomingFeature("InternalImportsByDefault"),
]
),
.testTarget(
name: "CmuxMobileShellTests",
dependencies: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ extension MobileShellComposite {
}

func currentSimulatorOwnership(panelID: String) -> DiagnosticSimulatorOwnershipState {
guard let state = simulatorStreamStore?.state(for: panelID) else { return .unknown }
guard let state = simulatorStreamStore.state(for: panelID) else { return .unknown }
return MobileSimulatorStreamStore.diagnosticOwnershipState(
ownerConnectionID: state.ownerConnectionID,
isOwnedByCurrentConnection: state.isOwnedByCurrentConnection
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,67 @@ import Foundation

@MainActor
extension MobileShellComposite {
/// Applies the user's selection to the composite-owned presentation state,
/// then reconciles the matching host RPC transition through the same owner.
public func selectMobileSimulatorStream(panelID: String, workspaceID: String) {
guard simulatorStreamStore.state(for: panelID) != nil else { return }
let previousPanelID = simulatorStreamStore.activeState(in: workspaceID).flatMap {
$0.id == panelID ? nil : $0.id
}
if let previousPanelID {
simulatorStreamStore.deactivate(panelID: previousPanelID, in: workspaceID)
}
simulatorStreamStore.activate(panelID: panelID, in: workspaceID)
transitionMobileSimulatorStreamSelection(
from: previousPanelID,
to: panelID,
workspaceID: workspaceID
)
}

/// Clears the active local Simulator surface and reconciles its host stop.
public func clearMobileSimulatorStreamSelection(workspaceID: String) {
guard let active = simulatorStreamStore.activeState(in: workspaceID) else { return }
simulatorStreamStore.deactivate(in: workspaceID)
transitionMobileSimulatorStreamSelection(
from: active.id,
to: nil,
workspaceID: workspaceID
)
}

/// Reconciles a workspace's selected Simulator stream through one
/// composite-owned latest-intent drain.
public func transitionMobileSimulatorStreamSelection(
from previousPanelID: String?,
to targetPanelID: String?,
workspaceID: String
) {
if mobileSimulatorStreamSelectionCoordinator == nil {
mobileSimulatorStreamSelectionCoordinator =
MobileSimulatorStreamSelectionCoordinator { [weak self] operation in
guard let self else { return }
switch operation {
case .start(let panelID, let workspaceID):
await self.startMobileSimulatorStream(
panelID: panelID,
workspaceID: workspaceID
)
case .stop(let panelID, let workspaceID):
await self.stopMobileSimulatorStream(
panelID: panelID,
workspaceID: workspaceID
)
}
}
}
mobileSimulatorStreamSelectionCoordinator?.requestTransition(
from: previousPanelID,
to: targetPanelID,
workspaceID: workspaceID
)
}

/// Serializes start/stop transitions per panel through the composite-owned
/// operation chain, so a foreground restart cannot overlap a still-running
/// background stop against the Mac's single-controller ownership.
Expand Down Expand Up @@ -45,7 +106,7 @@ extension MobileShellComposite {
)
return
}
simulatorStreamStore?.simulatorStreamWillStart(panelID: panelID)
simulatorStreamStore.simulatorStreamWillStart(panelID: panelID)
do {
let descriptor = try await client.startMobileSimulatorStream(
panelID: panelID,
Expand All @@ -62,7 +123,7 @@ extension MobileShellComposite {
return
}
startedMobileSimulatorPanelIDs.insert(panelID)
simulatorStreamStore?.simulatorStreamDidStart(descriptor)
simulatorStreamStore.simulatorStreamDidStart(descriptor)
armSimulatorStreamStalenessWatchdog(panelID: panelID)
recordSimulatorStream(
panelID: panelID,
Expand All @@ -71,7 +132,7 @@ extension MobileShellComposite {
activeSessions: startedMobileSimulatorPanelIDs.count
)
} catch MobileShellConnectionError.rpcError(let code, _) where code == "locked" {
simulatorStreamStore?.state(for: panelID)?.markLockedByOtherConnection()
simulatorStreamStore.state(for: panelID)?.markLockedByOtherConnection()
recordSimulatorStream(panelID: panelID, state: .locked, ownership: .otherConnection)
} catch {
settleFailedMobileSimulatorStreamStart(panelID: panelID)
Expand All @@ -89,7 +150,7 @@ extension MobileShellComposite {
/// Per-panel serialization guarantees at most one start attempt is in
/// flight, so a stale response can never settle a newer attempt.
private func settleFailedMobileSimulatorStreamStart(panelID: String) {
guard let state = simulatorStreamStore?.state(for: panelID),
guard let state = simulatorStreamStore.state(for: panelID),
state.streamStatus == .starting else { return }
state.streamStatus = .idle
}
Expand Down Expand Up @@ -148,6 +209,7 @@ extension MobileShellComposite {
/// staleness watchdogs disarm with them: once disconnected, the connection
/// layer owns the pane's truth (reconnecting/disconnected overlays).
func cancelMobileSimulatorStreamOperations() {
mobileSimulatorStreamSelectionCoordinator?.cancel()
for task in mobileSimulatorStreamOperationsByPanel.values {
task.cancel()
}
Expand Down Expand Up @@ -176,7 +238,7 @@ extension MobileShellComposite {
return
}
guard connectionState == .connected else { return }
guard let state = simulatorStreamStore?.state(for: panelID) else { return }
guard let state = simulatorStreamStore.state(for: panelID) else { return }
state.markStreamStale()
recordSimulatorStream(
panelID: panelID,
Expand Down Expand Up @@ -254,7 +316,7 @@ extension MobileShellComposite {

func handleMobileSimulatorFrameEvent(_ event: MobileEventEnvelope) {
guard let payload = event.payloadJSON else { return }
switch simulatorStreamStore?.receiveSimulatorFramePayload(payload) {
switch simulatorStreamStore.receiveSimulatorFramePayload(payload) {
case .received(let panelID, let sequence, let payloadBytes):
simulatorStreamStalenessMonitor.recordActivity(panelID: panelID)
recordSimulatorFrame(panelID: panelID, state: .received, sequence: sequence, payloadBytes: payloadBytes)
Expand All @@ -266,14 +328,12 @@ extension MobileShellComposite {
recordSimulatorFrame(panelID: "", state: .decodeFailed, payloadBytes: payloadBytes)
case .unknownPanel(let panelID, let sequence, let payloadBytes):
recordSimulatorFrame(panelID: panelID, state: .unknownPanel, sequence: sequence, payloadBytes: payloadBytes)
case nil:
break
}
}

func handleMobileSimulatorStateEvent(_ event: MobileEventEnvelope) {
guard let payload = event.payloadJSON else { return }
switch simulatorStreamStore?.receiveSimulatorStatePayload(payload) {
switch simulatorStreamStore.receiveSimulatorStatePayload(payload) {
case .unchanged(let panelID):
// Keepalive re-emission: feeds the staleness watchdog, records
// no diagnostic (a healthy session would flood one every 5s).
Expand All @@ -290,14 +350,12 @@ extension MobileShellComposite {
}
case .decodeFailed(let payloadBytes):
recordSimulatorFrame(panelID: "", state: .decodeFailed, payloadBytes: payloadBytes)
case nil:
break
}
}

func handleMobileSimulatorClosedEvent(_ event: MobileEventEnvelope) {
guard let payload = event.payloadJSON else { return }
if let panelID = simulatorStreamStore?.receiveSimulatorClosedPayload(payload) {
if let panelID = simulatorStreamStore.receiveSimulatorClosedPayload(payload) {
startedMobileSimulatorPanelIDs.remove(panelID)
simulatorStreamStalenessMonitor.disarm(panelID: panelID)
recordSimulatorStream(
Expand All @@ -311,7 +369,7 @@ extension MobileShellComposite {

func restartActiveMobileSimulatorStreams() {
guard connectionState == .connected, supportsSimulatorStream else { return }
let selections = simulatorStreamStore?.activeSimulatorStreamSelections() ?? []
let selections = simulatorStreamStore.activeSimulatorStreamSelections()
for selection in selections {
recordSimulatorStream(
panelID: selection.panelID,
Expand All @@ -332,8 +390,8 @@ extension MobileShellComposite {
}

func stopActiveMobileSimulatorStreamsForBackground() {
let selections = simulatorStreamStore?.activeSimulatorStreamSelections() ?? []
simulatorStreamStore?.pauseSimulatorStreams()
let selections = simulatorStreamStore.activeSimulatorStreamSelections()
simulatorStreamStore.pauseSimulatorStreams()
for selection in selections {
recordSimulatorStream(
panelID: selection.panelID,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
if connectionState == .connected {
restartTerminalLanesForMountedSurfaces()
browserStreamEvents?.setBrowserStreamConnectionStatus(.connected)
simulatorStreamStore?.setSimulatorStreamConnectionStatus(.connected)
simulatorStreamStore.setSimulatorStreamConnectionStatus(.connected)
restartActiveMobileBrowserStreams()
restartActiveMobileSimulatorStreams()
scheduleWorkspaceChangesSummaryRefresh()
Expand All @@ -205,7 +205,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
browserStreamEvents?.setBrowserStreamConnectionStatus(
macConnectionStatus == .reconnecting ? .reconnecting : .disconnected
)
simulatorStreamStore?.setSimulatorStreamConnectionStatus(
simulatorStreamStore.setSimulatorStreamConnectionStatus(
macConnectionStatus == .reconnecting ? .reconnecting : .disconnected
)
resetWorkspaceChangesState()
Expand Down Expand Up @@ -467,7 +467,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
/// The connected Mac's `mobile.host.status` capabilities. Feature gates are
/// computed from this set so version-skew checks cannot drift from the raw
/// host payload.
public internal(set) var supportedHostCapabilities: Set<String> = [] {
public package(set) var supportedHostCapabilities: Set<String> = [] {
didSet {
guard oldValue != supportedHostCapabilities else { return }
if workspaceChangesCapable {
Expand Down Expand Up @@ -508,8 +508,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {

/// Separate app-lifetime browser event sink; never stored in workspace preview state.
@ObservationIgnored let browserStreamEvents: (any BrowserStreamEventReceiving)?
/// Separate app-lifetime simulator stream state; never stored in workspace preview state.
@ObservationIgnored let simulatorStreamStore: MobileSimulatorStreamStore?
/// Sole owner of Simulator panel selection and lifecycle state. SwiftUI
/// injects this exact instance so local presentation and RPC callbacks
/// cannot drift onto separate stores.
@ObservationIgnored public let simulatorStreamStore: MobileSimulatorStreamStore
@ObservationIgnored let mobileBrowserStreamLifecycle = MobileBrowserStreamLifecycleCoordinator()
@ObservationIgnored var startedMobileBrowserPanelIDs: Set<String> = []
@ObservationIgnored var startedMobileSimulatorPanelIDs: Set<String> = []
Expand All @@ -518,6 +520,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
/// restart can never interleave against the Mac's single-controller
/// ownership. Entries self-remove when their chain drains.
@ObservationIgnored var mobileSimulatorStreamOperationsByPanel: [String: Task<Void, Never>] = [:]
/// One caller-owned drain for cross-panel selection transitions. Unlike
/// the per-panel operation chains, this prevents an older panel start from
/// overtaking a newer selection on another panel.
@ObservationIgnored var mobileSimulatorStreamSelectionCoordinator:
MobileSimulatorStreamSelectionCoordinator?
/// Clock behind the simulator stream staleness watchdog; injectable so
/// tests drive the threshold deterministically.
@ObservationIgnored let simulatorStreamStalenessClock: any Clock<Duration>
Expand Down Expand Up @@ -1497,7 +1504,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
terminalInputAckResubscribeClock: any Clock<Duration> = ContinuousClock(),
taskTemplateStore: (any MobileTaskTemplateStoring)? = nil,
browserStreamEvents: (any BrowserStreamEventReceiving)? = nil,
simulatorStreamStore: MobileSimulatorStreamStore? = nil,
simulatorStreamStore: MobileSimulatorStreamStore = MobileSimulatorStreamStore(),
simulatorStreamStalenessClock: any Clock<Duration> = ContinuousClock(),
storedMacReconnectRestoringDeadlineSeconds: Double = 15
) {
Expand Down Expand Up @@ -1663,6 +1670,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
}

isolated deinit {
mobileSimulatorStreamSelectionCoordinator?.cancel()
connectionRecoveryOwner.cancel()
automaticReconnectRetryTask?.cancel()
presenceTask?.cancel()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import Foundation

/// Serializes workspace Simulator selection intents so stream RPCs never overlap.
@MainActor
final class MobileSimulatorStreamSelectionCoordinator {
enum Operation: Equatable {
case start(panelID: String, workspaceID: String)
case stop(panelID: String, workspaceID: String)
}

private struct Selection: Equatable {
let panelID: String
let workspaceID: String
}

private struct Intent {
let target: Selection?
}

typealias PerformOperation = @MainActor (Operation) async -> Void

private let performOperation: PerformOperation
private var pendingIntent: Intent?
private var activeSelection: Selection?
private(set) var transitionTask: Task<Void, Never>?

init(performOperation: @escaping PerformOperation) {
self.performOperation = performOperation
}

func requestTransition(
from previousPanelID: String?,
to targetPanelID: String?,
workspaceID: String
) {
let previous = previousPanelID.map { Selection(panelID: $0, workspaceID: workspaceID) }
let target = targetPanelID.map { Selection(panelID: $0, workspaceID: workspaceID) }
if activeSelection == nil {
activeSelection = previous
}
pendingIntent = Intent(target: target)
startDrainIfNeeded()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coordinator breaks multi-workspace streams

High Severity

MobileSimulatorStreamSelectionCoordinator tracks one global activeSelection, while the store and restartActiveMobileSimulatorStreams keep per-workspace actives. After a selection in another workspace—or a clear whose from does not match the coordinator—the drain can stop the wrong panel. cancel also leaves activeSelection stale across disconnect/reconnect, so later UI actions diverge from the store.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9eedc37. Configure here.


func cancel() {
pendingIntent = nil
transitionTask?.cancel()
}
Comment thread
cursor[bot] marked this conversation as resolved.

func waitForIdle() async {
while let task = transitionTask {
await task.value
}
}

private func startDrainIfNeeded() {
guard transitionTask == nil else { return }
transitionTask = Task { @MainActor [weak self] in
await self?.drainPendingTransitions()
}
}

private func drainPendingTransitions() async {
while !Task.isCancelled, let intent = pendingIntent {
pendingIntent = nil
let previous = activeSelection

if let previous, previous != intent.target {
await performOperation(
.stop(panelID: previous.panelID, workspaceID: previous.workspaceID)
)
activeSelection = nil
guard !Task.isCancelled else { break }
}

// A newer intent that arrived while the stop was in flight owns
// the next start. Skipping this target avoids transient stale RPCs.
guard pendingIntent == nil else { continue }
guard let target = intent.target, target != activeSelection else { continue }

await performOperation(
.start(panelID: target.panelID, workspaceID: target.workspaceID)
)
activeSelection = target

if Task.isCancelled {
// Cancellation can race an already-sent start RPC. Stop the
// accepted target before releasing coordinator ownership.
await performOperation(
.stop(panelID: target.panelID, workspaceID: target.workspaceID)
)
activeSelection = nil
break
}
}

transitionTask = nil
if pendingIntent != nil {
startDrainIfNeeded()
}
}
}
Loading