From 61af99acd09cfc1d44eeb4def04488510a82c39d Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 6 Jun 2026 00:33:42 -0700 Subject: [PATCH 1/8] iOS: rename and pin workspaces from the phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a per-workspace context menu (Rename, Pin/Unpin) to the iOS workspace list, sorts pinned workspaces to the top with a pin glyph, and a rename sheet. The phone drives the Mac's existing workspace-scoped `workspace.action` RPC (pin/unpin/rename). Security: `MobileHostService` only newly authorizes `workspace.action`, and only its pin/unpin/rename sub-actions. The action param is normalized exactly as the handler's `v2ActionKey` (lowercase, '-'->'_') so the gate and handler can never disagree on which action runs, and workspace scope is enforced with the same check used for terminal input: a workspace-scoped ticket may only act on its own workspace, a Mac-wide pairing on any, a terminal-scoped ticket on none. The destructive/global sub-actions (move_*, close_*, set_color, …) and the global methods (reorder_many, group.*, the dedicated workspace.rename) stay Mac-only. New XCTest cases in MobileHostAuthorizationTests lock this down. The Mac now emits `is_pinned` in the mobile workspace-list payload, and the workspace-list observer watches `$isPinned` (and hashes it) so a pure pin toggle pushes to the phone. iOS decodes it backward-compatibly (nil on older Macs), updates the list optimistically with rollback on RPC failure, and the authoritative `workspace.updated` push reconciles. en+ja localized. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../MobileSyncWorkspaceListResponse.swift | 4 + ...MobileWorkspacePreview+RemoteMapping.swift | 1 + .../MobileShellComposite.swift | 60 +++++++ .../MobileWorkspacePreview.swift | 7 +- .../CmuxMobileShellUI/WorkspaceListView.swift | 36 +++- .../WorkspaceNavigationRow.swift | 39 ++++ .../WorkspaceRenameSheet.swift | 63 +++++++ .../CmuxMobileShellUI/WorkspaceRow.swift | 7 + .../WorkspaceShellView.swift | 16 +- Sources/Mobile/MobileHostService.swift | 21 +++ .../Mobile/MobileWorkspaceListObserver.swift | 5 + Sources/TerminalController.swift | 1 + cmuxTests/MobileHostAuthorizationTests.swift | 73 ++++++++ ios/cmux/Resources/Localizable.xcstrings | 170 ++++++++++++++---- 14 files changed, 458 insertions(+), 45 deletions(-) create mode 100644 Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceRenameSheet.swift diff --git a/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncWorkspaceListResponse.swift b/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncWorkspaceListResponse.swift index 8e60dfd3e65f..4a2a033c667a 100644 --- a/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncWorkspaceListResponse.swift +++ b/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncWorkspaceListResponse.swift @@ -15,6 +15,9 @@ public struct MobileSyncWorkspaceListResponse: Decodable, Sendable { public let currentDirectory: String? /// Whether the Mac currently has this workspace selected. public let isSelected: Bool + /// Whether this workspace is pinned, if the Mac reported it. `nil` when + /// connected to a Mac old enough not to emit `is_pinned`. + public let isPinned: Bool? /// Terminals belonging to this workspace. public let terminals: [Terminal] @@ -23,6 +26,7 @@ public struct MobileSyncWorkspaceListResponse: Decodable, Sendable { case title case currentDirectory = "current_directory" case isSelected = "is_selected" + case isPinned = "is_pinned" case terminals } } diff --git a/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspacePreview+RemoteMapping.swift b/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspacePreview+RemoteMapping.swift index 2d0dda24f7e5..b6e017b535a1 100644 --- a/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspacePreview+RemoteMapping.swift +++ b/Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspacePreview+RemoteMapping.swift @@ -7,6 +7,7 @@ extension MobileWorkspacePreview { self.init( id: ID(rawValue: remote.id), name: remote.title, + isPinned: remote.isPinned ?? false, terminals: remote.terminals.map { terminal in MobileTerminalPreview(remote: terminal) } diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index a50456942d38..1704a74537e8 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -310,6 +310,66 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } } + // MARK: - Workspace actions + + /// Rename a workspace on the Mac. Updates the local list optimistically and + /// rolls back if the RPC fails; the Mac's authoritative `workspace.updated` + /// push reconciles the final title. + /// - Parameters: + /// - id: The workspace to rename. + /// - title: The new title. Whitespace-only titles are ignored. + public func renameWorkspace(id: MobileWorkspacePreview.ID, title: String) async { + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let client = remoteClient else { return } + let previous = workspaces + if let index = workspaces.firstIndex(where: { $0.id == id }) { + workspaces[index].name = trimmed + } + do { + let request = try MobileCoreRPCClient.requestData( + method: "workspace.action", + params: [ + "workspace_id": id.rawValue, + "action": "rename", + "title": trimmed, + "client_id": clientID, + ] + ) + _ = try await client.sendRequest(request) + } catch { + workspaces = previous + mobileShellLog.error("workspace rename failed id=\(id.rawValue, privacy: .public) error=\(String(describing: error), privacy: .public)") + } + } + + /// Pin or unpin a workspace on the Mac. Updates the local list optimistically + /// and rolls back if the RPC fails; the Mac's authoritative `workspace.updated` + /// push reconciles the final pin state. + /// - Parameters: + /// - id: The workspace to pin or unpin. + /// - pinned: `true` to pin, `false` to unpin. + public func setWorkspacePinned(id: MobileWorkspacePreview.ID, _ pinned: Bool) async { + guard let client = remoteClient else { return } + let previous = workspaces + if let index = workspaces.firstIndex(where: { $0.id == id }) { + workspaces[index].isPinned = pinned + } + do { + let request = try MobileCoreRPCClient.requestData( + method: "workspace.action", + params: [ + "workspace_id": id.rawValue, + "action": pinned ? "pin" : "unpin", + "client_id": clientID, + ] + ) + _ = try await client.sendRequest(request) + } catch { + workspaces = previous + mobileShellLog.error("workspace pin failed id=\(id.rawValue, privacy: .public) error=\(String(describing: error), privacy: .public)") + } + } + // MARK: - Network recovery /// True while an automatic reconnect is in progress after a network change diff --git a/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspacePreview.swift b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspacePreview.swift index 27ddc39c3ff7..a7581ce07845 100644 --- a/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspacePreview.swift +++ b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspacePreview.swift @@ -29,6 +29,9 @@ public struct MobileWorkspacePreview: Identifiable, Equatable, Sendable { public var id: ID /// The workspace's user-facing display name. public var name: String + /// Whether the workspace is pinned on the Mac. Pinned workspaces sort to the + /// top of the mobile list. + public var isPinned: Bool /// The terminals contained in the workspace, in display order. public var terminals: [MobileTerminalPreview] @@ -36,10 +39,12 @@ public struct MobileWorkspacePreview: Identifiable, Equatable, Sendable { /// - Parameters: /// - id: The workspace's stable identifier. /// - name: The workspace's user-facing display name. + /// - isPinned: Whether the workspace is pinned on the Mac. Defaults to `false`. /// - terminals: The terminals contained in the workspace, in display order. - public init(id: ID, name: String, terminals: [MobileTerminalPreview]) { + public init(id: ID, name: String, isPinned: Bool = false, terminals: [MobileTerminalPreview]) { self.id = id self.name = name + self.isPinned = isPinned self.terminals = terminals } } diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift index 9b4c950ed074..9ff06e840edf 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift @@ -20,20 +20,38 @@ struct WorkspaceListView: View { /// previews), the menu is hidden. var rescanQR: (() -> Void)? var signOut: (() -> Void)? + /// Optional: rename a workspace on the Mac. When present, each row offers a + /// Rename context-menu action. + var renameWorkspace: ((MobileWorkspacePreview.ID, String) -> Void)? + /// Optional: pin/unpin a workspace on the Mac. When present, each row offers + /// a Pin/Unpin context-menu action and pinned workspaces sort to the top. + var setPinned: ((MobileWorkspacePreview.ID, Bool) -> Void)? @State private var searchText = "" @State private var showingShortcutsSettings = false @State private var showingSettings = false + /// Workspaces after search filtering, pinned ones first (stable within each + /// group so the Mac's order is otherwise preserved). private var filteredWorkspaces: [MobileWorkspacePreview] { let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) - guard !query.isEmpty else { - return workspaces - } - return workspaces.filter { workspace in - workspace.name.localizedCaseInsensitiveContains(query) - || workspace.previewLine.localizedCaseInsensitiveContains(query) - || workspace.terminals.contains { $0.name.localizedCaseInsensitiveContains(query) } + let matches: [MobileWorkspacePreview] + if query.isEmpty { + matches = workspaces + } else { + matches = workspaces.filter { workspace in + workspace.name.localizedCaseInsensitiveContains(query) + || workspace.previewLine.localizedCaseInsensitiveContains(query) + || workspace.terminals.contains { $0.name.localizedCaseInsensitiveContains(query) } + } } + return matches.enumerated() + .sorted { lhs, rhs in + if lhs.element.isPinned != rhs.element.isPinned { + return lhs.element.isPinned + } + return lhs.offset < rhs.offset + } + .map(\.element) } var body: some View { @@ -53,7 +71,9 @@ struct WorkspaceListView: View { connectionStatus: connectionStatus, isSelected: navigationStyle == .sidebar && selectedWorkspaceID == workspace.id, navigationStyle: navigationStyle, - selectWorkspace: selectWorkspace + selectWorkspace: selectWorkspace, + renameWorkspace: renameWorkspace, + setPinned: setPinned ) .listRowInsets(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12)) .listRowSeparator(.hidden) diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceNavigationRow.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceNavigationRow.swift index 4f985a55ce24..12631b49f39b 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceNavigationRow.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceNavigationRow.swift @@ -1,4 +1,5 @@ import CmuxMobileShellModel +import CmuxMobileSupport import SwiftUI struct WorkspaceNavigationRow: View { @@ -8,6 +9,14 @@ struct WorkspaceNavigationRow: View { let isSelected: Bool let navigationStyle: WorkspaceNavigationStyle let selectWorkspace: (MobileWorkspacePreview.ID) -> Void + /// Rename the workspace on the Mac. When `nil` (e.g. previews) the rename + /// affordance is hidden. + var renameWorkspace: ((MobileWorkspacePreview.ID, String) -> Void)? + /// Pin or unpin the workspace on the Mac. When `nil` the pin affordance is + /// hidden. + var setPinned: ((MobileWorkspacePreview.ID, Bool) -> Void)? + + @State private var isRenaming = false var body: some View { Group { @@ -28,10 +37,40 @@ struct WorkspaceNavigationRow: View { .buttonStyle(.plain) } } + .contextMenu { contextMenu } .accessibilityElement(children: .combine) .accessibilityAddTraits(.isButton) .accessibilityIdentifier("MobileWorkspaceRow-\(workspace.id.rawValue)") .accessibilityLabel(workspace.name) .accessibilityValue(workspace.accessibilitySummary(host: host, connectionStatus: connectionStatus)) + .sheet(isPresented: $isRenaming) { + WorkspaceRenameSheet(currentName: workspace.name) { newName in + renameWorkspace?(workspace.id, newName) + } + } + } + + @ViewBuilder + private var contextMenu: some View { + if let setPinned { + Button { + setPinned(workspace.id, !workspace.isPinned) + } label: { + if workspace.isPinned { + Label(L10n.string("mobile.workspace.unpin", defaultValue: "Unpin"), systemImage: "pin.slash") + } else { + Label(L10n.string("mobile.workspace.pin", defaultValue: "Pin"), systemImage: "pin") + } + } + .accessibilityIdentifier("MobileWorkspacePinButton-\(workspace.id.rawValue)") + } + if renameWorkspace != nil { + Button { + isRenaming = true + } label: { + Label(L10n.string("mobile.workspace.rename.action", defaultValue: "Rename"), systemImage: "pencil") + } + .accessibilityIdentifier("MobileWorkspaceRenameButton-\(workspace.id.rawValue)") + } } } diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceRenameSheet.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceRenameSheet.swift new file mode 100644 index 000000000000..484cc1b1ab81 --- /dev/null +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceRenameSheet.swift @@ -0,0 +1,63 @@ +#if os(iOS) +import CmuxMobileSupport +import SwiftUI + +/// A small sheet that renames a workspace from the mobile workspace list. +/// +/// Seeds its text field with the current name and hands the trimmed result back +/// through `onSave`; the caller forwards it to the Mac. Whitespace-only names are +/// rejected (Save is disabled). +struct WorkspaceRenameSheet: View { + @Environment(\.dismiss) private var dismiss + private let onSave: (String) -> Void + @State private var name: String + + /// Creates the rename sheet. + /// - Parameters: + /// - currentName: The workspace's current name, used to seed the field. + /// - onSave: Called with the new trimmed name when the user taps Save. + init(currentName: String, onSave: @escaping (String) -> Void) { + self.onSave = onSave + _name = State(initialValue: currentName) + } + + var body: some View { + NavigationStack { + Form { + TextField( + L10n.string("mobile.workspace.rename.placeholder", defaultValue: "Workspace name"), + text: $name + ) + .autocorrectionDisabled() + .submitLabel(.done) + .onSubmit(save) + .accessibilityIdentifier("WorkspaceRenameField") + } + .navigationTitle(L10n.string("mobile.workspace.rename.title", defaultValue: "Rename Workspace")) + .mobileInlineNavigationTitle() + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.string("mobile.common.cancel", defaultValue: "Cancel")) { + dismiss() + } + .accessibilityIdentifier("WorkspaceRenameCancelButton") + } + ToolbarItem(placement: .confirmationAction) { + Button(L10n.string("mobile.common.save", defaultValue: "Save")) { + save() + } + .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .accessibilityIdentifier("WorkspaceRenameSaveButton") + } + } + } + } + + private func save() { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + onSave(trimmed) + dismiss() + } +} +#endif diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceRow.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceRow.swift index 26ef3be68589..e3f0e18a04ec 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceRow.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceRow.swift @@ -14,6 +14,13 @@ struct WorkspaceRow: View { VStack(alignment: .leading, spacing: 4) { HStack(alignment: .firstTextBaseline, spacing: 8) { + if workspace.isPinned { + Image(systemName: "pin.fill") + .font(.caption2) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + } + Text(workspace.name) .font(.headline) .foregroundStyle(isSelected ? Color.accentColor : Color.primary) diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift index b919cad03ae1..3dcbc960d10f 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift @@ -63,7 +63,9 @@ struct WorkspaceShellView: View { selectWorkspace: selectWorkspace, createWorkspace: createWorkspaceInCompactStack, rescanQR: { store.disconnectAndForgetActiveMac() }, - signOut: signOut + signOut: signOut, + renameWorkspace: renameWorkspace, + setPinned: setWorkspacePinned ) .navigationDestination(for: MobileWorkspacePreview.ID.self) { workspaceID in workspaceDestination(for: workspaceID, createWorkspace: createWorkspaceInCompactStack) @@ -113,7 +115,9 @@ struct WorkspaceShellView: View { selectWorkspace: selectWorkspace, createWorkspace: store.createWorkspace, rescanQR: { store.disconnectAndForgetActiveMac() }, - signOut: signOut + signOut: signOut, + renameWorkspace: renameWorkspace, + setPinned: setWorkspacePinned ) .navigationSplitViewColumnWidth(min: 320, ideal: 380, max: 440) } detail: { @@ -137,6 +141,14 @@ struct WorkspaceShellView: View { } } + private func renameWorkspace(_ id: MobileWorkspacePreview.ID, _ title: String) { + Task { await store.renameWorkspace(id: id, title: title) } + } + + private func setWorkspacePinned(_ id: MobileWorkspacePreview.ID, _ pinned: Bool) { + Task { await store.setWorkspacePinned(id: id, pinned) } + } + private func createWorkspaceInCompactStack() { let existingWorkspaceIDs = Set(store.workspaces.map(\.id)) pendingCompactCreateNavigationWorkspaceIDs = existingWorkspaceIDs diff --git a/Sources/Mobile/MobileHostService.swift b/Sources/Mobile/MobileHostService.swift index b1dae06a8cb9..7eafb9f1e969 100644 --- a/Sources/Mobile/MobileHostService.swift +++ b/Sources/Mobile/MobileHostService.swift @@ -1235,6 +1235,27 @@ final class MobileHostService { return nil case "mobile.host.status": return nil + case "workspace.action": + // Mobile may only pin/unpin/rename a workspace it is scoped to. The + // other workspace.action sub-actions (move_*, close_*, set_color, + // mark_*, set_description) reorder the global sidebar or destroy + // sibling workspaces, so they stay Mac-only. Normalize the action + // exactly as the handler's `v2ActionKey` does (lowercase, '-'→'_') so + // the gate and the handler can never disagree on which action runs, + // then enforce the same workspace scope used for terminal input. + let requestedAction = (request.params["action"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: "-", with: "_") + guard let requestedAction, + ["pin", "unpin", "rename"].contains(requestedAction) else { + return scopedTicketError + } + return ticketTerminalAuthorizationError( + authorization: authorization, + workspaceSelection: workspaceSelection.value, + terminalSelection: terminalSelection.value + ) default: return scopedTicketError } diff --git a/Sources/Mobile/MobileWorkspaceListObserver.swift b/Sources/Mobile/MobileWorkspaceListObserver.swift index e335cc69d68d..09b124a4f5a6 100644 --- a/Sources/Mobile/MobileWorkspaceListObserver.swift +++ b/Sources/Mobile/MobileWorkspaceListObserver.swift @@ -80,6 +80,10 @@ final class MobileWorkspaceListObserver { // so without this a terminal rename never re-emits to the phone. workspace.$panelCustomTitles.map { _ in () }.eraseToAnyPublisher(), workspace.$title.map { _ in () }.eraseToAnyPublisher(), + // Pin/unpin is iOS-facing (the phone shows a Pinned section), and + // a pure pin toggle need not change the panel set or title, so + // without this the phone never learns the workspace was pinned. + workspace.$isPinned.map { _ in () }.eraseToAnyPublisher(), workspace.$currentDirectory.map { _ in () }.eraseToAnyPublisher(), workspace.$panelDirectories.map { _ in () }.eraseToAnyPublisher(), // Pure drag-reorders change spatial order without changing the panel @@ -130,6 +134,7 @@ final class MobileWorkspaceListObserver { for workspace in tabs { hasher.combine(workspace.id) hasher.combine(workspace.title) + hasher.combine(workspace.isPinned) // Spatial order is significant: hash the ordered id sequence so a // reorder of the same panel set changes the hash. let panelIDs = workspace.orderedPanelIds diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index a7ad06cf58f7..8748914c4867 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -20923,6 +20923,7 @@ class TerminalController { "title": workspace.title, "current_directory": v2OrNull(mobileNonEmpty(workspace.currentDirectory)), "is_selected": workspace.id == tabManager.selectedTabId, + "is_pinned": workspace.isPinned, "terminals": terminals ] } diff --git a/cmuxTests/MobileHostAuthorizationTests.swift b/cmuxTests/MobileHostAuthorizationTests.swift index 54a706778d85..ad691115c80f 100644 --- a/cmuxTests/MobileHostAuthorizationTests.swift +++ b/cmuxTests/MobileHostAuthorizationTests.swift @@ -1109,6 +1109,79 @@ final class MobileHostAuthorizationTests: XCTestCase { XCTAssertEqual(recordedMethods, ["workspace.list"]) } + // MARK: - workspace.action authorization (rename / pin) + + private func mobileAuthError( + ticket: CmxAttachTicket, + method: String, + params: [String: Any] + ) -> MobileHostRPCError? { + let request = MobileHostRPCRequest( + id: "workspace-action", + method: method, + params: params, + auth: MobileHostRPCAuth(attachToken: ticket.authToken, stackAccessToken: nil) + ) + return MobileHostService.debugTicketAuthorizationError(ticket: ticket, request: request) + } + + func testWorkspaceScopedTicketAllowsPinUnpinRenameForOwnWorkspace() throws { + let ticket = try scopedAttachTicket(workspaceID: "workspace", terminalID: nil) + XCTAssertNil(mobileAuthError(ticket: ticket, method: "workspace.action", params: ["action": "pin", "workspace_id": "workspace"])) + XCTAssertNil(mobileAuthError(ticket: ticket, method: "workspace.action", params: ["action": "unpin", "workspace_id": "workspace"])) + XCTAssertNil(mobileAuthError(ticket: ticket, method: "workspace.action", params: ["action": "rename", "workspace_id": "workspace", "title": "Renamed"])) + } + + func testWorkspaceScopedTicketRejectsActionForOtherWorkspace() throws { + let ticket = try scopedAttachTicket(workspaceID: "workspace", terminalID: nil) + XCTAssertEqual(mobileAuthError(ticket: ticket, method: "workspace.action", params: ["action": "pin", "workspace_id": "other"])?.code, "forbidden") + XCTAssertEqual(mobileAuthError(ticket: ticket, method: "workspace.action", params: ["action": "rename", "workspace_id": "other", "title": "x"])?.code, "forbidden") + } + + func testWorkspaceActionRejectsDestructiveAndGlobalSubActions() throws { + let ticket = try scopedAttachTicket(workspaceID: "workspace", terminalID: nil) + // move_* reorders the global sidebar; close_* destroys sibling + // workspaces; the rest mutate non-pin/rename state. All stay Mac-only. + for action in ["move_up", "move_down", "move_top", "close_others", "close_above", "close_below", "set_color", "clear_color", "set_description", "clear_description", "clear_name", "mark_read", "mark_unread"] { + XCTAssertEqual( + mobileAuthError(ticket: ticket, method: "workspace.action", params: ["action": action, "workspace_id": "workspace"])?.code, + "forbidden", + "workspace.action '\(action)' must be denied for a mobile ticket" + ) + } + } + + func testWorkspaceActionRequiresAnAllowedActionParam() throws { + let ticket = try scopedAttachTicket(workspaceID: "workspace", terminalID: nil) + // Missing action, and an unknown action, are denied. + XCTAssertEqual(mobileAuthError(ticket: ticket, method: "workspace.action", params: ["workspace_id": "workspace"])?.code, "forbidden") + XCTAssertEqual(mobileAuthError(ticket: ticket, method: "workspace.action", params: ["action": "self_destruct", "workspace_id": "workspace"])?.code, "forbidden") + // The gate normalizes the action exactly as the handler does, so an + // uppercase/hyphenated alias of an allowed action is still accepted. + XCTAssertNil(mobileAuthError(ticket: ticket, method: "workspace.action", params: ["action": "PIN", "workspace_id": "workspace"])) + } + + func testPairedDeviceTicketAllowsWorkspaceActionForAnyWorkspace() throws { + // An empty ticket workspaceID is a Mac-wide pairing; it may pin/rename any + // workspace, consistent with how it may input to any terminal. + let ticket = try scopedAttachTicket(workspaceID: "", terminalID: nil) + XCTAssertNil(mobileAuthError(ticket: ticket, method: "workspace.action", params: ["action": "pin", "workspace_id": "any-workspace"])) + XCTAssertNil(mobileAuthError(ticket: ticket, method: "workspace.action", params: ["action": "rename", "workspace_id": "any-workspace", "title": "x"])) + } + + func testGlobalWorkspaceMethodsRemainDeniedForMobile() throws { + let ticket = try scopedAttachTicket(workspaceID: "", terminalID: nil) + // Global / group mutations and the dedicated rename method are not in the + // mobile allowlist; rename is routed through the gated workspace.action. + for method in ["workspace.reorder_many", "workspace.rename", "workspace.group.pin", "workspace.group.create", "workspace.group.delete"] { + XCTAssertEqual( + mobileAuthError(ticket: ticket, method: method, params: ["workspace_id": "any-workspace", "title": "x"])?.code, + "forbidden", + "method '\(method)' must stay Mac-only for a mobile ticket" + ) + } + } + private func scopedAttachTicket(workspaceID: String, terminalID: String?) throws -> CmxAttachTicket { let route = try CmxAttachRoute( id: "debug", diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 95fc6fd17106..5872c2bccd4a 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -647,6 +647,23 @@ } } }, + "mobile.common.save": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保存" + } + } + } + }, "mobile.connection.connected": { "extractionState": "manual", "localizations": { @@ -2551,6 +2568,40 @@ } } }, + "mobile.testflight.help": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "TestFlight is invite-only for now. Get the Founders Edition to enroll and to download cmux for Mac." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "TestFlight は現在招待制です。Founders Edition から登録し、Mac 版 cmux をダウンロードしてください。" + } + } + } + }, + "mobile.testflight.link": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Download via TestFlight" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "TestFlight でダウンロード" + } + } + } + }, "mobile.workspace.emptyTitle": { "extractionState": "manual", "localizations": { @@ -2585,6 +2636,74 @@ } } }, + "mobile.workspace.pin": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pin" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ピン留め" + } + } + } + }, + "mobile.workspace.rename.action": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rename" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "名前を変更" + } + } + } + }, + "mobile.workspace.rename.placeholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace name" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペース名" + } + } + } + }, + "mobile.workspace.rename.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rename Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースの名前を変更" + } + } + } + }, "mobile.workspace.terminalCountFormat.one": { "extractionState": "manual", "localizations": { @@ -2619,6 +2738,23 @@ } } }, + "mobile.workspace.unpin": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unpin" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ピン留めを解除" + } + } + } + }, "mobile.workspaces.title": { "extractionState": "manual", "localizations": { @@ -2873,40 +3009,6 @@ } } } - }, - "mobile.testflight.link": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Download via TestFlight" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "TestFlight でダウンロード" - } - } - } - }, - "mobile.testflight.help": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "TestFlight is invite-only for now. Get the Founders Edition to enroll and to download cmux for Mac." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "TestFlight は現在招待制です。Founders Edition から登録し、Mac 版 cmux をダウンロードしてください。" - } - } - } } }, "version": "1.0" From d5249355c9a677d15d583b4fb899b9f15c8fd04b Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 6 Jun 2026 00:54:23 -0700 Subject: [PATCH 2/8] Fix PR2 mobile gate: route workspace.action through the real mobile dispatch The live mobile data plane authorizes identity via same-Stack-account verification and gates method exposure with an explicit allowlist in TerminalController.mobileHostHandleRPC (default -> method_not_found), not via MobileHostService.ticketAuthorizationError (which is only reached by the test hook). The earlier allowlist edit + tests targeted that test-only function, so workspace.action would have returned method_not_found at runtime and rename/pin would silently fail. Revert the ineffective MobileHostService change and its tests. Add a gated case "workspace.action" to mobileHostHandleRPC via v2MobileWorkspaceAction, which rejects every sub-action except pin/unpin/rename (normalized exactly as v2ActionKey) before calling v2WorkspaceAction, so move_*/close_*/set_color/etc. stay Mac-only. Cover the gate with a pure mobileAllowsWorkspaceAction test. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/Mobile/MobileHostService.swift | 21 ----- Sources/TerminalController.swift | 33 ++++++++ cmuxTests/MobileHostAuthorizationTests.swift | 82 ++++---------------- 3 files changed, 49 insertions(+), 87 deletions(-) diff --git a/Sources/Mobile/MobileHostService.swift b/Sources/Mobile/MobileHostService.swift index 7eafb9f1e969..b1dae06a8cb9 100644 --- a/Sources/Mobile/MobileHostService.swift +++ b/Sources/Mobile/MobileHostService.swift @@ -1235,27 +1235,6 @@ final class MobileHostService { return nil case "mobile.host.status": return nil - case "workspace.action": - // Mobile may only pin/unpin/rename a workspace it is scoped to. The - // other workspace.action sub-actions (move_*, close_*, set_color, - // mark_*, set_description) reorder the global sidebar or destroy - // sibling workspaces, so they stay Mac-only. Normalize the action - // exactly as the handler's `v2ActionKey` does (lowercase, '-'→'_') so - // the gate and the handler can never disagree on which action runs, - // then enforce the same workspace scope used for terminal input. - let requestedAction = (request.params["action"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - .replacingOccurrences(of: "-", with: "_") - guard let requestedAction, - ["pin", "unpin", "rename"].contains(requestedAction) else { - return scopedTicketError - } - return ticketTerminalAuthorizationError( - authorization: authorization, - workspaceSelection: workspaceSelection.value, - terminalSelection: terminalSelection.value - ) default: return scopedTicketError } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 8748914c4867..57f29afe5c2d 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -20702,6 +20702,8 @@ class TerminalController { result = v2MobileTerminalScroll(params: request.params) case "mobile.terminal.mouse", "terminal.mouse": result = v2MobileTerminalMouse(params: request.params) + case "workspace.action": + result = v2MobileWorkspaceAction(params: request.params) default: result = .err(code: "method_not_found", message: "Unknown mobile method", data: [ "method": request.method @@ -20710,6 +20712,37 @@ class TerminalController { return mobileHostResult(result) } + /// The `workspace.action` sub-actions the mobile data plane may invoke. + /// + /// Mobile gets pin/unpin/rename only. The other sub-actions of + /// ``v2WorkspaceAction(params:)`` (`move_*`, `close_*`, `set_color`, + /// `set_description`, `mark_*`, …) reorder the global sidebar or destroy + /// sibling workspaces, so they stay on the Mac/automation socket. The action + /// is normalized exactly as ``v2ActionKey(_:_:)`` so this gate and the + /// handler can never disagree on which action runs. + /// - Parameter rawAction: The raw `action` param value. + /// - Returns: `true` when the normalized action is mobile-allowed. + nonisolated static func mobileAllowsWorkspaceAction(_ rawAction: String?) -> Bool { + guard let trimmed = rawAction?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { return false } + let normalized = trimmed.lowercased().replacingOccurrences(of: "-", with: "_") + return ["pin", "unpin", "rename"].contains(normalized) + } + + /// Mobile-gated wrapper over ``v2WorkspaceAction(params:)``: rejects every + /// sub-action except pin/unpin/rename before dispatching. + private func v2MobileWorkspaceAction(params: [String: Any]) -> V2CallResult { + let rawAction = v2RawString(params, "action") + guard Self.mobileAllowsWorkspaceAction(rawAction) else { + return .err( + code: "method_not_found", + message: "Unsupported workspace action for mobile", + data: ["action": v2OrNull(rawAction)] + ) + } + return v2WorkspaceAction(params: params) + } + private func mobileHostResult(_ result: V2CallResult) -> MobileHostRPCResult { switch result { case let .ok(payload): diff --git a/cmuxTests/MobileHostAuthorizationTests.swift b/cmuxTests/MobileHostAuthorizationTests.swift index ad691115c80f..f27108f092a0 100644 --- a/cmuxTests/MobileHostAuthorizationTests.swift +++ b/cmuxTests/MobileHostAuthorizationTests.swift @@ -1109,77 +1109,27 @@ final class MobileHostAuthorizationTests: XCTestCase { XCTAssertEqual(recordedMethods, ["workspace.list"]) } - // MARK: - workspace.action authorization (rename / pin) + // MARK: - Mobile workspace.action sub-action gate - private func mobileAuthError( - ticket: CmxAttachTicket, - method: String, - params: [String: Any] - ) -> MobileHostRPCError? { - let request = MobileHostRPCRequest( - id: "workspace-action", - method: method, - params: params, - auth: MobileHostRPCAuth(attachToken: ticket.authToken, stackAccessToken: nil) - ) - return MobileHostService.debugTicketAuthorizationError(ticket: ticket, request: request) - } - - func testWorkspaceScopedTicketAllowsPinUnpinRenameForOwnWorkspace() throws { - let ticket = try scopedAttachTicket(workspaceID: "workspace", terminalID: nil) - XCTAssertNil(mobileAuthError(ticket: ticket, method: "workspace.action", params: ["action": "pin", "workspace_id": "workspace"])) - XCTAssertNil(mobileAuthError(ticket: ticket, method: "workspace.action", params: ["action": "unpin", "workspace_id": "workspace"])) - XCTAssertNil(mobileAuthError(ticket: ticket, method: "workspace.action", params: ["action": "rename", "workspace_id": "workspace", "title": "Renamed"])) - } - - func testWorkspaceScopedTicketRejectsActionForOtherWorkspace() throws { - let ticket = try scopedAttachTicket(workspaceID: "workspace", terminalID: nil) - XCTAssertEqual(mobileAuthError(ticket: ticket, method: "workspace.action", params: ["action": "pin", "workspace_id": "other"])?.code, "forbidden") - XCTAssertEqual(mobileAuthError(ticket: ticket, method: "workspace.action", params: ["action": "rename", "workspace_id": "other", "title": "x"])?.code, "forbidden") - } - - func testWorkspaceActionRejectsDestructiveAndGlobalSubActions() throws { - let ticket = try scopedAttachTicket(workspaceID: "workspace", terminalID: nil) - // move_* reorders the global sidebar; close_* destroys sibling - // workspaces; the rest mutate non-pin/rename state. All stay Mac-only. - for action in ["move_up", "move_down", "move_top", "close_others", "close_above", "close_below", "set_color", "clear_color", "set_description", "clear_description", "clear_name", "mark_read", "mark_unread"] { - XCTAssertEqual( - mobileAuthError(ticket: ticket, method: "workspace.action", params: ["action": action, "workspace_id": "workspace"])?.code, - "forbidden", - "workspace.action '\(action)' must be denied for a mobile ticket" + func testMobileWorkspaceActionGateAllowsOnlyPinUnpinRename() { + for action in ["pin", "unpin", "rename", "PIN", "UnPin", "RENAME"] { + XCTAssertTrue( + TerminalController.mobileAllowsWorkspaceAction(action), + "mobile workspace.action '\(action)' should be allowed" ) } - } - - func testWorkspaceActionRequiresAnAllowedActionParam() throws { - let ticket = try scopedAttachTicket(workspaceID: "workspace", terminalID: nil) - // Missing action, and an unknown action, are denied. - XCTAssertEqual(mobileAuthError(ticket: ticket, method: "workspace.action", params: ["workspace_id": "workspace"])?.code, "forbidden") - XCTAssertEqual(mobileAuthError(ticket: ticket, method: "workspace.action", params: ["action": "self_destruct", "workspace_id": "workspace"])?.code, "forbidden") - // The gate normalizes the action exactly as the handler does, so an - // uppercase/hyphenated alias of an allowed action is still accepted. - XCTAssertNil(mobileAuthError(ticket: ticket, method: "workspace.action", params: ["action": "PIN", "workspace_id": "workspace"])) - } - - func testPairedDeviceTicketAllowsWorkspaceActionForAnyWorkspace() throws { - // An empty ticket workspaceID is a Mac-wide pairing; it may pin/rename any - // workspace, consistent with how it may input to any terminal. - let ticket = try scopedAttachTicket(workspaceID: "", terminalID: nil) - XCTAssertNil(mobileAuthError(ticket: ticket, method: "workspace.action", params: ["action": "pin", "workspace_id": "any-workspace"])) - XCTAssertNil(mobileAuthError(ticket: ticket, method: "workspace.action", params: ["action": "rename", "workspace_id": "any-workspace", "title": "x"])) - } - - func testGlobalWorkspaceMethodsRemainDeniedForMobile() throws { - let ticket = try scopedAttachTicket(workspaceID: "", terminalID: nil) - // Global / group mutations and the dedicated rename method are not in the - // mobile allowlist; rename is routed through the gated workspace.action. - for method in ["workspace.reorder_many", "workspace.rename", "workspace.group.pin", "workspace.group.create", "workspace.group.delete"] { - XCTAssertEqual( - mobileAuthError(ticket: ticket, method: method, params: ["workspace_id": "any-workspace", "title": "x"])?.code, - "forbidden", - "method '\(method)' must stay Mac-only for a mobile ticket" + for action in [ + "move_up", "move-down", "move_top", + "close_others", "close_above", "close_below", + "set_color", "clear_color", "set_description", "clear_description", + "clear_name", "mark_read", "mark_unread", "self_destruct", "", + ] { + XCTAssertFalse( + TerminalController.mobileAllowsWorkspaceAction(action), + "mobile workspace.action '\(action)' must be rejected" ) } + XCTAssertFalse(TerminalController.mobileAllowsWorkspaceAction(nil)) } private func scopedAttachTicket(workspaceID: String, terminalID: String?) throws -> CmxAttachTicket { From 7ab6b1743b9c68ed7e07efc7bd2e27189dda9e88 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 6 Jun 2026 01:25:26 -0700 Subject: [PATCH 3/8] PR2 review: roll back only the targeted workspace field, match-guarded Autoreview P1: the optimistic rename/pin restored the whole `workspaces` snapshot on RPC failure, which could clobber newer authoritative state (a reconnect or a workspace.updated refresh landing while the request was in flight). Roll back only the single workspace's name/isPinned, and only when our optimistic value is still present, so a concurrent refresh is never reverted. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../MobileShellComposite.swift | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 1704a74537e8..0a1417e8a739 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -321,10 +321,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { public func renameWorkspace(id: MobileWorkspacePreview.ID, title: String) async { let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, let client = remoteClient else { return } - let previous = workspaces - if let index = workspaces.firstIndex(where: { $0.id == id }) { - workspaces[index].name = trimmed - } + guard let index = workspaces.firstIndex(where: { $0.id == id }) else { return } + let previousName = workspaces[index].name + workspaces[index].name = trimmed do { let request = try MobileCoreRPCClient.requestData( method: "workspace.action", @@ -337,7 +336,14 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { ) _ = try await client.sendRequest(request) } catch { - workspaces = previous + // Roll back only this workspace's name, and only if our optimistic + // value is still present: a reconnect or an authoritative + // `workspace.updated` refresh may have replaced the list while the + // request was in flight, and we must not clobber that newer state. + if let current = workspaces.firstIndex(where: { $0.id == id }), + workspaces[current].name == trimmed { + workspaces[current].name = previousName + } mobileShellLog.error("workspace rename failed id=\(id.rawValue, privacy: .public) error=\(String(describing: error), privacy: .public)") } } @@ -350,10 +356,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// - pinned: `true` to pin, `false` to unpin. public func setWorkspacePinned(id: MobileWorkspacePreview.ID, _ pinned: Bool) async { guard let client = remoteClient else { return } - let previous = workspaces - if let index = workspaces.firstIndex(where: { $0.id == id }) { - workspaces[index].isPinned = pinned - } + guard let index = workspaces.firstIndex(where: { $0.id == id }) else { return } + let previousPinned = workspaces[index].isPinned + workspaces[index].isPinned = pinned do { let request = try MobileCoreRPCClient.requestData( method: "workspace.action", @@ -365,7 +370,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { ) _ = try await client.sendRequest(request) } catch { - workspaces = previous + // Roll back only this workspace's pin state, and only if our + // optimistic value is still present, so a concurrent authoritative + // refresh is never reverted (see `renameWorkspace`). + if let current = workspaces.firstIndex(where: { $0.id == id }), + workspaces[current].isPinned == pinned { + workspaces[current].isPinned = previousPinned + } mobileShellLog.error("workspace pin failed id=\(id.rawValue, privacy: .public) error=\(String(describing: error), privacy: .public)") } } From 284a6e078393343ae05ab4f5ff972f60422dd229 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 6 Jun 2026 01:34:58 -0700 Subject: [PATCH 4/8] PR2 review: drop optimistic rename/pin, rely on authoritative push Second review iteration still found an optimistic-rollback race (overlapping pin then unpin, both failing, could leave stale local state). Stop patching the rollback and remove the optimistic mutation entirely: the Mac applies the rename/pin and its workspace-list observer pushes workspace.updated (now also on $isPinned), which refreshes the list. Fire-and-forget RPC, no local mutation, so no rollback and no overlap race. (The review's "removes terminal OSC/background handling" note is incorrect; this branch touches no terminal-rendering code.) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../MobileShellComposite.swift | 39 ++++++------------- 1 file changed, 12 insertions(+), 27 deletions(-) diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 0a1417e8a739..589fd92cc4ed 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -312,18 +312,18 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { // MARK: - Workspace actions - /// Rename a workspace on the Mac. Updates the local list optimistically and - /// rolls back if the RPC fails; the Mac's authoritative `workspace.updated` - /// push reconciles the final title. + /// Rename a workspace on the Mac. + /// + /// Fire-and-forget against the authoritative state: the Mac applies the title + /// and its workspace-list observer pushes `workspace.updated`, which refreshes + /// this list. No local optimistic mutation, so overlapping actions can never + /// leave stale state. /// - Parameters: /// - id: The workspace to rename. /// - title: The new title. Whitespace-only titles are ignored. public func renameWorkspace(id: MobileWorkspacePreview.ID, title: String) async { let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, let client = remoteClient else { return } - guard let index = workspaces.firstIndex(where: { $0.id == id }) else { return } - let previousName = workspaces[index].name - workspaces[index].name = trimmed do { let request = try MobileCoreRPCClient.requestData( method: "workspace.action", @@ -336,29 +336,21 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { ) _ = try await client.sendRequest(request) } catch { - // Roll back only this workspace's name, and only if our optimistic - // value is still present: a reconnect or an authoritative - // `workspace.updated` refresh may have replaced the list while the - // request was in flight, and we must not clobber that newer state. - if let current = workspaces.firstIndex(where: { $0.id == id }), - workspaces[current].name == trimmed { - workspaces[current].name = previousName - } mobileShellLog.error("workspace rename failed id=\(id.rawValue, privacy: .public) error=\(String(describing: error), privacy: .public)") } } - /// Pin or unpin a workspace on the Mac. Updates the local list optimistically - /// and rolls back if the RPC fails; the Mac's authoritative `workspace.updated` - /// push reconciles the final pin state. + /// Pin or unpin a workspace on the Mac. + /// + /// Fire-and-forget against the authoritative state: the Mac toggles the pin + /// and its workspace-list observer (which watches `$isPinned`) pushes + /// `workspace.updated`, which refreshes this list. No local optimistic + /// mutation, so overlapping pin/unpin taps can never leave stale state. /// - Parameters: /// - id: The workspace to pin or unpin. /// - pinned: `true` to pin, `false` to unpin. public func setWorkspacePinned(id: MobileWorkspacePreview.ID, _ pinned: Bool) async { guard let client = remoteClient else { return } - guard let index = workspaces.firstIndex(where: { $0.id == id }) else { return } - let previousPinned = workspaces[index].isPinned - workspaces[index].isPinned = pinned do { let request = try MobileCoreRPCClient.requestData( method: "workspace.action", @@ -370,13 +362,6 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { ) _ = try await client.sendRequest(request) } catch { - // Roll back only this workspace's pin state, and only if our - // optimistic value is still present, so a concurrent authoritative - // refresh is never reverted (see `renameWorkspace`). - if let current = workspaces.firstIndex(where: { $0.id == id }), - workspaces[current].isPinned == pinned { - workspaces[current].isPinned = previousPinned - } mobileShellLog.error("workspace pin failed id=\(id.rawValue, privacy: .public) error=\(String(describing: error), privacy: .public)") } } From 90d1a8f492a0627d961bd31058556d10e13e034d Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 6 Jun 2026 01:47:00 -0700 Subject: [PATCH 5/8] PR2 review: gate rename/pin UI on a host capability Review P2: the rename/pin affordances were shown on every connected Mac, but an older Mac lacking the new mobile workspace.action handler returns method_not_found, so the actions silently no-op. Advertise a workspace.actions.v1 capability in mobile.host.status, capture it on the client when reading host status, and only pass the rename/pin closures when the connected Mac supports it (reusing the existing nil-closure hiding). Reset on disconnect. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Sources/CmuxMobileShell/MobileShellComposite.swift | 10 ++++++++++ .../Sources/CmuxMobileShellUI/WorkspaceShellView.swift | 8 ++++---- Sources/TerminalController.swift | 4 ++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 589fd92cc4ed..eabc1e0ccf36 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -48,6 +48,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } private static let terminalRenderGridCapability = "terminal.render_grid.v1" + private static let workspaceActionsCapability = "workspace.actions.v1" private static let terminalOutputCapabilityTimeoutNanoseconds: UInt64 = 750_000_000 /// How long the render-grid stream may stay silent (no event of any topic) @@ -79,6 +80,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } public var pairingCode: String public var workspaces: [MobileWorkspacePreview] + /// Whether the connected Mac advertises the `workspace.actions.v1` capability + /// (rename/pin over the mobile RPC). `false` until host status is read, and + /// for older Macs that lack the handler, so the UI can hide rename/pin rather + /// than offer actions that would fail with `method_not_found`. + public private(set) var supportsWorkspaceActions: Bool = false public var terminalInputText: String public var selectedWorkspaceID: MobileWorkspacePreview.ID? { didSet { @@ -1224,6 +1230,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { pendingTerminalByteEndSeqBySurfaceID = [:] terminalReplaySurfaceIDsInFlight = [] terminalOutputTransport = .rawBytes + supportsWorkspaceActions = false terminalSubscriptionRefreshTask?.cancel() terminalSubscriptionRefreshTask = nil stopRenderGridLivenessWatchdog(listenerID: nil) @@ -1502,8 +1509,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { ) guard let payload = try? MobileHostStatusResponse.decode(data) else { terminalOutputTransport = fallback + supportsWorkspaceActions = false return fallback } + supportsWorkspaceActions = payload.capabilities.contains(Self.workspaceActionsCapability) let transport: TerminalOutputTransport = payload.capabilities.contains(Self.terminalRenderGridCapability) || payload.terminalFidelity == "render_grid" ? .renderGrid : .rawBytes terminalOutputTransport = transport @@ -1511,6 +1520,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { return transport } catch { terminalOutputTransport = fallback + supportsWorkspaceActions = false MobileDebugLog.anchormux("sync.transport=raw_bytes reason=status_failed") return fallback } diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift index 3dcbc960d10f..a118f3b1097d 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift @@ -64,8 +64,8 @@ struct WorkspaceShellView: View { createWorkspace: createWorkspaceInCompactStack, rescanQR: { store.disconnectAndForgetActiveMac() }, signOut: signOut, - renameWorkspace: renameWorkspace, - setPinned: setWorkspacePinned + renameWorkspace: store.supportsWorkspaceActions ? renameWorkspace : nil, + setPinned: store.supportsWorkspaceActions ? setWorkspacePinned : nil ) .navigationDestination(for: MobileWorkspacePreview.ID.self) { workspaceID in workspaceDestination(for: workspaceID, createWorkspace: createWorkspaceInCompactStack) @@ -116,8 +116,8 @@ struct WorkspaceShellView: View { createWorkspace: store.createWorkspace, rescanQR: { store.disconnectAndForgetActiveMac() }, signOut: signOut, - renameWorkspace: renameWorkspace, - setPinned: setWorkspacePinned + renameWorkspace: store.supportsWorkspaceActions ? renameWorkspace : nil, + setPinned: store.supportsWorkspaceActions ? setWorkspacePinned : nil ) .navigationSplitViewColumnWidth(min: 320, ideal: 380, max: 440) } detail: { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 57f29afe5c2d..7522fda48b6a 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -20765,6 +20765,10 @@ class TerminalController { "terminal.render_grid.v1", "terminal.replay.v1", "terminal.viewport.v1", + // Advertises the mobile-gated workspace.action handler (pin/unpin/ + // rename). Lets the iOS client hide rename/pin when paired with an + // older Mac that lacks the handler. + "workspace.actions.v1", ] guard includePrivateMetadata else { return .ok([ From 88a39cc63dd42649533f77ff37a805571b612354 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 6 Jun 2026 01:57:13 -0700 Subject: [PATCH 6/8] PR2: build the capability-gated closures as literals The previous commit's `store.supportsWorkspaceActions ? method : nil` ternary tripped a Swift type-checker bug ("failed to produce diagnostic") inside the large WorkspaceListView initializer. Build the optional rename/pin closures as explicit closure literals capturing the store instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../WorkspaceShellView.swift | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift index a118f3b1097d..9b8a05eec0c5 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift @@ -64,8 +64,8 @@ struct WorkspaceShellView: View { createWorkspace: createWorkspaceInCompactStack, rescanQR: { store.disconnectAndForgetActiveMac() }, signOut: signOut, - renameWorkspace: store.supportsWorkspaceActions ? renameWorkspace : nil, - setPinned: store.supportsWorkspaceActions ? setWorkspacePinned : nil + renameWorkspace: renameWorkspaceClosure, + setPinned: setWorkspacePinnedClosure ) .navigationDestination(for: MobileWorkspacePreview.ID.self) { workspaceID in workspaceDestination(for: workspaceID, createWorkspace: createWorkspaceInCompactStack) @@ -116,8 +116,8 @@ struct WorkspaceShellView: View { createWorkspace: store.createWorkspace, rescanQR: { store.disconnectAndForgetActiveMac() }, signOut: signOut, - renameWorkspace: store.supportsWorkspaceActions ? renameWorkspace : nil, - setPinned: store.supportsWorkspaceActions ? setWorkspacePinned : nil + renameWorkspace: renameWorkspaceClosure, + setPinned: setWorkspacePinnedClosure ) .navigationSplitViewColumnWidth(min: 320, ideal: 380, max: 440) } detail: { @@ -141,12 +141,21 @@ struct WorkspaceShellView: View { } } - private func renameWorkspace(_ id: MobileWorkspacePreview.ID, _ title: String) { - Task { await store.renameWorkspace(id: id, title: title) } + /// Rename/pin closures, present only when the connected Mac advertises the + /// `workspace.actions.v1` capability so the row affordances stay hidden on + /// older Macs that lack the handler. Built as explicit closure literals (not + /// a method-reference ternary, which the compiler fails to type-check inside + /// the large `WorkspaceListView` initializer). + private var renameWorkspaceClosure: ((MobileWorkspacePreview.ID, String) -> Void)? { + guard store.supportsWorkspaceActions else { return nil } + let store = store + return { id, title in Task { await store.renameWorkspace(id: id, title: title) } } } - private func setWorkspacePinned(_ id: MobileWorkspacePreview.ID, _ pinned: Bool) { - Task { await store.setWorkspacePinned(id: id, pinned) } + private var setWorkspacePinnedClosure: ((MobileWorkspacePreview.ID, Bool) -> Void)? { + guard store.supportsWorkspaceActions else { return nil } + let store = store + return { id, pinned in Task { await store.setWorkspacePinned(id: id, pinned) } } } private func createWorkspaceInCompactStack() { From 00faf13164d91c9c13f646a3d268a10d475d0e6c Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 6 Jun 2026 02:08:27 -0700 Subject: [PATCH 7/8] PR2 review: advertise workspace.actions.v1 on the live status paths Review P1: the mobile listener intercepts mobile.host.status and returns the public-status cache / publicHostStatusResult before TerminalController runs, so adding workspace.actions.v1 only to TerminalController's status left it invisible to the iOS client. supportsWorkspaceActions stayed false and rename/pin were hidden even on a supporting Mac. Consolidate all three capability lists into one source of truth (MobileHostService.mobileHostCapabilities), advertised on every status path, so the lists cannot drift again. Add a contract test asserting the shared list advertises workspace.actions.v1. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/Mobile/MobileHostService.swift | 30 +++++++++++--------- Sources/TerminalController.swift | 16 ++++------- cmuxTests/MobileHostAuthorizationTests.swift | 11 +++++++ 3 files changed, 32 insertions(+), 25 deletions(-) diff --git a/Sources/Mobile/MobileHostService.swift b/Sources/Mobile/MobileHostService.swift index b1dae06a8cb9..96a1f99a9a0a 100644 --- a/Sources/Mobile/MobileHostService.swift +++ b/Sources/Mobile/MobileHostService.swift @@ -167,13 +167,7 @@ private enum MobileHostPublicStatusCache { return .ok([ "routes": cachedRoutes.map(\.mobileHostJSONObject), "terminal_fidelity": "render_grid", - "capabilities": [ - "events.v1", - "terminal.bytes.v1", - "terminal.render_grid.v1", - "terminal.replay.v1", - "terminal.viewport.v1", - ], + "capabilities": MobileHostService.mobileHostCapabilities, ]) } } @@ -298,6 +292,20 @@ final class MobileHostService { static let shared = MobileHostService() nonisolated private static let maximumActiveConnectionCount = 10 + /// The single source of truth for the capabilities advertised to mobile + /// clients via `mobile.host.status`. Every status path (the public-status + /// cache, the live `publicHostStatusResult`, and `TerminalController`'s + /// full status) reads this so the lists cannot drift; iOS gates features + /// like rename/pin on the entries present here. + nonisolated static let mobileHostCapabilities: [String] = [ + "events.v1", + "terminal.bytes.v1", + "terminal.render_grid.v1", + "terminal.replay.v1", + "terminal.viewport.v1", + "workspace.actions.v1", + ] + private let callbackQueue = DispatchQueue(label: "dev.cmux.mobile.host-listener") private let routeResolver = MobileRouteResolver() private let ticketStore = MobileAttachTicketStore() @@ -857,13 +865,7 @@ final class MobileHostService { return .ok([ "routes": status.routes.map(\.mobileHostJSONObject), "terminal_fidelity": "render_grid", - "capabilities": [ - "events.v1", - "terminal.bytes.v1", - "terminal.render_grid.v1", - "terminal.replay.v1", - "terminal.viewport.v1", - ], + "capabilities": MobileHostService.mobileHostCapabilities, ]) } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 7522fda48b6a..c6af201b7acb 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -20759,17 +20759,11 @@ class TerminalController { includePrivateMetadata: Bool = true ) -> V2CallResult { let status = MobileHostService.shared.statusSnapshot() - let capabilities = [ - "events.v1", - "terminal.bytes.v1", - "terminal.render_grid.v1", - "terminal.replay.v1", - "terminal.viewport.v1", - // Advertises the mobile-gated workspace.action handler (pin/unpin/ - // rename). Lets the iOS client hide rename/pin when paired with an - // older Mac that lacks the handler. - "workspace.actions.v1", - ] + // Single source of truth shared with the mobile listener's public-status + // paths, so the advertised capabilities can never drift. Includes + // workspace.actions.v1 (the mobile-gated pin/unpin/rename handler), which + // the iOS client uses to show or hide rename/pin. + let capabilities = MobileHostService.mobileHostCapabilities guard includePrivateMetadata else { return .ok([ "routes": status.routes.map(\.mobileHostJSONObject), diff --git a/cmuxTests/MobileHostAuthorizationTests.swift b/cmuxTests/MobileHostAuthorizationTests.swift index f27108f092a0..ad5f535d3db3 100644 --- a/cmuxTests/MobileHostAuthorizationTests.swift +++ b/cmuxTests/MobileHostAuthorizationTests.swift @@ -1109,6 +1109,17 @@ final class MobileHostAuthorizationTests: XCTestCase { XCTAssertEqual(recordedMethods, ["workspace.list"]) } + // MARK: - Advertised mobile host capabilities + + func testMobileHostAdvertisesWorkspaceActionsCapability() { + // The iOS client gates rename/pin on `workspace.actions.v1`; every + // mobile.host.status path reads this single list, so advertising it here + // is what makes the feature visible to a supporting Mac. + let capabilities = MobileHostService.mobileHostCapabilities + XCTAssertTrue(capabilities.contains("workspace.actions.v1")) + XCTAssertTrue(capabilities.contains("terminal.render_grid.v1")) + } + // MARK: - Mobile workspace.action sub-action gate func testMobileWorkspaceActionGateAllowsOnlyPinUnpinRename() { From 6bc3e28551b80bb649908caa4619775c006ad2d6 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 6 Jun 2026 02:21:48 -0700 Subject: [PATCH 8/8] PR2 review: require an explicit valid workspace_id for mobile actions Review P2: v2MobileWorkspaceAction forwarded to v2WorkspaceAction, which falls back to the Mac's selected workspace when workspace_id is missing. A malformed or omitted workspace_id from the mobile plane could therefore pin/rename the wrong workspace. Validate like the other mobile handlers and additionally require the id to be present and resolvable before dispatching this mutating action. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/TerminalController.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index c6af201b7acb..efe92c940d15 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -20740,6 +20740,17 @@ class TerminalController { data: ["action": v2OrNull(rawAction)] ) } + // Reject a present-but-malformed workspace_id like the other mobile + // handlers, then require it to actually be present and resolvable: this + // is a mutating action, so it must target an explicit workspace and never + // fall back to the Mac's currently selected workspace (which + // v2WorkspaceAction would otherwise do for a missing workspace_id). + if let error = mobileWorkspaceIDValidationError(params: params) { + return error + } + guard v2UUID(params, "workspace_id") != nil else { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } return v2WorkspaceAction(params: params) }