diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 5c1bd3f9efd0..b2b0b1ee0a70 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -40689,6 +40689,13 @@ export default { appendIfExisting(Bundle.main.url(forResource: "opencode-plugin", withExtension: "js")) appendIfExisting(Bundle.main.resourceURL?.appendingPathComponent("opencode-plugin.js", isDirectory: false)) + if let runtimeRoot = ProcessInfo.processInfo.environment["CMUX_CI_RUNTIME_SOURCE_ROOT"], + !runtimeRoot.isEmpty { + appendIfExisting( + URL(fileURLWithPath: runtimeRoot, isDirectory: true) + .appendingPathComponent("src/Resources/opencode-plugin.js") + ) + } if let executableURL = resolvedExecutableURL() { let execDir = executableURL.deletingLastPathComponent().standardizedFileURL for relativePath in ["opencode-plugin.js", "../opencode-plugin.js", "../../Resources/opencode-plugin.js", "../../../Contents/Resources/opencode-plugin.js"] { diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AgentFeed.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AgentFeed.swift index 309c2424d391..5537ff2812f4 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AgentFeed.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AgentFeed.swift @@ -15,6 +15,7 @@ nonisolated private let mobileShellAgentFeedPrimaryTextByteLimit = 8_192 nonisolated private let mobileShellAgentFeedSecondaryTextByteLimit = 2_048 nonisolated private let mobileShellAgentFeedMetadataByteLimit = 512 nonisolated private let mobileShellAgentFeedMaxItemCount = 400 +nonisolated private let mobileShellAgentFeedRequestTimeoutNanoseconds: UInt64 = 15_000_000_000 private struct AgentFeedTextPage: Decodable { let text: String @@ -311,7 +312,11 @@ extension MobileShellComposite { method: "feed.list", params: [:] ) - let data = try await client.sendRequest(request) + let data = try await client.sendRequest( + request, + timeoutNanoseconds: runtime?.rpcRequestTimeoutNanoseconds + ?? mobileShellAgentFeedRequestTimeoutNanoseconds + ) let response = try MobileAgentFeedListResponse.decode(data) guard !Task.isCancelled, agentFeedClient(for: macDeviceID) === client else { return } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+NotificationFeed.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+NotificationFeed.swift index 9f425dbffc30..5b91d3ee0bc5 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+NotificationFeed.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+NotificationFeed.swift @@ -16,6 +16,7 @@ nonisolated private let mobileShellNotificationFeedSubtitleByteLimit = 512 nonisolated private let mobileShellNotificationFeedBodyByteLimit = 2_048 nonisolated private let mobileShellNotificationFeedMetadataByteLimit = 512 nonisolated private let mobileShellNotificationFeedMaximumImmediateRefreshAttempts = 2 +nonisolated private let mobileShellNotificationFeedRequestTimeoutNanoseconds: UInt64 = 15_000_000_000 @MainActor extension MobileShellComposite { @@ -939,7 +940,10 @@ extension MobileShellComposite { method: "notification.feed.list", params: [:] ) - let data = try await client.sendRequest(request) + let data = try await client.sendRequest( + request, + timeoutNanoseconds: mobileShellNotificationFeedRequestTimeoutNanoseconds + ) let stringLimits = mobileShellNotificationFeedListStringLimits() let maxNotifications = MobileNotificationFeedAggregation.maxItemCount let decoderTask = Task.detached(priority: .userInitiated) { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentFeedStoreView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentFeedStoreView.swift index 2dd38e9d1187..0e317d22581a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentFeedStoreView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentFeedStoreView.swift @@ -12,8 +12,10 @@ struct AgentFeedStoreView: View { let status: MobileNotificationFeedStatus let pendingReplyRequestIDs: Set let pendingTerminalReplyItemIDs: Set + var isActive = true @State private var showsNavigationFailure = false + @State private var isFeedVisible = false @Bindable var searchCoordinator: MobilePrimarySearchCoordinator var body: some View { @@ -24,14 +26,18 @@ struct AgentFeedStoreView: View { pendingTerminalReplyItemIDs: pendingTerminalReplyItemIDs, failedTerminalReplies: store.agentFeedFailedTerminalReplies, refreshesOnAppear: true, + isActive: isActive, actions: actions, searchText: searchCoordinator.searchDestinationText(for: .feed) ) .onAppear { - store.recordAppEvent(.agentFeedOpened, count: items.count) + updateFeedVisibility(isActive) + } + .onChange(of: isActive) { _, active in + updateFeedVisibility(active) } .onDisappear { - store.recordAppEvent(.agentFeedClosed) + updateFeedVisibility(false) } .alert(String(localized: "mobile.agentFeed.openFailed.title", defaultValue: "Couldn’t open event", bundle: .module), isPresented: $showsNavigationFailure) { @@ -95,5 +101,17 @@ struct AgentFeedStoreView: View { } ) } + + private func updateFeedVisibility(_ active: Bool) { + if active { + guard !isFeedVisible else { return } + isFeedVisible = true + store.recordAppEvent(.agentFeedOpened, count: items.count) + } else { + guard isFeedVisible else { return } + isFeedVisible = false + store.recordAppEvent(.agentFeedClosed) + } + } } #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentFeedView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentFeedView.swift index 1f5cabc52512..04f455d71e1f 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentFeedView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentFeedView.swift @@ -18,6 +18,7 @@ struct AgentFeedView: View { let pendingTerminalReplyItemIDs: Set var failedTerminalReplies: [MobileAgentFeedItemID: MobileAgentFeedFailedReply] = [:] let refreshesOnAppear: Bool + var isActive = true let actions: AgentFeedActions var searchText: String = "" @Environment(MobileDisplaySettings.self) private var displaySettings @@ -34,6 +35,7 @@ struct AgentFeedView: View { pendingTerminalReplyItemIDs: Set, failedTerminalReplies: [MobileAgentFeedItemID: MobileAgentFeedFailedReply] = [:], refreshesOnAppear: Bool, + isActive: Bool = true, actions: AgentFeedActions, searchText: String = "" ) { @@ -43,6 +45,7 @@ struct AgentFeedView: View { self.pendingTerminalReplyItemIDs = pendingTerminalReplyItemIDs self.failedTerminalReplies = failedTerminalReplies self.refreshesOnAppear = refreshesOnAppear + self.isActive = isActive self.actions = actions self.searchText = searchText _preparedRows = State(initialValue: items.map(AgentFeedRowModel.init)) @@ -129,8 +132,14 @@ struct AgentFeedView: View { } .onAppear { now = Date() - guard refreshesOnAppear else { return } - Task { await actions.refresh() } + } + .task(id: isActive) { + guard isActive, refreshesOnAppear else { return } + // Relative timestamps are anchored to the last visible visit, so + // switching away and back cannot leave the feed comparing rows to + // the date from its first appearance. + now = Date() + await actions.refresh() } .onChange(of: items) { _, newItems in preparedRows = newItems.map(AgentFeedRowModel.init) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimarySearchNavigationStack.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimarySearchNavigationStack.swift index ddfebf739ddc..3e7e2c97ed94 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimarySearchNavigationStack.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimarySearchNavigationStack.swift @@ -10,23 +10,34 @@ struct MobilePrimarySearchNavigationStack: View { @Binding var path: [MobileWorkspacePreview.ID] @Binding var selection: MobilePrimaryTab @Bindable var searchCoordinator: MobilePrimarySearchCoordinator + var isActive = true + var hidesRootNavigationBar = false + var managesTabBarVisibility = true @ViewBuilder let root: () -> Root @ViewBuilder let destination: (MobileWorkspacePreview.ID) -> Destination var body: some View { NavigationStack(path: $path) { root() + .mobileToolbarVisibility(rootNavigationBarVisibility, for: .navigationBar) .modifier(MobilePrimarySearchLifecycleModifier( scope: searchCoordinator.scope, - update: searchCoordinator.updateLifecycle + update: { scope, isSearching in + guard isActive || !isSearching else { return } + searchCoordinator.updateLifecycle(scope: scope, isSearching: isSearching) + } )) .navigationDestination(for: MobileWorkspacePreview.ID.self, destination: destination) } .searchable(text: searchText, isPresented: searchPresentation, prompt: prompt) .onSubmit(of: .search) { + guard isActive else { return } selection = searchCoordinator.commitSubmit() } - .mobileToolbarVisibility(path.isEmpty ? .automatic : .hidden, for: .tabBar) + .modifier(MobilePrimarySearchTabBarVisibilityModifier( + isEnabled: managesTabBarVisibility, + visibility: path.isEmpty ? .automatic : .hidden + )) } private var searchPresentation: Binding { @@ -38,6 +49,13 @@ struct MobilePrimarySearchNavigationStack: View { ) } + private var rootNavigationBarVisibility: Visibility { + if #available(iOS 26.0, *), hidesRootNavigationBar { + return .hidden + } + return .automatic + } + private var searchText: Binding { let scope = searchCoordinator.scope let generation = searchCoordinator.activationGeneration diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimarySearchTabBarVisibilityModifier.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimarySearchTabBarVisibilityModifier.swift new file mode 100644 index 000000000000..b77b7b5cb2b5 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimarySearchTabBarVisibilityModifier.swift @@ -0,0 +1,17 @@ +#if os(iOS) +import SwiftUI + +struct MobilePrimarySearchTabBarVisibilityModifier: ViewModifier { + let isEnabled: Bool + let visibility: Visibility + + @ViewBuilder + func body(content: Content) -> some View { + if isEnabled { + content.mobileToolbarVisibility(visibility, for: .tabBar) + } else { + content + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimaryTabNavigationHost.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimaryTabNavigationHost.swift new file mode 100644 index 000000000000..40664840df3a --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimaryTabNavigationHost.swift @@ -0,0 +1,37 @@ +#if os(iOS) +import CmuxMobileSupport +import SwiftUI + +/// Keeps the compact tab bar and its root navigation chrome in one hierarchy. +/// The tab contents still keep their own navigation paths, but they no longer +/// compete to install the root toolbar as selection changes. +struct MobilePrimaryTabNavigationHost: View { + let content: Content + let toolbar: Toolbar + let toolbarVisibility: Visibility + let tabBarVisibility: Visibility + + init( + toolbarVisibility: Visibility, + tabBarVisibility: Visibility = .automatic, + @ToolbarContentBuilder toolbar: () -> Toolbar, + @ViewBuilder content: () -> Content + ) { + self.content = content() + self.toolbar = toolbar() + self.toolbarVisibility = toolbarVisibility + self.tabBarVisibility = tabBarVisibility + } + + var body: some View { + NavigationStack { + content + .toolbar { + toolbar + } + .mobileToolbarVisibility(toolbarVisibility, for: .navigationBar) + .mobileToolbarVisibility(tabBarVisibility, for: .tabBar) + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimaryTabScaffold.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimaryTabScaffold.swift index 1bbeb4a0efe8..55228d52fa1f 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimaryTabScaffold.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePrimaryTabScaffold.swift @@ -66,28 +66,20 @@ struct MobilePrimaryTabScaffold< } } .tabViewSearchActivation(.searchTabSelection) + .tabViewStyle(.tabBarOnly) .accessibilityIdentifier("MobilePrimaryTabs") + .animation(nil, value: selection) .onChange(of: selection, initial: true) { _, selection in searchCoordinator.synchronizeSelection(selection) } - if selection == .workspaces, let taskComposerAction { - TaskComposerButton( - action: taskComposerAction, - diameter: iOS26BottomControlDiameter - ) - .padding(.trailing, iOS26BottomControlInset) - .padding(.bottom, iOS26TaskComposerBottomPadding) - // Compose anchors to the screen, not the keyboard. The - // only keyboard that can appear while it is visible - // belongs to an overlaying sheet (the composer's - // auto-focused prompt), whose inset dragged the button - // toward mid-screen and stranded it there whenever the - // hide update was missed. - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) - .ignoresSafeArea(.keyboard, edges: .bottom) + if selection == .workspaces { + iOS26TaskComposerButton } } + // The composer padding is calibrated from the screen edge. Keep + // the scaffold's layout space through the bottom container inset + // so the button does not float above its intended position. .ignoresSafeArea(.container, edges: .bottom) } else if #available(iOS 18.0, *) { TabView(selection: $selection) { @@ -127,6 +119,26 @@ struct MobilePrimaryTabScaffold< iOS26BottomControlInset + iOS26BottomControlDiameter + iOS26BottomControlSpacing } + @ViewBuilder + private var iOS26TaskComposerButton: some View { + if let taskComposerAction { + TaskComposerButton( + action: taskComposerAction, + diameter: iOS26BottomControlDiameter + ) + .padding(.trailing, iOS26BottomControlInset) + .padding(.bottom, iOS26TaskComposerBottomPadding) + // Compose anchors to the screen, not the keyboard. The + // only keyboard that can appear while it is visible + // belongs to an overlaying sheet (the composer's + // auto-focused prompt), whose inset dragged the button + // toward mid-screen and stranded it there whenever the + // hide update was missed. + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) + .ignoresSafeArea(.keyboard, edges: .bottom) + } + } + private var tabSelection: Binding { Binding( get: { selection }, @@ -141,7 +153,19 @@ struct MobilePrimaryTabScaffold< searchCoordinator.deactivateCurrentSearch() } } - selection = newValue + // Each primary tab owns a NavigationStack. Letting the + // selection write inherit SwiftUI's default animation makes + // UIKit animate the outgoing stack's toolbar away before the + // incoming stack has installed its own toolbar items. The + // resulting empty frame is the brief flash seen at the top + // while switching between Workspaces and Notifications. + // Keep the tab contents and their navigation state intact, + // but commit the stack swap as one layout transaction. + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + selection = newValue + } } ) } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedFilterMenu.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedFilterMenu.swift new file mode 100644 index 000000000000..65d4a551ba13 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedFilterMenu.swift @@ -0,0 +1,35 @@ +#if os(iOS) +import CmuxMobileShellModel +import CmuxMobileSupport +import SwiftUI + +/// The feed twin of `WorkspaceListFilterMenu`: read state lives in a toolbar +/// menu instead of a segmented bar above the list, and the icon fills while a +/// narrowing filter is active, mirroring Mail. +struct NotificationFeedFilterMenu: View { + @Binding var selection: MobileNotificationFeedFilter + + var body: some View { + Menu { + Picker( + L10n.string("mobile.notificationFeed.filter.label", defaultValue: "Notification filter"), + selection: $selection + ) { + Text(L10n.string( + "mobile.notificationFeed.filter.allNotifications", + defaultValue: "All Notifications" + )) + .tag(MobileNotificationFeedFilter.all) + Text(L10n.string("mobile.notificationFeed.filter.unread", defaultValue: "Unread")) + .tag(MobileNotificationFeedFilter.unread) + } + } label: { + Image(systemName: selection == .unread + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease.circle") + } + .accessibilityLabel(L10n.string("mobile.notificationFeed.filter", defaultValue: "Filter")) + .accessibilityIdentifier("MobileNotificationFeedFilterMenu") + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedPreviewView.swift index 00f034205c48..4b31783af7d5 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedPreviewView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedPreviewView.swift @@ -15,6 +15,7 @@ public struct NotificationFeedPreviewView: View { @State private var referenceDate: Date @State private var items: [MobileNotificationFeedItem] @State private var projection = NotificationFeedProjection() + @State private var isConfirmingMarkAllRead = false @State private var notificationRoute: NotificationWorkspaceRoute? @State private var searchNavigationPath: [MobileWorkspacePreview.ID] = [] @State private var pendingSearchNotificationNavigationID: MobileWorkspacePreview.ID? @@ -41,57 +42,113 @@ public struct NotificationFeedPreviewView: View { /// The preview fixture's production-style tab and feed body. public var body: some View { GeometryReader { geometry in - MobilePrimaryTabScaffold( - selection: $selectedTab, - searchCoordinator: primarySearchCoordinator, - notificationUnreadCount: items.lazy.filter { !$0.isRead }.count - ) { - NavigationStack { - NotificationFeedPreviewWorkspacesView() - } - } feed: { - Text(verbatim: "Agent feed fixture") - .foregroundStyle(.secondary) - } notifications: { - NavigationStack { - ScrollViewReader { proxy in - notificationsTabFeed(proxy: proxy) + MobilePrimaryTabNavigationHost( + toolbarVisibility: previewRootToolbarVisible ? .visible : .hidden, + tabBarVisibility: previewTabBarVisibility, + toolbar: { + if previewRootToolbarVisible { + WorkspaceRootToolbarContent( + openSettings: {}, + openDevices: {}, + title: L10n.string( + "mobile.workspaces.macPicker.allConnections", + defaultValue: "All Computers" + ), + isLoading: false, + selection: macSelection, + select: { macSelection = $0 }, + machines: [], + showAddDevice: nil + ) + if selectedTab == .notifications + || (selectedTab == .search && primarySearchCoordinator.scope == .notifications) { + NotificationFeedToolbarContent( + projection: projection, + requestMarkAllRead: { isConfirmingMarkAllRead = true } + ) + } } - } - .onAppear { - consumePendingSearchNavigation(for: .notifications) - } - .onChange(of: pendingSearchNotificationNavigationID) { _, _ in - consumePendingSearchNavigation(for: .notifications) - } - } cloud: { - Text(L10n.string("mobile.cloud.title", defaultValue: "Cloud")) - .foregroundStyle(.secondary) - } search: { - MobilePrimarySearchNavigationStack( - path: $searchNavigationPath, - selection: $selectedTab, - searchCoordinator: primarySearchCoordinator - ) { - switch primarySearchCoordinator.scope { - case .feed: + }, + content: { + MobilePrimaryTabScaffold( + selection: $selectedTab, + searchCoordinator: primarySearchCoordinator, + notificationUnreadCount: items.lazy.filter { !$0.isRead }.count + ) { + NavigationStack { + NotificationFeedPreviewWorkspacesView() + } + .toolbar(.hidden, for: .navigationBar) + } feed: { Text(verbatim: "Agent feed fixture") - case .workspaces: - NotificationFeedPreviewWorkspacesView() - case .notifications: - NotificationFeedView( - status: .ready, - projection: projection, - refreshesOnAppear: false, - actions: actions - ) + .foregroundStyle(.secondary) + } notifications: { + NavigationStack { + ScrollViewReader { proxy in + notificationsTabFeed(proxy: proxy) + } + .navigationDestination(isPresented: notificationRouteIsPresented) { + NotificationFeedPreviewWorkspaceDestination( + workspaceName: notificationRoute.map { workspaceName(for: $0.id) } + ?? L10n.string( + "mobile.notificationFeed.workspaceFallback", + defaultValue: "Workspace" + ) + ) + .toolbar(.visible, for: .navigationBar) + } + } + .toolbar(.hidden, for: .navigationBar) + .onAppear { + consumePendingSearchNavigation(for: .notifications) + } + .onChange(of: pendingSearchNotificationNavigationID) { _, _ in + consumePendingSearchNavigation(for: .notifications) + } + } cloud: { + Text(L10n.string("mobile.cloud.title", defaultValue: "Cloud")) + .foregroundStyle(.secondary) + } search: { + MobilePrimarySearchNavigationStack( + path: $searchNavigationPath, + selection: $selectedTab, + searchCoordinator: primarySearchCoordinator, + isActive: selectedTab == .search, + hidesRootNavigationBar: true, + managesTabBarVisibility: false + ) { + Group { + switch primarySearchCoordinator.scope { + case .feed: + Text(verbatim: "Agent feed fixture") + case .workspaces: + NotificationFeedPreviewWorkspacesView() + case .notifications: + NotificationFeedView( + status: .ready, + projection: projection, + refreshesOnAppear: true, + actions: actions, + isActive: selectedTab == .search + && primarySearchCoordinator.scope == .notifications, + isConfirmingMarkAllRead: $isConfirmingMarkAllRead, + showsNavigationToolbar: false + ) + } + } + } destination: { workspaceID in + NotificationFeedPreviewWorkspaceDestination( + workspaceName: workspaceName(for: workspaceID) + ) + .toolbar(.visible, for: .navigationBar) + } } - } destination: { workspaceID in - NotificationFeedPreviewWorkspaceDestination( - workspaceName: workspaceName(for: workspaceID) - ) } - } + ) + .notificationFeedMarkAllReadAlert( + isPresented: $isConfirmingMarkAllRead, + markAllRead: { actions.markAllRead() } + ) .background { NotificationFeedSearchProjectionSync( searchCoordinator: primarySearchCoordinator, @@ -105,6 +162,9 @@ public struct NotificationFeedPreviewView: View { consumePendingSearchNavigation(for: selectedTab) } .onChange(of: selectedTab) { oldValue, newValue in + if newValue == .notifications { + consumePendingSearchNavigation(for: .notifications) + } if oldValue == .search, newValue != .search { searchNavigationPath = [] } @@ -112,6 +172,52 @@ public struct NotificationFeedPreviewView: View { .onChange(of: items, initial: true) { _, items in projection.update(items: items, referenceDate: referenceDate) } + .task { + guard notificationFeedPreviewTabSwitchEnabled else { return } + let clock = ContinuousClock() + // Let the UI test establish the initial Notifications toolbar + // before the deterministic transition sequence begins. + try? await clock.sleep(for: .seconds(8)) + guard !Task.isCancelled else { return } + for _ in 0..<6 where !Task.isCancelled { + try? await clock.sleep(for: .milliseconds(700)) + guard !Task.isCancelled else { return } + transitionPrimaryTab(to: .workspaces) + try? await clock.sleep(for: .milliseconds(700)) + guard !Task.isCancelled else { return } + transitionPrimaryTab(to: .notifications) + } + } + } + + private var notificationFeedPreviewTabSwitchEnabled: Bool { + #if DEBUG + ProcessInfo.processInfo.environment["CMUX_UITEST_NOTIFICATION_FEED_PREVIEW_TAB_SWITCH"] == "1" + #else + false + #endif + } + + private var previewRootToolbarVisible: Bool { + switch selectedTab { + case .workspaces, .feed, .cloud: + true + case .notifications: + notificationRoute == nil + case .search: + searchNavigationPath.isEmpty + } + } + + private var previewTabBarVisibility: Visibility { + switch selectedTab { + case .workspaces, .feed, .cloud: + .automatic + case .notifications: + notificationRoute == nil ? .automatic : .hidden + case .search: + searchNavigationPath.isEmpty ? .automatic : .hidden + } } /// The notifications-tab feed with the optional profiling scroll driver. @@ -120,36 +226,15 @@ public struct NotificationFeedPreviewView: View { status: .ready, projection: projection, refreshesOnAppear: true, - actions: actions + actions: actions, + isActive: selectedTab == .notifications, + isConfirmingMarkAllRead: $isConfirmingMarkAllRead, + showsNavigationToolbar: false ) - .task { + .task(id: selectedTab) { + guard selectedTab == .notifications else { return } await runScrollStressIfEnabled(proxy: proxy) } - .toolbar { - WorkspaceRootToolbarContent( - openSettings: {}, - openDevices: {}, - title: L10n.string( - "mobile.workspaces.macPicker.allConnections", - defaultValue: "All Computers" - ), - isLoading: false, - selection: macSelection, - select: { macSelection = $0 }, - machines: [], - showAddDevice: nil - ) - } - .navigationDestination(isPresented: notificationRouteIsPresented) { - NotificationFeedPreviewWorkspaceDestination( - workspaceName: notificationRoute.map { workspaceName(for: $0.id) } - ?? L10n.string( - "mobile.notificationFeed.workspaceFallback", - defaultValue: "Workspace" - ) - ) - .mobileToolbarVisibility(.hidden, for: .tabBar) - } } private var actions: NotificationFeedActions { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedStoreView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedStoreView.swift index a6e93e6b40c0..752c359c9503 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedStoreView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedStoreView.swift @@ -8,25 +8,40 @@ import SwiftUI /// This is the only notification-feed view that retains a store reference. struct NotificationFeedStoreView: View { @Bindable var store: CMUXMobileShellStore + @Binding var isConfirmingMarkAllRead: Bool @Environment(\.mobilePrimarySearchDestination) private var isSearchDestination let items: [MobileNotificationFeedItem] let status: MobileNotificationFeedStatus let projection: NotificationFeedProjection let selectedMacDeviceIDs: Set? + var isActive = true + var showsNavigationToolbar = true + @State private var isFeedVisible = false var body: some View { NotificationFeedView( status: status, projection: projection, - refreshesOnAppear: !isSearchDestination, - actions: actions + // The mounted tab and the active search destination both use the + // active gate below, so only the visible owner refreshes. + refreshesOnAppear: true, + actions: actions, + isActive: isActive, + isConfirmingMarkAllRead: $isConfirmingMarkAllRead, + showsNavigationToolbar: showsNavigationToolbar ) .onAppear { - store.recordAppEvent(.notificationFeedOpened, count: items.count) + updateFeedVisibility(isActive) + } + .onChange(of: isActive) { _, active in + if !active { + store.cancelPendingNotificationFeedOpen() + } + updateFeedVisibility(active) } .onDisappear { store.cancelPendingNotificationFeedOpen() - store.recordAppEvent(.notificationFeedClosed) + updateFeedVisibility(false) } } @@ -67,5 +82,18 @@ struct NotificationFeedStoreView: View { } ) } + + private func updateFeedVisibility(_ active: Bool) { + if active { + guard !isFeedVisible else { return } + isFeedVisible = true + store.recordAppEvent(.notificationFeedOpened, count: items.count) + } else { + guard isFeedVisible else { return } + isFeedVisible = false + store.cancelPendingNotificationFeedOpen() + store.recordAppEvent(.notificationFeedClosed) + } + } } #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedToolbarContent.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedToolbarContent.swift new file mode 100644 index 000000000000..ec2a41681190 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedToolbarContent.swift @@ -0,0 +1,33 @@ +#if os(iOS) +import CmuxMobileShellModel +import CmuxMobileSupport +import SwiftUI + +/// Toolbar controls shared by the visible notification feed and the compact +/// primary-tab parent. Keeping this preference in one toolbar hierarchy avoids +/// inactive, opacity-hidden feed stacks contributing duplicate items. +struct NotificationFeedToolbarContent: ToolbarContent { + @Bindable var projection: NotificationFeedProjection + let requestMarkAllRead: () -> Void + + var body: some ToolbarContent { + ToolbarItemGroup(placement: .topBarTrailing) { + if projection.sourceUnreadCount > 0 { + Button(action: requestMarkAllRead) { + Label( + L10n.string("mobile.notificationFeed.markAllRead", defaultValue: "Mark All Read"), + systemImage: "envelope.open" + ) + .labelStyle(.iconOnly) + } + .accessibilityLabel( + L10n.string("mobile.notificationFeed.markAllRead", defaultValue: "Mark All Read") + ) + .accessibilityIdentifier("MobileNotificationFeedMarkAllRead") + } + + NotificationFeedFilterMenu(selection: $projection.filter) + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedView.swift index eeb2dae12e62..b22e88173c6e 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedView.swift @@ -21,14 +21,16 @@ struct NotificationFeedView: View { let projection: NotificationFeedProjection let refreshesOnAppear: Bool let actions: NotificationFeedActions + var isActive = true + @Binding var isConfirmingMarkAllRead: Bool + let showsNavigationToolbar: Bool /// Mark-all-read cannot be undone in one gesture, so the toolbar button /// only arms this confirmation instead of mutating directly. - @State private var isConfirmingMarkAllRead = false var body: some View { @Bindable var projection = projection - VStack(spacing: 0) { + let feed = VStack(spacing: 0) { NotificationFeedList( sections: projection.sections, sourceItemCount: projection.sourceItemCount, @@ -49,82 +51,56 @@ struct NotificationFeedView: View { // No title of its own (the tab names the screen), so collapse the // large-title zone or the list opens with a bar-height blank strip. .mobileInlineNavigationTitle() - .toolbar { - ToolbarItemGroup(placement: .topBarTrailing) { - if projection.sourceUnreadCount > 0 { - Button { - isConfirmingMarkAllRead = true - } label: { - Label( - L10n.string("mobile.notificationFeed.markAllRead", defaultValue: "Mark All Read"), - systemImage: "envelope.open" - ) - .labelStyle(.iconOnly) - } - .accessibilityLabel( - L10n.string("mobile.notificationFeed.markAllRead", defaultValue: "Mark All Read") + + Group { + if showsNavigationToolbar { + feed.toolbar { + NotificationFeedToolbarContent( + projection: projection, + requestMarkAllRead: { isConfirmingMarkAllRead = true } ) - .accessibilityIdentifier("MobileNotificationFeedMarkAllRead") } - - NotificationFeedFilterMenu(selection: $projection.filter) + } else { + feed } } - .alert( + .task(id: isActive) { + guard isActive, refreshesOnAppear else { return } + await actions.refresh() + } + .onChange(of: projection.filter) { _, filter in + guard isActive else { return } + actions.filterChanged(filter) + } + .accessibilityIdentifier("MobileNotificationFeed") + } +} + +extension View { + /// Presents the feed's destructive confirmation from the one navigation + /// host that owns the active notification scope. Individual feed views can + /// remain mounted for search and tab navigation without competing to + /// present the same alert. + func notificationFeedMarkAllReadAlert( + isPresented: Binding, + markAllRead: @escaping @MainActor () -> Void + ) -> some View { + alert( L10n.string( "mobile.notificationFeed.markAllRead.confirmTitle", defaultValue: "Mark all notifications as read?" ), - isPresented: $isConfirmingMarkAllRead + isPresented: isPresented ) { Button( L10n.string("mobile.notificationFeed.markAllRead", defaultValue: "Mark All Read"), - role: .destructive - ) { - actions.markAllRead() - } + role: .destructive, + action: markAllRead + ) .accessibilityIdentifier("MobileNotificationFeedMarkAllReadConfirm") Button(L10n.string("mobile.common.cancel", defaultValue: "Cancel"), role: .cancel) {} .accessibilityIdentifier("MobileNotificationFeedMarkAllReadCancel") } - .task { - guard refreshesOnAppear else { return } - await actions.refresh() - } - .onChange(of: projection.filter) { _, filter in - actions.filterChanged(filter) - } - .accessibilityIdentifier("MobileNotificationFeed") - } -} - -/// The feed twin of `WorkspaceListFilterMenu`: read state lives in a toolbar -/// menu instead of a segmented bar above the list, and the icon fills while a -/// narrowing filter is active, mirroring Mail. -private struct NotificationFeedFilterMenu: View { - @Binding var selection: MobileNotificationFeedFilter - - var body: some View { - Menu { - Picker( - L10n.string("mobile.notificationFeed.filter.label", defaultValue: "Notification filter"), - selection: $selection - ) { - Text(L10n.string( - "mobile.notificationFeed.filter.allNotifications", - defaultValue: "All Notifications" - )) - .tag(MobileNotificationFeedFilter.all) - Text(L10n.string("mobile.notificationFeed.filter.unread", defaultValue: "Unread")) - .tag(MobileNotificationFeedFilter.unread) - } - } label: { - Image(systemName: selection == .unread - ? "line.3.horizontal.decrease.circle.fill" - : "line.3.horizontal.decrease.circle") - } - .accessibilityLabel(L10n.string("mobile.notificationFeed.filter", defaultValue: "Filter")) - .accessibilityIdentifier("MobileNotificationFeedFilterMenu") } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift index d12360464e06..efa7204c67b2 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift @@ -135,7 +135,6 @@ struct WorkspaceRootToolbarContent: ToolbarContent { addDevice: showAddDevice ) ) - .equatable() .frame( minHeight: horizontalSizeClass == .regular ? WorkspaceRootToolbarSizing.controlHeight @@ -257,6 +256,7 @@ struct WorkspaceShellView: View { @State private var feedNavigationPath: [MobileWorkspacePreview.ID] = [] @State private var notificationSearchNavigationPath: [MobileWorkspacePreview.ID] = [] @State private var workspaceSearchNavigationPath: [MobileWorkspacePreview.ID] = [] + @State private var isConfirmingNotificationFeedMarkAllRead = false @State private var pendingPrimarySearchWorkspaceNavigationID: MobileWorkspacePreview.ID? @State private var pendingPrimarySearchNotificationNavigationID: MobileWorkspacePreview.ID? // A NavigationStack path write only reaches UIKit while the stack is in the @@ -311,6 +311,39 @@ struct WorkspaceShellView: View { #endif } + #if os(iOS) + private var compactRootToolbarVisible: Bool { + switch selectedPrimaryTab { + case .workspaces: + return compactNavigationPath.isEmpty + case .feed: + return feedNavigationPath.isEmpty + case .notifications: + return notificationNavigationPath.isEmpty + case .cloud: + return true + case .search: + return primarySearchNavigationPath.wrappedValue.isEmpty + } + } + + private var compactTabBarVisibility: Visibility { + compactRootToolbarVisible ? .automatic : .hidden + } + + private var compactRootToolbarVisibility: Visibility { + guard compactRootToolbarVisible else { return .hidden } + guard selectedPrimaryTab == .search else { return .visible } + // Before iOS 26, the searchable stack owns the navigation bar that + // presents the search field. The shared outer bar must stay hidden so + // it does not create a second navigation chrome layer. + if #available(iOS 26.0, *) { + return .visible + } + return .hidden + } + #endif + private var listConnectionStatus: MobileMacConnectionStatus { if isInitialConnectionLoading || initialConnectionTimedOut { return .reconnecting @@ -346,7 +379,20 @@ struct WorkspaceShellView: View { ) #if os(iOS) GeometryReader { geometry in - primaryScaffold(presentation: presentation) + Group { + if usesCompactStack { + compactScaffold(presentation: presentation) + } else { + // Regular-width (iPad): the NavigationSplitView is the one + // navigation hierarchy. Wrapping it in the TabView renders + // the iOS 26 floating tab strip on top of the split + // columns' own toolbars; destinations move into the + // sidebar's bottom bar instead. + workspaceTabContent( + presentation: presentation + ) + } + } .background { NotificationFeedSearchProjectionSync( searchCoordinator: primarySearchCoordinator, @@ -417,6 +463,16 @@ struct WorkspaceShellView: View { .onChange(of: presentation.notificationFeedItems, initial: true) { _, items in notificationFeedProjection.update(items: items) } + .notificationFeedMarkAllReadAlert( + isPresented: $isConfirmingNotificationFeedMarkAllRead, + markAllRead: { + Task { + await store.markNotificationFeedItemsRead( + scopedTo: presentation.selectedNotificationFeedMacDeviceIDs + ) + } + } + ) } #endif #else @@ -428,90 +484,103 @@ struct WorkspaceShellView: View { } #if os(iOS) - /// Primary destinations retain their own navigation across tab switches. - /// Workspace tabs keep split navigation at regular widths. - private func primaryScaffold(presentation: WorkspaceShellRenderPresentation) -> some View { - MobilePrimaryTabScaffold( - selection: $selectedPrimaryTab, - searchCoordinator: primarySearchCoordinator, - notificationUnreadCount: presentation.notificationUnreadCount, - feedNeedsInputCount: presentation.agentFeedNeedsInputCount, - showsNotificationsTab: !displaySettings.feedReplacesNotifications, - taskComposerAction: usesCompactStack && !compactNavigationPath.isEmpty - ? nil - : taskComposerAction - ) { - workspaceTabContent( - presentation: presentation - ) - } feed: { - NavigationStack(path: $feedNavigationPath) { - agentFeedStoreView(for: presentation) - .toolbar { - if feedNavigationPath.isEmpty { - rootToolbarContent - } - } - .navigationDestination(for: MobileWorkspacePreview.ID.self) { workspaceID in - workspaceDestination( - for: workspaceID, - createWorkspace: createWorkspaceInCompactStack, - canCreateWorkspaceForSelection: presentation.canCreateWorkspaceForSelection + /// The compact (iPhone-style) shell: the primary destinations live in the + /// system TabView with the transient search tab. + private func compactScaffold(presentation: WorkspaceShellRenderPresentation) -> some View { + MobilePrimaryTabNavigationHost( + toolbarVisibility: compactRootToolbarVisibility, + tabBarVisibility: compactTabBarVisibility, + toolbar: { + if compactRootToolbarVisible { + rootToolbarContent + if selectedPrimaryTab == .notifications + || (selectedPrimaryTab == .search && primarySearchCoordinator.scope == .notifications) { + NotificationFeedToolbarContent( + projection: notificationFeedProjection, + requestMarkAllRead: { + isConfirmingNotificationFeedMarkAllRead = true + } ) - .mobileToolbarVisibility(.hidden, for: .tabBar) } - } - } notifications: { - if !usesCompactStack { - workspaceTabContent(presentation: presentation) - } else { - NavigationStack(path: $notificationNavigationPath) { - NotificationFeedStoreView( - store: store, - items: presentation.notificationFeedItems, - status: presentation.notificationFeedStatus, - projection: notificationFeedProjection, - selectedMacDeviceIDs: presentation.selectedNotificationFeedMacDeviceIDs - ) - .toolbar { - if notificationNavigationPath.isEmpty { - rootToolbarContent - } + } + }, + content: { + MobilePrimaryTabScaffold( + selection: $selectedPrimaryTab, + searchCoordinator: primarySearchCoordinator, + notificationUnreadCount: presentation.notificationUnreadCount, + feedNeedsInputCount: presentation.agentFeedNeedsInputCount, + showsNotificationsTab: !displaySettings.feedReplacesNotifications, + taskComposerAction: usesCompactStack && !compactNavigationPath.isEmpty + ? nil + : taskComposerAction + ) { + workspaceTabContent(presentation: presentation) + } feed: { + NavigationStack(path: $feedNavigationPath) { + agentFeedStoreView( + for: presentation, + isActive: selectedPrimaryTab == .feed + ) + .navigationDestination(for: MobileWorkspacePreview.ID.self) { workspaceID in + workspaceDestination( + for: workspaceID, + createWorkspace: createWorkspaceInCompactStack, + canCreateWorkspaceForSelection: presentation.canCreateWorkspaceForSelection + ) + .toolbar(.visible, for: .navigationBar) + } } - .navigationDestination(for: MobileWorkspacePreview.ID.self) { workspaceID in - workspaceDestination( - for: workspaceID, - createWorkspace: createWorkspaceInCompactStack, - canCreateWorkspaceForSelection: presentation.canCreateWorkspaceForSelection + .toolbar(.hidden, for: .navigationBar) + } notifications: { + NavigationStack(path: $notificationNavigationPath) { + NotificationFeedStoreView( + store: store, + isConfirmingMarkAllRead: $isConfirmingNotificationFeedMarkAllRead, + items: presentation.notificationFeedItems, + status: presentation.notificationFeedStatus, + projection: notificationFeedProjection, + selectedMacDeviceIDs: presentation.selectedNotificationFeedMacDeviceIDs, + isActive: selectedPrimaryTab == .notifications, + showsNavigationToolbar: false ) - .mobileToolbarVisibility(.hidden, for: .tabBar) + .navigationDestination(for: MobileWorkspacePreview.ID.self) { workspaceID in + workspaceDestination( + for: workspaceID, + createWorkspace: createWorkspaceInCompactStack, + canCreateWorkspaceForSelection: presentation.canCreateWorkspaceForSelection + ) + .toolbar(.visible, for: .navigationBar) + } + } + .toolbar(.hidden, for: .navigationBar) + .onAppear { + guard selectedPrimaryTab == .notifications else { return } + notificationsStackIsOnScreen = true + consumePendingPrimarySearchNavigation(for: .notifications) + } + .onDisappear { + notificationsStackIsOnScreen = false + } + .onChange(of: pendingPrimarySearchNotificationNavigationID) { _, _ in + consumePendingPrimarySearchNavigation(for: .notifications) + } + } cloud: { + // Supplied by the composition root; the shell owns no Cloud code. + // The host above already owns the compact navigation stack. + // Mount Cloud's embedded variant here so its section does + // not create a second stack inside the shared toolbar host. + cloudTabContent?.makeEmbeddedView() + } search: { + primarySearchTabContent(presentation: presentation) } } - .onAppear { - notificationsStackIsOnScreen = true - consumePendingPrimarySearchNavigation(for: .notifications) - } - .onDisappear { - notificationsStackIsOnScreen = false - } - .onChange(of: pendingPrimarySearchNotificationNavigationID) { _, _ in - consumePendingPrimarySearchNavigation(for: .notifications) - } - } - } cloud: { - // Supplied by the composition root; the shell owns no Cloud code. - cloudTabContent?.makeView() - } search: { - primarySearchTabContent(presentation: presentation) - } + ) } - #endif #if os(iOS) - private func workspaceTabContent( - presentation: WorkspaceShellRenderPresentation - ) -> some View { + private func workspaceTabContent(presentation: WorkspaceShellRenderPresentation) -> some View { workspaceActionToastOverlay { layoutContent(presentation: presentation) } @@ -539,7 +608,10 @@ struct WorkspaceShellView: View { MobilePrimarySearchNavigationStack( path: primarySearchNavigationPath, selection: $selectedPrimaryTab, - searchCoordinator: primarySearchCoordinator + searchCoordinator: primarySearchCoordinator, + isActive: selectedPrimaryTab == .search, + hidesRootNavigationBar: true, + managesTabBarVisibility: false ) { Group { switch primarySearchCoordinator.scope { @@ -559,28 +631,45 @@ struct WorkspaceShellView: View { ) } case .feed: - agentFeedStoreView(for: presentation) + agentFeedStoreView( + for: presentation, + isActive: selectedPrimaryTab == .search + && primarySearchCoordinator.scope == .feed + ) case .notifications: NotificationFeedStoreView( store: store, + isConfirmingMarkAllRead: $isConfirmingNotificationFeedMarkAllRead, items: presentation.notificationFeedItems, status: presentation.notificationFeedStatus, projection: notificationFeedProjection, - selectedMacDeviceIDs: presentation.selectedNotificationFeedMacDeviceIDs + selectedMacDeviceIDs: presentation.selectedNotificationFeedMacDeviceIDs, + isActive: selectedPrimaryTab == .search + && primarySearchCoordinator.scope == .notifications, + showsNavigationToolbar: false ) } } - .toolbar { - if primarySearchNavigationPath.wrappedValue.isEmpty { - rootToolbarContent - } - } } destination: { workspaceID in workspaceDestination( for: workspaceID, createWorkspace: createWorkspaceInCompactStack, canCreateWorkspaceForSelection: presentation.canCreateWorkspaceForSelection ) + .toolbar(.visible, for: .navigationBar) + } + } + .toolbar { + if #unavailable(iOS 26.0), primarySearchNavigationPath.wrappedValue.isEmpty { + rootToolbarContent + if primarySearchCoordinator.scope == .notifications { + NotificationFeedToolbarContent( + projection: notificationFeedProjection, + requestMarkAllRead: { + isConfirmingNotificationFeedMarkAllRead = true + } + ) + } } } } @@ -814,11 +903,6 @@ struct WorkspaceShellView: View { canCreateWorkspaceForSelection: canCreateWorkspaceForSelection ) } - .toolbar { - if compactNavigationPath.isEmpty { - rootToolbarContent - } - } .navigationDestination(for: MobileWorkspacePreview.ID.self) { workspaceID in workspaceDestination( for: workspaceID, @@ -831,8 +915,9 @@ struct WorkspaceShellView: View { ) ) #if os(iOS) - .mobileToolbarVisibility(.hidden, for: .tabBar, .bottomBar) + .mobileToolbarVisibility(.hidden, for: .bottomBar) #endif + .toolbar(.visible, for: .navigationBar) // Only on the pushed compact stack (where a back button // exists): replace the system back button with a custom one // that folds the unread-workspace count INTO the same button @@ -842,6 +927,7 @@ struct WorkspaceShellView: View { .background(InteractiveSwipeBackEnabler()) } } + .toolbar(.hidden, for: .navigationBar) .onChange(of: store.selectedWorkspaceID) { _, selectedWorkspaceID in if let createdPath = compactNavigationPolicy.pathForCreatedWorkspaceSelection( currentPath: compactNavigationPath, @@ -903,7 +989,8 @@ struct WorkspaceShellView: View { } private var taskComposerAction: (() -> Void)? { - guard store.supportsTaskComposer else { return nil } + // The task composer creates workspaces through a Mac. + guard store.supportsTaskComposer, selectedSSHHostID == nil else { return nil } return openTaskComposer } @@ -983,12 +1070,15 @@ struct WorkspaceShellView: View { #endif #if os(iOS) - /// The workspace tab's split sidebar. Top-level destination navigation is - /// provided by the system tab bar; this sidebar owns workspace search and - /// the Mac picker. + /// The split (iPad) sidebar column: one destination surface switched by + /// the bottom-bar control, the shared root toolbar on top, and the native + /// search field scoped to the visible destination. There is no TabView in + /// this hierarchy, so no floating tab strip can overlap the column + /// toolbars. private func splitSidebar(presentation: WorkspaceShellRenderPresentation) -> some View { let selectedMacDeviceIDs = presentation.selectedNotificationFeedMacDeviceIDs let notificationItems = presentation.notificationFeedItems + let unreadCount = presentation.notificationUnreadCount return Group { switch splitSidebarDestination { case .feed: @@ -996,6 +1086,7 @@ struct WorkspaceShellView: View { case .notifications: NotificationFeedStoreView( store: store, + isConfirmingMarkAllRead: $isConfirmingNotificationFeedMarkAllRead, items: notificationItems, status: presentation.notificationFeedStatus, projection: notificationFeedProjection, @@ -1032,6 +1123,9 @@ struct WorkspaceShellView: View { .toolbar { rootToolbarContent } + .toolbar { + splitSidebarBottomBar(unreadCount: unreadCount) + } // Keep NavigationSplitView's synthesized control out of the toolbar. // The shared custom action is owned by this sidebar while open and by // the detail bar after the sidebar is hidden. @@ -1384,6 +1478,13 @@ struct WorkspaceShellView: View { filterState: workspaceListFilterState, searchText: searchText ) + #if os(iOS) + .sshWorkspaceListPanel( + sshWorkspaceListPanel, + installingCmuxTUI: !store.sshComputers.installingCmuxTUIHosts.isEmpty, + actions: sshWorkspaceListPanelActions + ) + #endif } #if os(iOS) @@ -1404,7 +1505,12 @@ struct WorkspaceShellView: View { /// connection story: reauth and initial restore render their own chrome, /// transient degradation renders only this line. private var toolbarConnectionStatusLine: WorkspaceConnectionStatusLine? { - WorkspaceListConnectionChrome( + // An SSH computer reports its own connection, the same way; the Mac + // connection's status line would describe a different computer. + if selectedSSHHostID != nil { + return sshWorkspaceListPanel?.statusLine + } + return WorkspaceListConnectionChrome( hasStore: true, connectionRequiresReauth: store.connectionRequiresReauth, connectionRecoveryFailed: store.connectionRecoveryFailed, @@ -1462,7 +1568,15 @@ struct WorkspaceShellView: View { names[mac.id] = mac.resolvedName } if let buildScope = MobileIOSBuildScope.current() { - names = buildScope.computerDisplayNames(names, isExternalHost: store.externalHostOwnsHost) + names = buildScope.computerDisplayNames( + names, + isExternalHost: store.externalHostOwnsHost + ) + } + // After the build-scope mapping: the dev tag suffix names a cmux Mac + // build, and an SSH host is not one. + for host in store.sshComputers.hosts { + names[store.sshComputerDeviceID(hostID: host.id)] = host.name } let buildLabelsByID = WorkspaceMacBuildLabelResolver().labels( @@ -1489,20 +1603,23 @@ struct WorkspaceShellView: View { agentFeedPendingReplyRequestIDs: store.agentFeedPendingReplyRequestIDs, agentFeedPendingTerminalReplyItemIDs: store.agentFeedPendingTerminalReplyItemIDs, toolbarMachineSnapshots: toolbarMachineSnapshots, - canCreateWorkspaceForSelection: scope.canCreateWorkspace( - base: canCreateWorkspace, - switchPending: pendingMacSwitchID != nil - ) + // The shared gate also covers SSH computers and the computer + // menu, which the Mac-only selection scope cannot see. + canCreateWorkspaceForSelection: canCreateWorkspaceForMacSelection ) } - private func agentFeedStoreView(for presentation: WorkspaceShellRenderPresentation) -> AgentFeedStoreView { + private func agentFeedStoreView( + for presentation: WorkspaceShellRenderPresentation, + isActive: Bool = true + ) -> AgentFeedStoreView { AgentFeedStoreView( store: store, items: presentation.agentFeedItems, status: presentation.agentFeedStatus, pendingReplyRequestIDs: presentation.agentFeedPendingReplyRequestIDs, pendingTerminalReplyItemIDs: presentation.agentFeedPendingTerminalReplyItemIDs, + isActive: isActive, searchCoordinator: primarySearchCoordinator ) } @@ -1606,9 +1723,17 @@ struct WorkspaceShellView: View { pendingPrimarySearchNotificationNavigationID = workspaceID transitionPrimaryTab(to: .notifications) case .mountedNotificationTab: - transitionPrimaryTab(to: .notifications) - if notificationNavigationPath.last != workspaceID { - notificationNavigationPath = [workspaceID] + if selectedPrimaryTab == .notifications, notificationsStackIsOnScreen { + if notificationNavigationPath.last != workspaceID { + notificationNavigationPath = [workspaceID] + } + } else { + // Keep the path write behind the destination stack's + // onAppear whenever it is not currently in the window. + pendingPrimarySearchNotificationNavigationID = workspaceID + if selectedPrimaryTab != .notifications { + transitionPrimaryTab(to: .notifications) + } } } return @@ -1641,7 +1766,10 @@ struct WorkspaceShellView: View { // Compact pushes must wait for the workspaces stack to be in the // window (its onAppear re-runs this); the split layout only writes // the store selection, which is safe at any time. - guard !usesCompactStack || workspacesStackIsOnScreen else { return } + guard + !usesCompactStack + || (selectedPrimaryTab == .workspaces && workspacesStackIsOnScreen) + else { return } guard let workspaceID = pendingPrimarySearchWorkspaceNavigationID else { return } pendingPrimarySearchWorkspaceNavigationID = nil selectWorkspaceImmediately(workspaceID) @@ -1649,7 +1777,7 @@ struct WorkspaceShellView: View { // The Feed has no search-result navigation lane yet. break case .notifications: - guard notificationsStackIsOnScreen else { return } + guard selectedPrimaryTab == .notifications, notificationsStackIsOnScreen else { return } guard let workspaceID = pendingPrimarySearchNotificationNavigationID else { return } pendingPrimarySearchNotificationNavigationID = nil if notificationNavigationPath.last != workspaceID { @@ -1779,6 +1907,11 @@ struct WorkspaceShellView: View { /// reference) is what crosses into the `List`-hosting view. private var refreshWorkspacesClosure: @Sendable () async -> Void { let store = store + // An SSH computer's pull-to-refresh is its explicit reconnect (and + // relist), which also resumes automatic connects it had paused. + if let hostID = selectedSSHHostID { + return { await store.openSSHComputer(hostID: hostID) } + } // Reconnect-or-refresh: when offline, pull-to-refresh re-attempts the saved // active Mac or the visible unavailable workspace owner instead of // no-opping, so the offline list can recover itself. @@ -1828,6 +1961,28 @@ struct WorkspaceShellView: View { return store.sshComputers.hosts.first?.id } + /// Status and empty state for the selected SSH computer. + private var sshWorkspaceListPanel: SSHWorkspaceListPanel? { + guard let hostID = selectedSSHHostID, + store.sshComputers.host(id: hostID) != nil else { return nil } + let deviceID = store.sshComputerDeviceID(hostID: hostID) + return SSHWorkspaceListPanel( + hostID: hostID, + status: store.sshComputers.statusByHost[hostID] ?? .idle, + hasWorkspaces: store.workspaces.contains { $0.macDeviceID == deviceID }, + willAutoConnect: store.sshComputers.canAutoConnect(hostID: hostID) + ) + } + + private var sshWorkspaceListPanelActions: SSHWorkspaceListPanelActions { + let store = store + return SSHWorkspaceListPanelActions( + refresh: { hostID in await store.openSSHComputer(hostID: hostID) }, + autoConnect: { hostID in store.autoConnectSSHComputer(hostID: hostID) }, + refreshConnected: { store.sshComputers.refreshConnectedHosts() } + ) + } + @MainActor func switchMacFromWorkspacePicker( macDeviceID: String, @@ -1865,6 +2020,7 @@ struct WorkspaceShellView: View { notificationFeedItems: store.notificationFeedItems, foregroundMacDeviceID: store.connectedMacDeviceID ?? store.activeTicket?.macDeviceID, foregroundInstanceTag: store.connectedMacInstanceTag, + locallyServedMachineIDs: Set(store.sshComputers.hosts.map { store.sshComputerDeviceID(hostID: $0.id) }), aliasesFor: { store.pairedMacAliasIDs(for: $0, instanceTag: $1) } diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/AgentFeedInlineTextTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/AgentFeedInlineTextTests.swift index b563237634a6..169a140fefa4 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/AgentFeedInlineTextTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/AgentFeedInlineTextTests.swift @@ -66,15 +66,19 @@ import UIKit let measuredSize = view.measure(width: 600) view.frame = CGRect(origin: .zero, size: measuredSize) view.layoutIfNeeded() - let lineY = view.bounds.midY - let linkPoint = try #require(stride(from: 0, to: view.bounds.width, by: 2) - .map { CGPoint(x: $0, y: lineY) } - .first { view.link(at: $0) != nil }) + let linkPoint = try #require( + stride(from: 0, to: view.bounds.height, by: 2) + .flatMap { y in + stride(from: 0, to: view.bounds.width, by: 2) + .map { CGPoint(x: $0, y: y) } + } + .first { view.link(at: $0) != nil } + ) - #expect(view.link(at: CGPoint(x: 1, y: lineY)) == nil) + #expect(view.link(at: CGPoint(x: 1, y: linkPoint.y)) == nil) #expect(view.activateLink(at: linkPoint)) #expect(opened.urls == [URL(string: "https://github.com/manaflow-ai/cmux/pull/14342")!]) - #expect(!view.activateLink(at: CGPoint(x: 1, y: lineY))) + #expect(!view.activateLink(at: CGPoint(x: 1, y: linkPoint.y))) #expect(opened.urls.count == 1) } diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/Debug/UITestConfig+NotificationFeedPreview.swift b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/Debug/UITestConfig+NotificationFeedPreview.swift index 4f3df38ad959..34e4cb2aa524 100644 --- a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/Debug/UITestConfig+NotificationFeedPreview.swift +++ b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/Debug/UITestConfig+NotificationFeedPreview.swift @@ -62,4 +62,5 @@ extension UITestConfig { return false #endif } + } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 7da3018e9f40..127330607844 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -698,6 +698,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private nonisolated static let persistedWindowGeometryDefaultsKey = SessionSnapshotPersistenceWriter.persistedWindowGeometryDefaultsKey #if DEBUG nonisolated static var debugPersistedWindowGeometryDefaultsKey: String { persistedWindowGeometryDefaultsKey } + + private nonisolated static func forgetPersistedWindowGeometryForTestProcess() { + UserDefaults.standard.removeObject(forKey: persistedWindowGeometryDefaultsKey) + removeLegacyPersistedWindowGeometry() + } #endif weak var tabManager: TabManager? diff --git a/cmuxCLITests/BundledCLITestSupport.swift b/cmuxCLITests/BundledCLITestSupport.swift index 2aebcc02f600..f6deab95c3d9 100644 --- a/cmuxCLITests/BundledCLITestSupport.swift +++ b/cmuxCLITests/BundledCLITestSupport.swift @@ -7,6 +7,41 @@ import XCTest /// has no app host, so it resolves it from `CMUX_CLI_PATH` or relative to the /// built products directory the bundle itself was copied into. enum BundledCLITestSupport { + static func ciRuntimeSourceRootEnvironment() -> [String: String] { + let environment = ProcessInfo.processInfo.environment + guard let runtimeRoot = [ + environment["CMUX_CI_RUNTIME_SOURCE_ROOT"], + environment["TEST_RUNNER_CMUX_CI_RUNTIME_SOURCE_ROOT"], + ].compactMap({ $0 }).first(where: { !$0.isEmpty }) else { + return [:] + } + return [ + "CMUX_CI_RUNTIME_SOURCE_ROOT": runtimeRoot, + "TEST_RUNNER_CMUX_CI_RUNTIME_SOURCE_ROOT": runtimeRoot, + ] + } + + static func repositoryRoot(file: StaticString = #filePath) -> URL { + let environment = ProcessInfo.processInfo.environment + var roots: [URL] = [] + for key in ["CMUX_CI_RUNTIME_SOURCE_ROOT", "TEST_RUNNER_CMUX_CI_RUNTIME_SOURCE_ROOT"] { + if let runtimeRoot = environment[key], !runtimeRoot.isEmpty { + roots.append(URL(fileURLWithPath: runtimeRoot, isDirectory: true) + .appendingPathComponent("src", isDirectory: true)) + } + } + let fileRoot = URL(fileURLWithPath: String(describing: file)) + .deletingLastPathComponent() + .deletingLastPathComponent() + roots.append(fileRoot) + roots.append(URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)) + return roots.first(where: { + FileManager.default.isExecutableFile( + atPath: $0.appendingPathComponent("Resources/bin/cmux-claude-wrapper").path + ) + }) ?? fileRoot + } + /// The daemon handshake must match the CLI under test, not the xctest host. static func appVersion(cliPath: String) throws -> String { let result = CLIHookProcessRunner.run( diff --git a/cmuxCLITests/CLIExplicitSurfaceRoutingTests.swift b/cmuxCLITests/CLIExplicitSurfaceRoutingTests.swift index 930c04ea0342..15f505a676a6 100644 --- a/cmuxCLITests/CLIExplicitSurfaceRoutingTests.swift +++ b/cmuxCLITests/CLIExplicitSurfaceRoutingTests.swift @@ -43,6 +43,49 @@ struct CLIExplicitSurfaceRoutingTests { ) } + @Test func vmTreeUsesCloudLinkErrorMessageInHumanOutput() throws { + let temporaryHome = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-cli-vm-tree-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: temporaryHome, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: temporaryHome) } + + let execution = try runMockCommand( + arguments: ["vm", "tree"], + socketName: "vm-tree-link-error", + environmentOverrides: [ + "CFFIXED_USER_HOME": temporaryHome.path, + "HOME": temporaryHome.path, + ] + ) { line in + guard let request = Self.jsonObject(line), + let id = request["id"] as? String, + request["method"] as? String == "surface.catalog" else { + return Self.malformedRequestResponse(raw: line) + } + return Self.v2Response( + id: id, + ok: true, + result: [ + "machines": [[ + "id": "brave-otter", + "status": "running", + "link_state": "error", + "link_error": "cloud_api_unavailable", + "link_error_message": "cmux cannot reach the Cloud service for this machine right now." + ]], + "resources": [] + ] + ) + } + + #expect(execution.result.status == 0, Comment(rawValue: execution.result.stderr)) + #expect( + execution.result.stdout.contains("cmux cannot reach the Cloud service"), + Comment(rawValue: execution.result.stdout) + ) + #expect(!execution.result.stdout.contains("cloud_api_unavailable")) + } + @Test func sendKeyCommandsRejectExtraArgumentsWithoutSocketRequest() throws { let cases: [[String]] = [ ["send-key", "--surface", Self.targetSurfaceRef, "ctrl+c", "enter"], diff --git a/cmuxTests/SwiftTestingAssertions.swift b/cmuxTests/SwiftTestingAssertions.swift index fa41cdae638d..93379f9dd2b1 100644 --- a/cmuxTests/SwiftTestingAssertions.swift +++ b/cmuxTests/SwiftTestingAssertions.swift @@ -8,20 +8,24 @@ import Testing struct SwiftTestingAssertions { /// Resolves a test source file from its module-relative identity. static func sourceURL(_ file: StaticString = #fileID) -> URL { - let sourceRoot: URL - if let runtimeRoot = ProcessInfo.processInfo.environment["CMUX_CI_RUNTIME_SOURCE_ROOT"], - !runtimeRoot.isEmpty { - sourceRoot = URL(fileURLWithPath: runtimeRoot, isDirectory: true) - .appendingPathComponent("src", isDirectory: true) - } else { - sourceRoot = URL(fileURLWithPath: #filePath) + let fileID = String(describing: file) + let environment = ProcessInfo.processInfo.environment + var roots: [URL] = [] + for key in ["CMUX_CI_RUNTIME_SOURCE_ROOT", "TEST_RUNNER_CMUX_CI_RUNTIME_SOURCE_ROOT"] { + if let runtimeRoot = environment[key], !runtimeRoot.isEmpty { + roots.append(URL(fileURLWithPath: runtimeRoot, isDirectory: true) + .appendingPathComponent("src", isDirectory: true)) + } + } + roots.append( + URL(fileURLWithPath: #filePath) .deletingLastPathComponent() .deletingLastPathComponent() - } - - let fileID = String(describing: file) + ) + roots.append(URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)) // #fileID is "/"; the module name matches the repo directory. - return sourceRoot.appendingPathComponent(fileID) + let candidates = roots.map { $0.appendingPathComponent(fileID) } + return candidates.first(where: { FileManager.default.fileExists(atPath: $0.path) }) ?? candidates[0] } diff --git a/cmuxTests/WorkspaceCloseTabsContextMenuTests.swift b/cmuxTests/WorkspaceCloseTabsContextMenuTests.swift index 3a5e4d6774c2..065a6b6f0d43 100644 --- a/cmuxTests/WorkspaceCloseTabsContextMenuTests.swift +++ b/cmuxTests/WorkspaceCloseTabsContextMenuTests.swift @@ -96,8 +96,8 @@ struct WorkspaceCloseTabsContextMenuTests { for: tab, inPane: fixture.paneId ) - drainMainQueue() - drainMainQueue() + drainMainQueueForCloseTest(timeout: mainActorTestMainQueueSpin) + drainMainQueueForCloseTest(timeout: mainActorTestMainQueueSpin) #expect(promptCount == 1) #expect(fixture.workspace.panelIdFromSurfaceId(tabId) == nil) diff --git a/ios/cmuxUITests/cmuxUITests.swift b/ios/cmuxUITests/cmuxUITests.swift index 18ced8f7f58d..f5d8cf3d2275 100644 --- a/ios/cmuxUITests/cmuxUITests.swift +++ b/ios/cmuxUITests/cmuxUITests.swift @@ -4769,6 +4769,58 @@ final class cmuxUITests: XCTestCase { } } + @MainActor + func testNotificationTabSwitchKeepsSharedRootToolbarMounted() throws { + let app = launchApp(mockData: false, environment: [ + "CMUX_UITEST_NOTIFICATION_FEED_PREVIEW": "1", + "CMUX_UITEST_NOTIFICATION_FEED_PREVIEW_TAB_SWITCH": "1", + ]) + defer { app.terminate() } + + let feed = app.descendants(matching: .any)["MobileNotificationFeed"] + XCTAssertTrue(feed.waitForExistence(timeout: 8)) + XCTAssertTrue(app.buttons["MobileNotificationFeedMarkAllRead"].waitForExistence(timeout: 3)) + XCTAssertTrue(app.buttons["MobileNotificationFeedFilterMenu"].waitForExistence(timeout: 3)) + + let picker = app.buttons["MobileWorkspaceMacPicker"] + XCTAssertTrue(picker.waitForExistence(timeout: 3)) + XCTAssertTrue(app.tabBars.buttons["Notifications"].isSelected) + + let workspacesTab = app.tabBars.buttons["Workspaces"] + XCTAssertTrue(workspacesTab.waitForExistence(timeout: 3)) + // The DEBUG fixture holds the initial Notifications state for eight + // seconds, then waits 700 ms before its first Workspaces switch. + let workspacesDeadline = Date().addingTimeInterval(15) + while !workspacesTab.isSelected && Date() < workspacesDeadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.03)) + } + XCTAssertTrue( + workspacesTab.isSelected, + "The repeated-switch fixture never reached its Workspaces phase" + ) + + // The fixture switches tabs six times at 700 ms intervals. Sample the + // shared toolbar while those transitions are in flight so a transient + // unmount or blank frame cannot pass by behind a settled-state wait. + let transitionDeadline = Date().addingTimeInterval(9) + var sampleCount = 0 + while Date() < transitionDeadline { + XCTAssertTrue( + picker.exists, + "Shared Mac picker disappeared during a primary-tab transition" + ) + let frame = picker.frame + XCTAssertFalse(frame.isNull || frame.isEmpty) + XCTAssertEqual(frame.midX, app.frame.midX, accuracy: 2) + XCTAssertTrue(frame.intersects(app.frame)) + sampleCount += 1 + RunLoop.current.run(until: Date().addingTimeInterval(0.03)) + } + XCTAssertGreaterThan(sampleCount, 20) + XCTAssertTrue(waitForHittable(feed, timeout: 3)) + XCTAssertTrue(app.tabBars.buttons["Notifications"].isSelected) + } + /// Drives the production push coordinator through its three user-visible /// states: a parked tap while the Mac is disconnected, selection after the /// connection recovers, and an alert when the target tab is gone. diff --git a/scripts/ci/run-in-console-session.sh b/scripts/ci/run-in-console-session.sh index 985bceac855d..922846c90d38 100755 --- a/scripts/ci/run-in-console-session.sh +++ b/scripts/ci/run-in-console-session.sh @@ -239,7 +239,7 @@ if [ -n "$console_user" ] && [ "$console_user" != "root" ] \ CMUX_APP_HOST_XCODEBUILD_ATTEMPTS \ CMUX_RENDERER_MEMORY_REGRESSION \ CMUX_APP_HOST_CAPTURE_XCRESULTS CMUX_APP_HOST_RESULT_BUNDLE_ROOT CMUX_APP_HOST_TEST_CASE_TIMEOUT_SECONDS \ - GITHUB_REPOSITORY_ID GITHUB_RUN_ID GITHUB_RUN_ATTEMPT CMUX_APP_HOST_SHARD CMUX_APP_HOST_XCTESTRUN CMUX_NUMERIC_LOCALE_XCTESTRUN CMUX_CI_APP_HOST_ISOLATION_REQUIRED CMUX_APP_HOST_KEY CMUX_APP_HOST_HOME CMUX_APP_HOST_XDG_CONFIG_HOME CMUX_APP_HOST_RECEIPT_DIR CMUX_APP_HOST_CLEANUP_CONFIRMATION CMUX_APP_HOST_CONFIRMATION_FILE \ + GITHUB_REPOSITORY_ID GITHUB_RUN_ID GITHUB_RUN_ATTEMPT CMUX_APP_HOST_SHARD CMUX_APP_HOST_XCTESTRUN CMUX_NUMERIC_LOCALE_XCTESTRUN CMUX_CI_RUNTIME_SOURCE_ROOT CMUX_CI_APP_HOST_ISOLATION_REQUIRED CMUX_APP_HOST_KEY CMUX_APP_HOST_HOME CMUX_APP_HOST_XDG_CONFIG_HOME CMUX_APP_HOST_RECEIPT_DIR CMUX_APP_HOST_CLEANUP_CONFIRMATION CMUX_APP_HOST_CONFIRMATION_FILE \ CFFIXED_USER_HOME XDG_CONFIG_HOME CARGO_HOME RUSTUP_HOME) if [ "${CMUX_CI_APP_HOST_CLEANUP_TEST_HELPER:-0}" = "1" ]; then forward+=(CMUX_CI_APP_HOST_CLEANUP_TEST_HELPER CMUX_APP_HOST_LSOF CMUX_FAKE_LSOF_STATE)