diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Surfaces/CloudWorkspaceProjectionPlan.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Surfaces/CloudWorkspaceProjectionPlan.swift index 77938c0d29cf..d4f5198ac417 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Surfaces/CloudWorkspaceProjectionPlan.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Surfaces/CloudWorkspaceProjectionPlan.swift @@ -10,6 +10,7 @@ public struct CloudWorkspaceProjectionPlan { public init(desired: [SurfaceResourcePlacement], existing: [SurfaceProjection]) { let wanted = Set(desired) var seen = Set() + var satisfied = Set() var obsolete: [SurfaceProjection] = [] for projection in existing.sorted(by: { $0.panelID.uuidString < $1.panelID.uuidString }) { let placement = SurfaceResourcePlacement( @@ -22,13 +23,19 @@ public struct CloudWorkspaceProjectionPlan { // coordinates were cleared by an authoritative remote deletion has // neither coordinate and must still be retired. if projection.isLocalWorkspaceView && projection.remoteWorkspaceID != nil { - seen.insert(placement) + for matching in desired where + matching.cloudDisplayMembershipViewID != nil + && matching.resource == projection.resource + && matching.remoteWorkspaceID == projection.remoteWorkspaceID { + satisfied.insert(matching) + seen.insert(matching) + } continue } if !wanted.contains(placement) || !seen.insert(placement).inserted { obsolete.append(projection) } } var missingSeen = Set() - missing = desired.filter { !seen.contains($0) && missingSeen.insert($0).inserted } + missing = desired.filter { !satisfied.contains($0) && missingSeen.insert($0).inserted } self.obsolete = obsolete } } diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Surfaces/SurfaceProjection+WorkspaceMembership.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Surfaces/SurfaceProjection+WorkspaceMembership.swift index 9ab0e1e29ed3..43e213d3d4af 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Surfaces/SurfaceProjection+WorkspaceMembership.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Surfaces/SurfaceProjection+WorkspaceMembership.swift @@ -10,7 +10,10 @@ extension SurfaceProjection { /// their bound workspace; availability in the machine resource pool never does. /// A daemon placement of the same resource already supplies that workspace row. public static func localWorkspaceMembers(resources: [SurfaceResource], projections: [SurfaceProjection]) -> [(resource: SurfaceResource, workspaceID: String)] { - let previews = Dictionary(uniqueKeysWithValues: resources.filter { $0.kind == .display || $0.id.isForwardedPort }.map { ($0.id, $0) }) + let previews = Dictionary( + resources.filter { $0.kind == .display || $0.id.isForwardedPort }.map { ($0.id, $0) }, + uniquingKeysWith: { first, _ in first } + ) var seen: [SurfaceResourceID: Set] = [:] return projections.compactMap { projection in guard projection.isLocalWorkspaceView, diff --git a/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudWorkspaceProjectionPlanTests.swift b/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudWorkspaceProjectionPlanTests.swift index a220f4087a14..61bc646204b1 100644 --- a/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudWorkspaceProjectionPlanTests.swift +++ b/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudWorkspaceProjectionPlanTests.swift @@ -29,6 +29,39 @@ struct CloudWorkspaceProjectionPlanTests { #expect(plan.missing == [desired]) } + @Test("A local Desktop preview satisfies an exact frontend membership view") + func localDisplayPreviewSatisfiesMembership() { + let display = SurfaceResourceID(machine: machine, kind: .display, key: "display:1") + let preview = SurfaceProjection( + resource: display, + workspaceID: UUID(), + panelID: UUID(), + remoteWorkspaceID: "remote-workspace" + ) + let remoteWorkspace = SurfaceRemoteWorkspace( + id: "remote-workspace", name: "Cloud", index: 0, focused: true + ) + let desired = SurfaceResourcePlacement( + resource: display, + remoteView: SurfaceRemoteView( + tabID: SurfaceRemoteView.cloudDisplayMembershipViewPrefix + "view-a", + workspace: remoteWorkspace + ) + ) + let secondView = SurfaceResourcePlacement( + resource: display, + remoteView: SurfaceRemoteView( + tabID: SurfaceRemoteView.cloudDisplayMembershipViewPrefix + "view-b", + workspace: remoteWorkspace + ) + ) + + let plan = CloudWorkspaceProjectionPlan(desired: [desired, secondView], existing: [preview]) + + #expect(plan.obsolete.isEmpty) + #expect(plan.missing.isEmpty) + } + @Test("A preview whose remote placement was deleted is retired") func deletedRemotePlacementIsNotMistakenForPreview() { let display = SurfaceResourceID(machine: machine, kind: .display, key: "display:1") diff --git a/Packages/macOS/CmuxCloudTui/Sources/CmuxCloudTui/CloudTuiFrontendProjectionRequests.swift b/Packages/macOS/CmuxCloudTui/Sources/CmuxCloudTui/CloudTuiFrontendProjectionRequests.swift new file mode 100644 index 000000000000..4367e54acb44 --- /dev/null +++ b/Packages/macOS/CmuxCloudTui/Sources/CmuxCloudTui/CloudTuiFrontendProjectionRequests.swift @@ -0,0 +1,26 @@ +import Foundation + +public extension CloudTuiRequests { + /// Updates one durable Cloud display-membership projection. + static func putCloudDisplayMembershipProjection( + projectionID: String, + frontendID: String, + windowID: String, + generation: String, + projection: [String: Any], + expectedProjectionRevision: UInt64?, + idempotencyKey: String + ) -> CloudTuiRequest { + var fields: [String: Any] = [ + "frontend_projection": projectionID, + "frontend_id": frontendID, + "window_id": windowID, + "generation": generation, + "projection": projection, + ] + if let expectedProjectionRevision { + fields["expected_projection_revision"] = String(expectedProjectionRevision) + } + return CloudTuiRequest("frontend_projection.put", fields, mutation: true, key: idempotencyKey) + } +} diff --git a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/CloudVMDisplayMembership.swift b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/CloudVMDisplayMembership.swift new file mode 100644 index 000000000000..41205150de53 --- /dev/null +++ b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/CloudVMDisplayMembership.swift @@ -0,0 +1,85 @@ +import Foundation + +/// One durable display view attached to a Cloud workspace. +/// +/// The daemon stores these values in a frontend projection because a VNC display +/// is a machine resource rather than a cmux-tui tab. `machine`, `workspaceID`, +/// and `displayID` are the ownership identity; `clientID` and `viewID` let +/// independent clients remove only the view they created. +public struct CloudVMDisplayMembership: Hashable, Codable, Sendable { + /// Schema stored in the daemon's frontend projection record. + public static let projectionSchema = "cmux.cloud.workspace-displays.v1" + /// Frontend identity authorized to author this projection. + public static let projectionFrontendID = "cmux-macos-cloud-layout-v1" + /// Projection generation used to reject stale frontend implementations. + public static let projectionGeneration = "cmux-cloud-display-layout-v1" + + /// Stable window subject used to scope one machine workspace projection. + public static func projectionWindowID(machine: SurfaceMachineID, workspaceID: String) -> String { + "cloud-workspace:\(machine.rawValue):\(workspaceID)" + } + + public let machine: SurfaceMachineID + public let workspaceID: String + public let displayID: String + public let clientID: String + public let viewID: String + + public init( + machine: SurfaceMachineID, + workspaceID: String, + displayID: String, + clientID: String, + viewID: String + ) { + self.machine = machine + self.workspaceID = workspaceID + self.displayID = displayID + self.clientID = clientID + self.viewID = viewID + } +} + +extension CloudVMState { + /// Reads only validated display memberships from the accepted frontend + /// projection rows. Unknown machines, workspaces, displays, and malformed + /// view tokens are ignored so stale or foreign provenance cannot enter the + /// catalog projection. + public var displayMemberships: [CloudVMDisplayMembership] { + let workspaceIDs = Set(workspaces.map(\.id)) + var result = Set() + let expectedSessionID = (document.value(forKey: "session") as? [String: Any])?["id"] as? String + for row in document.objects(forCollectionKey: "frontend_projections") ?? [] { + guard row["frontend_id"] as? String == CloudVMDisplayMembership.projectionFrontendID, + row["generation"] as? String == CloudVMDisplayMembership.projectionGeneration else { continue } + if let expectedSessionID, row["session_id"] as? String != expectedSessionID { continue } + guard let projection = row["projection"] as? [String: Any], + projection["schema"] as? String == CloudVMDisplayMembership.projectionSchema, + projection["machine_id"] as? String == machine.rawValue, + let workspaceID = projection["workspace_id"] as? String, + workspaceIDs.contains(workspaceID), + row["window_id"] as? String == CloudVMDisplayMembership.projectionWindowID(machine: machine, workspaceID: workspaceID), + let memberships = projection["memberships"] as? [[String: Any]] else { continue } + for membership in memberships { + guard let displayID = membership["display_id"] as? String, + displayID.hasPrefix("display:"), + displayID.dropFirst("display:".count).isEmpty == false, + let clientID = membership["client_id"] as? String, + !clientID.isEmpty, + let viewID = membership["view_id"] as? String, + !viewID.isEmpty else { continue } + result.insert(CloudVMDisplayMembership( + machine: machine, + workspaceID: workspaceID, + displayID: displayID, + clientID: clientID, + viewID: viewID + )) + } + } + return result.sorted { + ($0.workspaceID, $0.displayID, $0.clientID, $0.viewID) + < ($1.workspaceID, $1.displayID, $1.clientID, $1.viewID) + } + } +} diff --git a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogSnapshot+CloudDisplayMembership.swift b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogSnapshot+CloudDisplayMembership.swift new file mode 100644 index 000000000000..0d75537017b9 --- /dev/null +++ b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogSnapshot+CloudDisplayMembership.swift @@ -0,0 +1,41 @@ +import Foundation + +extension SurfaceCatalogSnapshot { + /// Returns the machine pool plus one presentation copy for every accepted + /// display/workspace membership. The copies retain the same resource id and + /// are used only by workspace rows and groups; the pool remains one row per + /// discovered display resource. A copy contains only its frontend views, so + /// an existing daemon display tab is not emitted a second time. + public func cloudWorkspaceResources(on machine: SurfaceMachineID) -> [SurfaceResource] { + let base = resources(on: machine) + let byID = Dictionary(base.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + let workspaces = Dictionary( + (machines.first { $0.id == machine }?.remoteWorkspaces ?? []).map { ($0.id, $0) }, + uniquingKeysWith: { first, _ in first } + ) + var result = base + let membershipsByDisplayID = Dictionary(grouping: cloudDisplayMemberships.filter { $0.machine == machine }, by: \.displayID) + for displayID in membershipsByDisplayID.keys.sorted() { + let id = SurfaceResourceID(machine: machine, kind: .display, key: displayID) + guard let baseResource = byID[id] else { continue } + var placed = baseResource + let memberships = (membershipsByDisplayID[displayID] ?? []) + .sorted { ($0.workspaceID, $0.clientID, $0.viewID) < ($1.workspaceID, $1.clientID, $1.viewID) } + let views = memberships.enumerated().compactMap { index, membership -> SurfaceRemoteView? in + guard let workspace = workspaces[membership.workspaceID] else { return nil } + return SurfaceRemoteView( + tabID: SurfaceRemoteView.cloudDisplayMembershipViewPrefix + membership.viewID, + workspace: workspace, + name: nil, + index: index, + focused: false + ) + } + guard !views.isEmpty else { continue } + placed.remoteViews = views + placed.remoteWorkspace = views.first?.workspace + result.append(placed) + } + return result + } +} diff --git a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogSnapshot.swift b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogSnapshot.swift index 38a5616d917f..350082d79dad 100644 --- a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogSnapshot.swift +++ b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogSnapshot.swift @@ -3,9 +3,6 @@ import Foundation /// The catalog as one value: what the sidebar renders, what `surface.catalog` and /// `cmux vm tree --json` print. Machines are ordered local first, then by name. public struct SurfaceCatalogSnapshot: Hashable, Codable, Sendable { - /// Workspaces admitted for deletion but not yet confirmed by the daemon, - /// per machine. Nil when nothing is pending, so socket readers on older - /// builds keep decoding the same document. /// Pending native workspace identities, keyed by machine and daemon workspace. public var pendingWorkspaceCreations: [SurfaceMachineID: [String: UUID]]? = nil public var pendingWorkspaceDeletions: [SurfaceMachineID: Set]? = nil @@ -14,6 +11,7 @@ public struct SurfaceCatalogSnapshot: Hashable, Codable, Sendable { public var projections: [SurfaceProjection] public var staleMachineIDs: Set = [] public var displayCreationMachines: Set? = nil + public var cloudDisplayMemberships: [CloudVMDisplayMembership] = [] public static let empty = SurfaceCatalogSnapshot(machines: [], resources: [], projections: []) @@ -37,7 +35,8 @@ public struct SurfaceCatalogSnapshot: Hashable, Codable, Sendable { resources: [SurfaceResource], projections: [SurfaceProjection], staleMachineIDs: Set = [], - displayCreationMachines: Set? = nil + displayCreationMachines: Set? = nil, + cloudDisplayMemberships: [CloudVMDisplayMembership] = [] ) { self.pendingWorkspaceCreations = pendingWorkspaceCreations self.pendingWorkspaceDeletions = pendingWorkspaceDeletions @@ -46,12 +45,13 @@ public struct SurfaceCatalogSnapshot: Hashable, Codable, Sendable { self.projections = projections self.staleMachineIDs = staleMachineIDs self.displayCreationMachines = displayCreationMachines + self.cloudDisplayMemberships = cloudDisplayMemberships } } extension SurfaceCatalogSnapshot { private enum CodingKeys: String, CodingKey { - case pendingWorkspaceCreations, pendingWorkspaceDeletions, machines, resources, projections, staleMachineIDs, displayCreationMachines + case pendingWorkspaceCreations, pendingWorkspaceDeletions, machines, resources, projections, staleMachineIDs, displayCreationMachines, cloudDisplayMemberships } public init(from decoder: Decoder) throws { @@ -63,5 +63,6 @@ extension SurfaceCatalogSnapshot { projections = try values.decode([SurfaceProjection].self, forKey: .projections) staleMachineIDs = try values.decodeIfPresent(Set.self, forKey: .staleMachineIDs) ?? [] displayCreationMachines = try values.decodeIfPresent(Set.self, forKey: .displayCreationMachines) + cloudDisplayMemberships = try values.decodeIfPresent([CloudVMDisplayMembership].self, forKey: .cloudDisplayMemberships) ?? [] } } diff --git a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceRemoteView+CloudDisplayMembership.swift b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceRemoteView+CloudDisplayMembership.swift new file mode 100644 index 000000000000..56f041eb16d4 --- /dev/null +++ b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceRemoteView+CloudDisplayMembership.swift @@ -0,0 +1,21 @@ +import Foundation + +extension SurfaceRemoteView { + /// Synthetic tab identity used only to order a display view persisted by + /// the Cloud frontend projection. It is never sent to a daemon tab API. + public static let cloudDisplayMembershipViewPrefix = "cloud-display-view:" + + public var isCloudDisplayMembershipView: Bool { + tabID.hasPrefix(Self.cloudDisplayMembershipViewPrefix) + } + + /// The durable frontend view token carried by a synthetic display view. + /// It is intentionally separate from daemon tab ids: a display membership + /// is persisted outside the daemon layout, but still needs an exact identity + /// when two clients attach the same display to one workspace. + public var cloudDisplayMembershipViewID: String? { + guard isCloudDisplayMembershipView else { return nil } + let value = String(tabID.dropFirst(Self.cloudDisplayMembershipViewPrefix.count)) + return value.isEmpty ? nil : value + } +} diff --git a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceResourcePlacement.swift b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceResourcePlacement.swift index f15c77e811f5..363ccbc786e9 100644 --- a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceResourcePlacement.swift +++ b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceResourcePlacement.swift @@ -8,15 +8,20 @@ public struct SurfaceResourcePlacement: Hashable, Codable, Sendable { public let resource: SurfaceResourceID public let remoteWorkspaceID: String? public let remoteTabID: String? + /// Exact frontend display membership identity, when this placement came + /// from the Cloud workspace projection rather than a daemon tab. + public let cloudDisplayMembershipViewID: String? public init( resource: SurfaceResourceID, remoteView: SurfaceRemoteView? = nil, remoteWorkspaceID: String? = nil, - remoteTabID: String? = nil + remoteTabID: String? = nil, + cloudDisplayMembershipViewID: String? = nil ) { self.resource = resource self.remoteWorkspaceID = remoteView?.workspace.id ?? remoteWorkspaceID - self.remoteTabID = remoteView?.tabID ?? remoteTabID + self.remoteTabID = remoteView?.isCloudDisplayMembershipView == true ? nil : (remoteView?.tabID ?? remoteTabID) + self.cloudDisplayMembershipViewID = remoteView?.cloudDisplayMembershipViewID ?? cloudDisplayMembershipViewID } } diff --git a/Sources/Cloud/CloudTreeNode.swift b/Sources/Cloud/CloudTreeNode.swift index 6c5982a2360f..41045af43959 100644 --- a/Sources/Cloud/CloudTreeNode.swift +++ b/Sources/Cloud/CloudTreeNode.swift @@ -83,7 +83,6 @@ final class CloudTreeNode: NSObject { var resourceSection: CloudTreeMachineResourceSection? /// For workspace rows: everything the workspace holds, in the order it opens. private var explicitDragGroup: SurfaceResourceGroup? - init(id: String, kind: Kind, children: [CloudTreeNode] = [], dragGroup: SurfaceResourceGroup? = nil, isPinned: Bool = false) { self.id = id self.kind = kind @@ -422,12 +421,13 @@ enum CloudTreeNodeBuilder { for resource in snapshot.resources where resource.remoteViews?.count == 1 { singleViewResources.insert(resource.id) } - for projection in snapshot.projections { openResources.insert(projection.resource) workspaceCountsByResource[projection.resource, default: [:]][projection.workspaceID, default: 0] += 1 if let remoteWorkspaceID = projection.remoteWorkspaceID { - if let remoteTabID = projection.remoteTabID, !remoteTabID.isEmpty { + if CloudTreeNodeBuilder.hasCloudDisplayMembershipProjection(projection, snapshot: snapshot, workspaceID: remoteWorkspaceID) { + workspaceOnly[RemoteWorkspaceIdentity(resource: projection.resource, workspaceID: remoteWorkspaceID), default: []].append(projection.workspaceID) + } else if let remoteTabID = projection.remoteTabID, !remoteTabID.isEmpty { let identity = RemotePlacementIdentity( resource: projection.resource, workspaceID: remoteWorkspaceID, @@ -435,7 +435,8 @@ enum CloudTreeNodeBuilder { ) openPlacements.insert(identity) exact[identity, default: []].append(projection.workspaceID) - } else if let views = resourceByID[projection.resource]?.remoteViews { + } else if let views = resourceByID[projection.resource]?.remoteViews, + !views.allSatisfy(\.isCloudDisplayMembershipView) { // Intermediate builds persisted the workspace id before // they persisted tab ids. Recover the tab only when the // current graph has one unambiguous view in that @@ -486,7 +487,6 @@ enum CloudTreeNodeBuilder { } } } - func isOpen(_ resource: SurfaceResourceID, remoteView: SurfaceRemoteView?) -> Bool { guard let remoteView else { return openResources.contains(resource) } let identity = RemotePlacementIdentity( @@ -876,7 +876,7 @@ enum CloudTreeNodeBuilder { for workspace in info.remoteWorkspaces ?? [] { byWorkspace[workspace.id] = RemoteWorkspaceRows(workspace: workspace) } - for resource in resources { + for resource in snapshot.cloudWorkspaceResources(on: machine) { for placement in remotePlacements(of: resource) { var rows = byWorkspace[placement.workspace.id] ?? RemoteWorkspaceRows(workspace: placement.workspace) switch resource.kind { diff --git a/Sources/Cloud/CloudTreeRemoteWorkspaces.swift b/Sources/Cloud/CloudTreeRemoteWorkspaces.swift index 71a760416792..99c8dfb2263f 100644 --- a/Sources/Cloud/CloudTreeRemoteWorkspaces.swift +++ b/Sources/Cloud/CloudTreeRemoteWorkspaces.swift @@ -35,6 +35,19 @@ enum CloudTreeRemoteWorkspaceLookup: Equatable { } extension CloudTreeNodeBuilder { + static func hasCloudDisplayMembershipProjection( + _ projection: SurfaceProjection, + snapshot: SurfaceCatalogSnapshot, + workspaceID: String + ) -> Bool { + projection.resource.kind == .display && projection.remoteTabID == nil + && snapshot.cloudDisplayMemberships.contains { + $0.machine == projection.resource.machine + && $0.displayID == projection.resource.key + && $0.workspaceID == workspaceID + } + } + /// Every cmux-tui workspace on a machine, in the daemon's order: the ones the /// machine itself reports (including empty workspaces needed by lookup and /// persistence) plus any that a resource's views name before the machine list @@ -118,7 +131,7 @@ extension CloudTreeNodeBuilder { static func lookupRemoteWorkspace(_ selector: String, on machine: SurfaceMachineID, snapshot: SurfaceCatalogSnapshot) -> CloudTreeRemoteWorkspaceLookup { let trimmed = selector.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return .notFound } - let resources = snapshot.resources(on: machine) + let resources = snapshot.cloudWorkspaceResources(on: machine) let workspaces = remoteWorkspaces(info: snapshot.machines.first { $0.id == machine }, resources: resources) if let byID = workspaces.first(where: { $0.id == trimmed }) { return .found(byID, remoteWorkspaceMembers(workspaceID: byID.id, resources: resources, projections: snapshot.projections)) diff --git a/Sources/Cloud/CloudTuiManualMirrorSession+PendingReplay.swift b/Sources/Cloud/CloudTuiManualMirrorSession+PendingReplay.swift new file mode 100644 index 000000000000..1c7982af9a05 --- /dev/null +++ b/Sources/Cloud/CloudTuiManualMirrorSession+PendingReplay.swift @@ -0,0 +1,19 @@ +import CmuxTerminal +import Foundation + +extension CloudTuiManualMirrorSession { + /// Applies an attach replay that arrived before the native pane was bound. + /// The replay is already reset and color-composed, so it must enter Ghostty + /// exactly once after binding. + func flushPendingReplay() { + guard let pendingReplay, let surface else { return } + self.pendingReplay = nil + let token = replayFidelity.replayQueued(remote: lastRemoteGrid, local: settledGrid()) + surface.processRemoteReplay(pendingReplay) { [weak self, weak surface] in + surface?.forceRefresh(reason: "cloud.replay.applied") + self?.replayApplied(token: token) + } onDiscarded: { [weak self] in + self?.replayDiscarded(token: token) + } + } +} diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index f4b64a3821b8..038fc9e6ff13 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -52,6 +52,7 @@ final class CloudTuiManualMirrorSession { private(set) var socketPath: String? private var nextRequestID: UInt64 = 1 var pendingRequests: [UInt64: CloudTuiManualMirrorRequestKind] = [:] + var pendingReplay: Data? /// Capabilities belong to the current control connection. They must not /// survive a daemon restart because an older generation may not implement /// lease-fenced sizing or initial attach dimensions. @@ -216,10 +217,7 @@ final class CloudTuiManualMirrorSession { bindSharing(surfaceID: surface.id) surface.hostedView.cloudTerminalOverlay.session = self manualMirrorLogger.info("bind terminal=\(self.terminalID, privacy: .private(mask: .hash)) surface=\(self.remoteSurfaceID)") - // A color sidecar that arrived before any surface existed reaches this - // one now. The stored sidecar is the remote truth, and the next - // identical sidecar would produce an empty delta and leave the pane on - // the local theme. + // A color sidecar that arrived before any surface existed reaches this one now. let pendingColors = appliedRemoteColors.oscBytes if !pendingColors.isEmpty { surface.processRemoteOutput(pendingColors) @@ -245,6 +243,7 @@ final class CloudTuiManualMirrorSession { surface.onManualVisibilityChanged = { [weak self] visible in self?.visibilityChanged(visible) } + flushPendingReplay() surface.flushPendingManualSizeReportIfAttached() runtimeReady() } @@ -336,7 +335,7 @@ final class CloudTuiManualMirrorSession { remoteLease = nil serverCapabilities.removeAll(keepingCapacity: true) resizeScheduler.resetForReconnect() - lastRemoteGrid = nil + lastRemoteGrid = nil; pendingReplay = nil diagnosticReplayReceived = false } /// Samples the grid after Ghostty has created its runtime surface. Runtime @@ -629,6 +628,7 @@ final class CloudTuiManualMirrorSession { case let .output(surfaceID, bytes, colors): guard surfaceID == remoteSurfaceID else { return } surface?.processRemoteOutput(bytes) + if surface == nil { pendingReplay = (pendingReplay ?? Data()) + bytes } applyColors(colors) case let .resized(surfaceID, columns, rows, bytes, colors, pending): guard surfaceID == remoteSurfaceID else { return } @@ -698,8 +698,8 @@ final class CloudTuiManualMirrorSession { // otherwise land inside it. replay.append(pending) appliedRemoteColors = replayColors + guard let surface else { pendingReplay = replay; return } let token = replayFidelity.replayQueued(remote: remote, local: settledGrid()) - guard let surface else { return } surface.processRemoteReplay(replay) { [weak self, weak surface] in surface?.forceRefresh(reason: "cloud.replay.applied") self?.replayApplied(token: token) @@ -707,7 +707,6 @@ final class CloudTuiManualMirrorSession { self?.replayDiscarded(token: token) } } - /// The replay is theme-portable: it carries no palette or default-color /// OSC state, so the local Ghostty theme stands for every color the /// remote PTY did not author. The sidecar restores the authored ones and @@ -720,6 +719,7 @@ final class CloudTuiManualMirrorSession { appliedRemoteColors = colors guard !delta.isEmpty else { return } surface?.processRemoteOutput(delta) + if surface == nil { pendingReplay = (pendingReplay ?? Data()) + delta } } private func transitionToDisconnected(reason: CloudTerminalAttachmentInterruption) { diff --git a/Sources/Surfaces/CloudDisplayMembershipSyncing.swift b/Sources/Surfaces/CloudDisplayMembershipSyncing.swift new file mode 100644 index 000000000000..8fa04d434988 --- /dev/null +++ b/Sources/Surfaces/CloudDisplayMembershipSyncing.swift @@ -0,0 +1,17 @@ +import Foundation + +/// Persists a local VNC view in the VM's revisioned Cloud workspace projection. +@MainActor +protocol CloudDisplayMembershipSyncing: AnyObject { + func cloudDisplayMembershipWorkspace( + displayID: String, + panelID: UUID + ) async throws -> String? + + func syncCloudDisplayMembership( + displayID: String, + workspaceID: String, + panelID: UUID, + attached: Bool + ) async throws +} diff --git a/Sources/Surfaces/CloudPlacementCoordinator+CloudDisplayMembership.swift b/Sources/Surfaces/CloudPlacementCoordinator+CloudDisplayMembership.swift new file mode 100644 index 000000000000..da6d196b1e78 --- /dev/null +++ b/Sources/Surfaces/CloudPlacementCoordinator+CloudDisplayMembership.swift @@ -0,0 +1,94 @@ +import CmuxCloud +import CmuxSurfaceCatalogModel +import Foundation + +extension CloudPlacementCoordinator { + /// Writes the durable membership after the local pane has been admitted. + /// The lane serializes a move's detach and attach so a revision update cannot + /// publish a half-moved display view. + func syncCloudDisplayMembership( + projection: SurfaceProjection, + catalog: SurfaceCatalog + ) { + guard projection.resource.kind == .display, + let provider = catalog.provider(for: projection.resource.machine) as? any CloudDisplayMembershipSyncing + else { return } + enqueue(projection, catalog: catalog, presentFailure: false) { + guard let latest = catalog.projection(forPanel: projection.panelID), + latest.resource == projection.resource else { return false } + let old = try await provider.cloudDisplayMembershipWorkspace( + displayID: projection.resource.key, + panelID: projection.panelID + ) + let next = latest.remoteWorkspaceID + var attachedNext = false + if let old, old != next { + // Each workspace has its own projection row, so a move cannot + // be one backend transaction. Attach first to keep the old + // placement live if the new write fails; compensate on a + // failed detach so a transient move never loses membership. + if let next { + try await provider.syncCloudDisplayMembership( + displayID: projection.resource.key, + workspaceID: next, + panelID: projection.panelID, + attached: true + ) + attachedNext = true + } + do { + try await provider.syncCloudDisplayMembership( + displayID: projection.resource.key, + workspaceID: old, + panelID: projection.panelID, + attached: false + ) + } catch { + if let next { + try? await provider.syncCloudDisplayMembership( + displayID: projection.resource.key, + workspaceID: next, + panelID: projection.panelID, + attached: false + ) + } + throw error + } + } + if let next, !attachedNext { + try await provider.syncCloudDisplayMembership( + displayID: projection.resource.key, + workspaceID: next, + panelID: projection.panelID, + attached: true + ) + } + return old != next || attachedNext + } + } + + /// Removes only this local view's durable token. Other clients and other + /// views retain their membership in the shared projection. + func syncCloudDisplayMembershipEnd( + projection: SurfaceProjection, + reason: SurfaceProjectionEndReason, + catalog: SurfaceCatalog + ) { + guard reason == .paneClosed, + projection.resource.kind == .display, + let provider = catalog.provider(for: projection.resource.machine) as? any CloudDisplayMembershipSyncing else { return } + enqueue(projection, catalog: catalog, presentFailure: false) { + guard let workspaceID = try await provider.cloudDisplayMembershipWorkspace( + displayID: projection.resource.key, + panelID: projection.panelID + ) else { return false } + try await provider.syncCloudDisplayMembership( + displayID: projection.resource.key, + workspaceID: workspaceID, + panelID: projection.panelID, + attached: false + ) + return true + } + } +} diff --git a/Sources/Surfaces/CloudPlacementCoordinator.swift b/Sources/Surfaces/CloudPlacementCoordinator.swift index c2d19179d7f8..a3818e9d1d58 100644 --- a/Sources/Surfaces/CloudPlacementCoordinator.swift +++ b/Sources/Surfaces/CloudPlacementCoordinator.swift @@ -121,6 +121,7 @@ final class CloudPlacementCoordinator { // when the pane moves into an unbound viewer workspace. let current = projectionInCurrentWorkspace(projection) catalog.setRemotePlacement(for: projection, workspaceID: current.remoteWorkspaceID, tabID: nil) + syncCloudDisplayMembership(projection: projection, catalog: catalog) return } guard let target = boundRemoteWorkspaceID(forLocalWorkspace: projection.workspaceID, on: projection.resource.machine), @@ -211,6 +212,10 @@ final class CloudPlacementCoordinator { } func projectionDidEnd(_ projection: SurfaceProjection, reason: SurfaceProjectionEndReason, catalog: SurfaceCatalog) { + if projection.isLocalWorkspaceView { + syncCloudDisplayMembershipEnd(projection: projection, reason: reason, catalog: catalog) + return + } guard reason == .paneClosed, let bound = boundRemoteWorkspaceID(forLocalWorkspace: projection.workspaceID, on: projection.resource.machine), let provider = catalog.provider(for: projection.resource.machine) as? any SurfacePlacementSyncing else { return } @@ -245,7 +250,9 @@ final class CloudPlacementCoordinator { let current = catalog.projections.filter { $0.resource == resourceID } guard !current.isEmpty else { return false } if let state = catalog.cloudStates[resourceID.machine] { - guard current.contains(where: { catalog.cloudWorkspaceProjectionCoordinator.retainsProjection($0, in: state) }) else { return false } + guard current.contains(where: { + catalog.cloudWorkspaceProjectionCoordinator.retainsProjection($0, in: state, catalog: catalog) + }) else { return false } } let targets = Set(current.compactMap { self.boundRemoteWorkspaceID(forLocalWorkspace: $0.workspaceID, on: resourceID.machine) @@ -298,7 +305,7 @@ final class CloudPlacementCoordinator { } @discardableResult - private func enqueue( + func enqueue( _ projection: SurfaceProjection, catalog: SurfaceCatalog, presentFailure: Bool = true, diff --git a/Sources/Surfaces/CloudWorkspaceProjectionCoordinator.swift b/Sources/Surfaces/CloudWorkspaceProjectionCoordinator.swift index f73582d528a9..7b80ceb29185 100644 --- a/Sources/Surfaces/CloudWorkspaceProjectionCoordinator.swift +++ b/Sources/Surfaces/CloudWorkspaceProjectionCoordinator.swift @@ -63,6 +63,9 @@ final class CloudWorkspaceProjectionCoordinator { guard let binding = environment.bindings()[projection.workspaceID], binding.vmID == state.machine.rawValue, let workspaceID = binding.remoteWorkspaceID else { return true } let tabs = state.lookupIndex.tabs(contentKind: projection.resource.kind.rawValue, contentID: projection.resource.key) + if projection.resource.kind == .display && projection.remoteTabID == nil { + return state.displayMemberships.contains { $0.workspaceID == workspaceID && $0.displayID == projection.resource.key } + } return tabs.contains { tab in guard projection.remoteTabID == nil || projection.remoteTabID == tab.id, let pane = state.lookupIndex.pane(id: tab.paneID), @@ -71,6 +74,24 @@ final class CloudWorkspaceProjectionCoordinator { } } + /// Applies the catalog's known display inventory to the graph check. A + /// frontend row can carry a syntactically valid `display:*` value that is + /// no longer an exposed display after reconnect; that row must not retain + /// or recreate a local pane. + func retainsProjection( + _ projection: SurfaceProjection, + in state: CloudVMState, + catalog: SurfaceCatalog + ) -> Bool { + guard retainsProjection(projection, in: state) else { return false } + guard projection.resource.kind == .display, projection.remoteTabID == nil else { return true } + return catalog.cloudDisplayMemberships().contains { + $0.machine == projection.resource.machine + && $0.displayID == projection.resource.key + && $0.workspaceID == projection.remoteWorkspaceID + } + } + func cancel(machine: SurfaceMachineID) { tasks.removeValue(forKey: machine)?.task.cancel() requested.remove(machine) @@ -115,8 +136,10 @@ final class CloudWorkspaceProjectionCoordinator { if !Task.isCancelled { requested.insert(machine) } return } - let view = try catalog.remoteView(for: placement.resource, tabID: placement.remoteTabID, - workspaceID: placement.remoteTabID == nil ? nil : remoteID) + let view = try catalog.remoteView( + for: placement, + fallbackWorkspaceID: remoteID + ) _ = try await catalog.project(placement.resource, into: .workspace(id: workspaceID, placement: .tab), focus: false, reuseExisting: true, reuseInWorkspace: workspaceID, remoteView: view) } diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+CloudDisplayMembership.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+CloudDisplayMembership.swift new file mode 100644 index 000000000000..e7a405a9a86c --- /dev/null +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+CloudDisplayMembership.swift @@ -0,0 +1,110 @@ +import CmuxCloud +import CmuxCloudTui +import CmuxSurfaceCatalogModel +import CryptoKit +import Foundation + +@MainActor +extension CmuxTuiSurfaceProvider: CloudDisplayMembershipSyncing { + func cloudDisplayMembershipWorkspace(displayID: String, panelID: UUID) async throws -> String? { + guard let connected = try? await links.connected(machineID: machineID), + let link = await links.link(machineID: machineID) else { + throw ProviderError.machineAsleep(machineID) + } + let data = try await link.run(arguments: CloudTuiRequests.snapshotArguments(socketPath: connected.socketPath)) + guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let state = CmuxTuiSnapshotParser.state(fromSnapshot: object, machine: machine), + state.document.containsCollection("frontend_projections") else { + throw SurfaceCatalogError.unsupported(CloudGuestDisplaySnapshot.unavailableMessage) + } + let clientID = CloudTuiClientPaths().notificationClientID() + let viewID = panelID.uuidString.lowercased() + return state.displayMemberships.first { + $0.displayID == displayID && $0.clientID == clientID && $0.viewID == viewID + }?.workspaceID + } + + func syncCloudDisplayMembership( + displayID: String, + workspaceID: String, + panelID: UUID, + attached: Bool + ) async throws { + let resourceID = SurfaceResourceID(machine: machine, kind: .display, key: displayID) + guard catalog.resources[resourceID]?.kind == .display else { + throw SurfaceCatalogError.unknownResource(resourceID) + } + guard let connected = try? await links.connected(machineID: machineID), + let link = await links.link(machineID: machineID) else { + throw ProviderError.machineAsleep(machineID) + } + let clientID = CloudTuiClientPaths().notificationClientID() + let viewID = panelID.uuidString.lowercased() + let projectionID = Self.displayMembershipProjectionID(machine: machine, workspaceID: workspaceID) + let windowID = CloudVMDisplayMembership.projectionWindowID(machine: machine, workspaceID: workspaceID) + let idempotencyKey = "cmux-cloud-display-membership-\(UUID().uuidString.lowercased())" + var lastError: Error? + for _ in 0..<4 { + try Task.checkCancellation() + let data = try await link.run(arguments: CloudTuiRequests.snapshotArguments(socketPath: connected.socketPath)) + guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let state = CmuxTuiSnapshotParser.state(fromSnapshot: object, machine: machine), + state.document.containsCollection("frontend_projections") else { + throw SurfaceCatalogError.unsupported(CloudGuestDisplaySnapshot.unavailableMessage) + } + guard state.workspaceIDs.contains(workspaceID) else { + throw SurfaceCatalogError.destinationNotFound("workspace \(workspaceID) on \(machine.rawValue)") + } + let previousMemberships = Set(state.displayMemberships.filter { $0.workspaceID == workspaceID }) + var memberships = previousMemberships + let token = CloudVMDisplayMembership( + machine: machine, + workspaceID: workspaceID, + displayID: displayID, + clientID: clientID, + viewID: viewID + ) + if attached { memberships.insert(token) } else { memberships.remove(token) } + let rows = (object["frontend_projections"] as? [[String: Any]]) ?? [] + let row = rows.first { ($0["id"] as? String) == projectionID } + if row != nil, memberships == previousMemberships { return } + let projection: [String: Any] = [ + "schema": CloudVMDisplayMembership.projectionSchema, + "machine_id": machine.rawValue, + "workspace_id": workspaceID, + "memberships": memberships.sorted { + ($0.displayID, $0.clientID, $0.viewID) < ($1.displayID, $1.clientID, $1.viewID) + }.map { [ + "display_id": $0.displayID, + "client_id": $0.clientID, + "view_id": $0.viewID, + ] }, + ] + let expected = row.flatMap { CloudWireNumber.unsigned($0["projection_revision"]) } + let request = CloudTuiRequests.putCloudDisplayMembershipProjection( + projectionID: projectionID, + frontendID: CloudVMDisplayMembership.projectionFrontendID, + windowID: windowID, + generation: CloudVMDisplayMembership.projectionGeneration, + projection: projection, + expectedProjectionRevision: expected, + idempotencyKey: idempotencyKey + ) + do { + _ = try await link.run(arguments: request) + scheduleRefresh() + return + } catch { + lastError = error + guard Self.isRevisionConflict(error) else { throw error } + } + } + throw lastError ?? SurfaceCatalogError.unsupported(CloudGuestDisplaySnapshot.unavailableMessage) + } + + private static func displayMembershipProjectionID(machine: SurfaceMachineID, workspaceID: String) -> String { + let input = Data("\(machine.rawValue)/\(workspaceID)/\(CloudVMDisplayMembership.projectionSchema)".utf8) + let digest = SHA256.hash(data: input) + return "projection_" + digest.prefix(16).map { String(format: "%02x", $0) }.joined() + } +} diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift index 0c02575b6d5a..ceaa2093b49a 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift @@ -263,7 +263,7 @@ extension CmuxTuiSurfaceProvider { if let state = cloudState { let resourceID = SurfaceResourceID(machine: machine, kind: .terminal, key: terminalID) guard catalog.projections(of: resourceID).contains(where: { - catalog.cloudWorkspaceProjectionCoordinator.retainsProjection($0, in: state) + catalog.cloudWorkspaceProjectionCoordinator.retainsProjection($0, in: state, catalog: catalog) }) else { continue } } for session in sessionsByTerminal[terminalID] ?? [] { diff --git a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift index 3f9d13f10053..ab05a915f9e0 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift @@ -82,7 +82,6 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { var portDiscovery = CloudPortDiscovery() private(set) var summaryGeneration: UInt64 = 0 let loadPortSummary: @MainActor (String) async throws -> VMSummary - func publishPortDiscovery() { guard isRegisteredInCatalog() else { return } info.portDiscoveryState = portDiscovery.state @@ -787,6 +786,7 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { info: info, observation: acceptedObservation ) + catalog.reconcileCloudRemoteState(machine: machine, state: state, observation: acceptedObservation) if reconcileTitles { catalog.cloudWorkspaceRenameService.reconcileRemoteState( machine: machine, @@ -956,7 +956,7 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { workspaceID: created.workspaceID, panelID: created.panelID, remoteWorkspaceID: createdPlacement?.workspaceID ?? selectedView?.workspace.id, - remoteTabID: createdPlacement?.tabID ?? selectedView?.tabID + remoteTabID: createdPlacement?.tabID ?? (selectedView?.isCloudDisplayMembershipView == true ? nil : selectedView?.tabID) ) } @@ -1700,7 +1700,7 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { let terminals = catalog.authoritativeSnapshot.resources(on: machine).filter { $0.kind == .terminal } for terminal in terminals { for projection in catalog.projections(of: terminal.id) where !materializedPanels.contains(projection.panelID) { - guard cloudState.map({ catalog.cloudWorkspaceProjectionCoordinator.retainsProjection(projection, in: $0) }) != false, + guard cloudState.map({ catalog.cloudWorkspaceProjectionCoordinator.retainsProjection(projection, in: $0, catalog: catalog) }) != false, let workspace = AppDelegate.shared?.workspace(containingSurfaceID: projection.panelID), let paneID = SurfacePaneFactory.paneID(ofPanel: projection.panelID, in: projection.workspaceID) else { continue diff --git a/Sources/Surfaces/SurfaceCatalog+CloudDisplayMembership.swift b/Sources/Surfaces/SurfaceCatalog+CloudDisplayMembership.swift new file mode 100644 index 000000000000..4a75ae139429 --- /dev/null +++ b/Sources/Surfaces/SurfaceCatalog+CloudDisplayMembership.swift @@ -0,0 +1,156 @@ +import CmuxSurfaceCatalogModel +import Foundation + +extension SurfaceCatalog { + /// The accepted daemon projection rows that describe VNC membership. This is + /// derived at snapshot time so the tree never reads a local pane as remote + /// authority. + func cloudDisplayMemberships() -> [CloudVMDisplayMembership] { + let knownDisplayIDs = Set(resources.keys.filter { $0.kind == .display }) + return cloudStates.values + .flatMap(\.displayMemberships) + .filter { + knownDisplayIDs.contains(SurfaceResourceID( + machine: $0.machine, kind: .display, key: $0.displayID + )) + } + .sorted { + ($0.machine.rawValue, $0.workspaceID, $0.displayID, $0.clientID, $0.viewID) + < ($1.machine.rawValue, $1.workspaceID, $1.displayID, $1.clientID, $1.viewID) + } + } + + /// Reconstructs one accepted frontend display view from the catalog's + /// authoritative projection. This is the materialization boundary for a + /// workspace row on a client that has no corresponding daemon display tab. + func cloudDisplayMembershipView( + for resource: SurfaceResourceID, + workspaceID: String, + viewID: String? = nil + ) -> SurfaceRemoteView? { + guard resource.kind == .display else { return nil } + let matches = cloudDisplayMemberships().filter { + $0.machine == resource.machine && $0.displayID == resource.key && $0.workspaceID == workspaceID + && (viewID == nil || $0.viewID == viewID) + } + guard matches.count == 1, let membership = matches.first, + let workspace = machines[resource.machine]?.remoteWorkspaces?.first(where: { $0.id == workspaceID }) + ?? cloudStates[resource.machine]?.workspaces.first(where: { $0.id == workspaceID }).map({ + SurfaceRemoteWorkspace(id: $0.id, name: $0.name, index: $0.index, focused: $0.focused) + }) else { return nil } + return SurfaceRemoteView( + tabID: SurfaceRemoteView.cloudDisplayMembershipViewPrefix + membership.viewID, + workspace: workspace, + name: nil, + index: nil, + focused: false + ) + } + + /// Resolves the exact placement token carried by a workspace group. A + /// frontend display view has no daemon tab id, so its marker is resolved + /// against the accepted projection instead of the base display resource. + func remoteView( + for placement: SurfaceResourcePlacement, + fallbackWorkspaceID: String? = nil + ) throws -> SurfaceRemoteView? { + let workspaceID = placement.remoteWorkspaceID ?? fallbackWorkspaceID + if let membershipViewID = placement.cloudDisplayMembershipViewID { + guard let workspaceID, + let view = cloudDisplayMembershipView( + for: placement.resource, + workspaceID: workspaceID, + viewID: membershipViewID + ) else { + throw SurfaceCatalogError.unavailable( + placement.resource, + reason: "Cloud display membership is no longer present" + ) + } + return view + } + if resources[placement.resource]?.kind == .display, + resources[placement.resource]?.remoteViews == nil, + let workspaceID, + let view = cloudDisplayMembershipView(for: placement.resource, workspaceID: workspaceID) { + return view + } + return try remoteView( + for: placement.resource, + tabID: placement.remoteTabID, + workspaceID: workspaceID + ) + } + + /// Resolves a group placement without allowing a missing display tab to + /// fall back to an unrelated resource view. + func resolveRemoteView( + for member: SurfaceResourcePlacement, + fallbackWorkspaceID: String? + ) throws -> SurfaceRemoteView? { + guard resources[member.resource] != nil else { return nil } + if member.cloudDisplayMembershipViewID == nil, member.remoteTabID == nil, + let workspaceID = member.remoteWorkspaceID ?? fallbackWorkspaceID, + projections.contains(where: { + $0.resource == member.resource && $0.isLocalWorkspaceView && $0.remoteWorkspaceID == workspaceID + }) { + return nil + } + return try remoteView(for: member, fallbackWorkspaceID: fallbackWorkspaceID) + } + + /// Resolves an agent-provided remote placement against the latest accepted + /// graph. A workspace id alone is valid only when it identifies one view; + /// callers that need a particular tab must provide `tabID`. + func remoteView( + for id: SurfaceResourceID, + tabID: String? = nil, + workspaceID: String? = nil + ) throws -> SurfaceRemoteView? { + guard let resource = resources[id] else { throw SurfaceCatalogError.unknownResource(id) } + if let tabID, tabID.hasPrefix(SurfaceRemoteView.cloudDisplayMembershipViewPrefix) { + let membershipViewID = String(tabID.dropFirst(SurfaceRemoteView.cloudDisplayMembershipViewPrefix.count)) + guard resource.kind == .display, + !membershipViewID.isEmpty, + let workspaceID, + let view = cloudDisplayMembershipView( + for: id, + workspaceID: workspaceID, + viewID: membershipViewID + ) else { + throw SurfaceCatalogError.unavailable(id, reason: "Cloud display membership is no longer present") + } + return view + } + guard let views = resource.remoteViews else { + if tabID != nil || workspaceID != nil { + throw SurfaceCatalogError.unavailable(id, reason: "remote placement data is unavailable") + } + return nil + } + if let tabID { + let matches = views.filter { $0.tabID == tabID } + guard matches.count == 1, let view = matches.first else { + if matches.count > 1 { + throw SurfaceCatalogError.unavailable(id, reason: "remote tab \(tabID) has ambiguous placement") + } + throw SurfaceCatalogError.unavailable(id, reason: "remote tab \(tabID) is no longer present") + } + if let workspaceID, view.workspace.id != workspaceID { + throw SurfaceCatalogError.unavailable(id, reason: "remote tab \(tabID) is not in workspace \(workspaceID)") + } + return view + } + guard let workspaceID else { return nil } + let matches = views.filter { !$0.isCloudDisplayMembershipView && $0.workspace.id == workspaceID } + guard matches.count <= 1 else { + throw SurfaceCatalogError.ambiguousRemotePlacement(id, workspaceID: workspaceID) + } + guard let view = matches.first else { + if resource.kind == .display, views.contains(where: \.isCloudDisplayMembershipView) { return nil } + throw SurfaceCatalogError.unavailable(id, reason: "remote workspace \(workspaceID) has no view of this resource") + } + return view + } + +} diff --git a/Sources/Surfaces/SurfaceCatalog+CloudDisplayProjection.swift b/Sources/Surfaces/SurfaceCatalog+CloudDisplayProjection.swift new file mode 100644 index 000000000000..8b57a8572019 --- /dev/null +++ b/Sources/Surfaces/SurfaceCatalog+CloudDisplayProjection.swift @@ -0,0 +1,14 @@ +import CmuxSurfaceCatalogModel +import Foundation + +extension SurfaceCatalog { + /// Registers a Cloud pane and admits its local display membership through + /// the same placement coordinator used by pane moves and closes. + func recordCloudProjection(_ projection: SurfaceProjection) { + let resolved = projection.remoteWorkspaceID == nil + ? cloudPlacementCoordinator.projectionInCurrentWorkspace(projection) + : projection + insertSupersedingLocalPlaceholder(resolved) + cloudPlacementCoordinator.projectionDidMove(resolved, catalog: self) + } +} diff --git a/Sources/Surfaces/SurfaceCatalog+Groups.swift b/Sources/Surfaces/SurfaceCatalog+Groups.swift index 794d52cb4dfc..ff1eee0574ac 100644 --- a/Sources/Surfaces/SurfaceCatalog+Groups.swift +++ b/Sources/Surfaces/SurfaceCatalog+Groups.swift @@ -2,7 +2,6 @@ import CmuxCloud import CmuxCore import CmuxSurfaceCatalogModel import Foundation - /// A collection of resources that travels as one drag or one "open all": a cmux-tui /// workspace on a machine, or a local workspace (the panes it projects). The canonical /// payload is typed placements. `resources` and the group workspace id remain as derived @@ -181,33 +180,6 @@ extension SurfaceCatalog { return projected } - private func resolveRemoteView( - for member: SurfaceResourcePlacement, - fallbackWorkspaceID: String? - ) throws -> SurfaceRemoteView? { - // Unknown resources are skipped by the group projector. Once a resource exists, - // delegate placement validation to the catalog's single resolver so explicit IDs - // cannot silently fall back when remote view metadata is absent. - guard resources[member.resource] != nil else { - return nil - } - if let tabID = member.remoteTabID { - return try remoteView( - for: member.resource, - tabID: tabID, - workspaceID: member.remoteWorkspaceID ?? fallbackWorkspaceID - ) - } - let workspaceID = member.remoteWorkspaceID ?? fallbackWorkspaceID - guard let workspaceID else { return nil } - if projections.contains(where: { - $0.resource == member.resource && $0.isLocalWorkspaceView && $0.remoteWorkspaceID == workspaceID - }) { - return nil - } - return try remoteView(for: member.resource, workspaceID: workspaceID) - } - /// How a group becomes a new local workspace: the machinery a caller injects so the /// layout can be checked without AppKit. struct NewWorkspaceHost { diff --git a/Sources/Surfaces/SurfaceCatalog+Snapshot.swift b/Sources/Surfaces/SurfaceCatalog+Snapshot.swift index a2e20b80af20..f55aba2cef10 100644 --- a/Sources/Surfaces/SurfaceCatalog+Snapshot.swift +++ b/Sources/Surfaces/SurfaceCatalog+Snapshot.swift @@ -41,7 +41,8 @@ extension SurfaceCatalog { resources: resources.values.map(resourceForPresentation).sorted { $0.catalogPrecedes($1) }, projections: projections.sorted { $0.panelID.uuidString < $1.panelID.uuidString }, staleMachineIDs: Set(cloudStateObservations.filter { $0.value.freshness != .current }.keys), - displayCreationMachines: displayCreationMachines.isEmpty ? nil : displayCreationMachines + displayCreationMachines: displayCreationMachines.isEmpty ? nil : displayCreationMachines, + cloudDisplayMemberships: cloudDisplayMemberships() ) } diff --git a/Sources/Surfaces/SurfaceCatalog+WorkspaceMembership.swift b/Sources/Surfaces/SurfaceCatalog+WorkspaceMembership.swift index 479b421952fc..0c2e675ca72e 100644 --- a/Sources/Surfaces/SurfaceCatalog+WorkspaceMembership.swift +++ b/Sources/Surfaces/SurfaceCatalog+WorkspaceMembership.swift @@ -16,7 +16,7 @@ extension SurfaceCatalog { let machineSnapshot = snapshot let machineInfo = machineSnapshot.machines.first { $0.id == machine } var workspace = machineInfo?.remoteWorkspaces?.first { $0.id == workspaceID } - let resources = machineSnapshot.resources(on: machine) + let resources = machineSnapshot.cloudWorkspaceResources(on: machine) struct Candidate { let placement: SurfaceResourcePlacement diff --git a/Sources/Surfaces/SurfaceCatalog.swift b/Sources/Surfaces/SurfaceCatalog.swift index 4c68ebb49dee..d90e3a97a349 100644 --- a/Sources/Surfaces/SurfaceCatalog.swift +++ b/Sources/Surfaces/SurfaceCatalog.swift @@ -83,7 +83,6 @@ final class SurfaceCatalog { var pendingRestoredProjections = SurfaceProjectionRestoreStore() { didSet { cloudProjectionIndex = nil } } /// Focus/select behavior the app uses to bring an existing projection forward. var focusProjection: ((SurfaceProjection) -> Void)? - init( abandonedMaterializationTimeout: Duration = SurfaceCatalog.defaultAbandonedMaterializationTimeout, retiredMaterializationRetention: Duration = SurfaceCatalog.defaultRetiredMaterializationRetention, @@ -620,19 +619,34 @@ final class SurfaceCatalog { // different placement after a concurrent daemon update. let resolvedRemoteView: SurfaceRemoteView? if let remoteView { - guard let current = resource.remoteViews?.first(where: { $0.tabID == remoteView.tabID }) else { - throw SurfaceCatalogError.unavailable( - id, - reason: "remote tab \(remoteView.tabID) is no longer present" - ) - } - guard current.workspace.id == remoteView.workspace.id else { - throw SurfaceCatalogError.unavailable( - id, - reason: "remote tab \(remoteView.tabID) moved to workspace \(current.workspace.id)" - ) + if let membershipViewID = remoteView.cloudDisplayMembershipViewID { + guard resource.kind == .display, + let current = cloudDisplayMembershipView( + for: id, + workspaceID: remoteView.workspace.id, + viewID: membershipViewID + ) else { + throw SurfaceCatalogError.unavailable( + id, + reason: "Cloud display membership is no longer present" + ) + } + resolvedRemoteView = current + } else { + guard let current = resource.remoteViews?.first(where: { $0.tabID == remoteView.tabID }) else { + throw SurfaceCatalogError.unavailable( + id, + reason: "remote tab \(remoteView.tabID) is no longer present" + ) + } + guard current.workspace.id == remoteView.workspace.id else { + throw SurfaceCatalogError.unavailable( + id, + reason: "remote tab \(remoteView.tabID) moved to workspace \(current.workspace.id)" + ) + } + resolvedRemoteView = current } - resolvedRemoteView = current } else { resolvedRemoteView = nil } @@ -646,6 +660,9 @@ final class SurfaceCatalog { // An explicit placement must match an explicit projection. A legacy // projection with no tab id is not safe to reuse because it may be // showing another tab of the same terminal. + if let resolvedRemoteView, resolvedRemoteView.isCloudDisplayMembershipView { + return $0.remoteTabID == nil && $0.remoteWorkspaceID == resolvedRemoteView.workspace.id + } return resolvedRemoteView == nil || $0.remoteTabID == resolvedRemoteView?.tabID }) { try claimCompletedMaterializationIfNeeded(materializationKey, projection: existing) @@ -704,7 +721,6 @@ final class SurfaceCatalog { throw CancellationError() } record(projection) - cloudPlacementCoordinator.projectionDidMove(projection, catalog: self) return (projection, false) } @@ -881,7 +897,6 @@ final class SurfaceCatalog { throw SurfaceCatalogError.unavailable(id, reason: "projection closed while opening") } acknowledgeMaterialization(key, waiterID: waiterID) - if !result.reused { cloudPlacementCoordinator.projectionDidMove(projection, catalog: self) } if result.reused, focus { focusProjection?(projection) } return (projection, result.reused) } @@ -1109,7 +1124,7 @@ final class SurfaceCatalog { /// Records a materialized pane and reconciles it with the installed graph. func record(_ projection: SurfaceProjection) { consumePendingProjectionIfMaterialized(projection) - insertSupersedingLocalPlaceholder(cloudPlacementCoordinator.projectionInCurrentWorkspace(projection)) + recordCloudProjection(projection) reconcileCloudWorkspaceBinding(localWorkspaceID: projection.workspaceID) reconcileCloudProjection(projection) syncCloudTerminalTabIcon(projection) @@ -1144,28 +1159,11 @@ final class SurfaceCatalog { )) } - /// Fills a legacy projection's missing remote coordinates, or replaces a - /// stale coordinate only when the caller explicitly supplied the same tab. - /// The set remains the single owner of projection identity. - @discardableResult - private func attachRemoteView(_ view: SurfaceRemoteView?, to projection: SurfaceProjection) -> SurfaceProjection { - guard let view, - projection.remoteTabID == nil || projection.remoteTabID == view.tabID else { return projection } - projections.remove(projection) - var updated = projection - updated.remoteWorkspaceID = view.workspace.id - updated.remoteTabID = view.tabID - projections.insert(updated) - reconcileCloudWorkspaceBinding(localWorkspaceID: updated.workspaceID) - notifyChange(for: updated.resource.machine) - return updated - } - /// A pane can show one resource. When a remote resource is projected into a pane the /// local provider already registered as a plain local terminal (the pane is created /// first, then attached), the local placeholder yields: its projection ends and the /// local resource disappears, so the pane counts once, as the remote terminal. - private func insertSupersedingLocalPlaceholder(_ projection: SurfaceProjection) { + func insertSupersedingLocalPlaceholder(_ projection: SurfaceProjection) { if !projection.resource.machine.isLocal { for existing in projections where existing.panelID == projection.panelID && existing.resource.machine.isLocal { projections.remove(existing) @@ -1260,43 +1258,32 @@ final class SurfaceCatalog { notifyChange(for: source.resource.machine) } - /// Resolves an agent-provided remote placement against the latest accepted - /// graph. A workspace id alone is valid only when it identifies one view; - /// callers that need a particular tab must provide `tabID`. - func remoteView( - for id: SurfaceResourceID, - tabID: String? = nil, - workspaceID: String? = nil - ) throws -> SurfaceRemoteView? { - guard let resource = resources[id] else { throw SurfaceCatalogError.unknownResource(id) } - guard let views = resource.remoteViews else { - if tabID != nil || workspaceID != nil { - throw SurfaceCatalogError.unavailable(id, reason: "remote placement data is unavailable") - } - return nil - } - if let tabID { - let matches = views.filter { $0.tabID == tabID } - guard matches.count == 1, let view = matches.first else { - if matches.count > 1 { - throw SurfaceCatalogError.unavailable(id, reason: "remote tab \(tabID) has ambiguous placement") - } - throw SurfaceCatalogError.unavailable(id, reason: "remote tab \(tabID) is no longer present") - } - if let workspaceID, view.workspace.id != workspaceID { - throw SurfaceCatalogError.unavailable(id, reason: "remote tab \(tabID) is not in workspace \(workspaceID)") - } - return view - } - guard let workspaceID else { return nil } - let matches = views.filter { $0.workspace.id == workspaceID } - guard matches.count <= 1 else { - throw SurfaceCatalogError.ambiguousRemotePlacement(id, workspaceID: workspaceID) - } - guard let view = matches.first else { - throw SurfaceCatalogError.unavailable(id, reason: "remote workspace \(workspaceID) has no view of this resource") + /// Fills a legacy projection's missing remote coordinates, or replaces a + /// stale coordinate only when the caller explicitly supplied the same tab. + /// The set remains the single owner of projection identity. + @discardableResult + private func attachRemoteView(_ view: SurfaceRemoteView?, to projection: SurfaceProjection) -> SurfaceProjection { + guard let view else { return projection } + if view.isCloudDisplayMembershipView { + guard projection.remoteTabID == nil else { return projection } + projections.remove(projection) + var updated = projection + updated.remoteWorkspaceID = view.workspace.id + updated.remoteTabID = nil + projections.insert(updated) + reconcileCloudWorkspaceBinding(localWorkspaceID: updated.workspaceID) + notifyChange(for: updated.resource.machine) + return updated } - return view + guard projection.remoteTabID == nil || projection.remoteTabID == view.tabID else { return projection } + projections.remove(projection) + var updated = projection + updated.remoteWorkspaceID = view.workspace.id + updated.remoteTabID = view.tabID + projections.insert(updated) + reconcileCloudWorkspaceBinding(localWorkspaceID: updated.workspaceID) + notifyChange(for: updated.resource.machine) + return updated } /// Returns whether the panel is backed by a non-local resource projection. @@ -1348,7 +1335,7 @@ final class SurfaceCatalog { if resources[record.resource] != nil { wokenMachines.insert(record.resource.machine) pendingRestoredProjections.remove(panelID: record.panelID) - insertSupersedingLocalPlaceholder(cloudPlacementCoordinator.restoredProjection(record, workspaceID: workspaceID)) + recordCloudProjection(cloudPlacementCoordinator.restoredProjection(record, workspaceID: workspaceID)) } else { pendingRestoredProjections.stage(record, workspaceID: workspaceID) } @@ -1408,7 +1395,7 @@ final class SurfaceCatalog { isAllowed: canRestoreProjection ) for projection in resolved { - insertSupersedingLocalPlaceholder(cloudPlacementCoordinator.resolvingLocalPreviewMembership(projection)) + recordCloudProjection(cloudPlacementCoordinator.resolvingLocalPreviewMembership(projection)) resolvedWorkspaceIDs.insert(projection.workspaceID) } for workspaceID in resolvedWorkspaceIDs { diff --git a/Sources/Surfaces/SurfaceProvider+MaterializationValidation.swift b/Sources/Surfaces/SurfaceProvider+MaterializationValidation.swift index 5c10d999cb3a..9464582a8aca 100644 --- a/Sources/Surfaces/SurfaceProvider+MaterializationValidation.swift +++ b/Sources/Surfaces/SurfaceProvider+MaterializationValidation.swift @@ -33,7 +33,8 @@ extension SurfaceProvider { } guard projection.resource == resource.id, projection.workspaceID == destination.workspaceID, - reservation == nil || remoteView == nil || projection.remoteTabID == remoteView?.tabID, + reservation == nil || remoteView == nil || remoteView?.isCloudDisplayMembershipView == true + || projection.remoteTabID == remoteView?.tabID, expectedWorkspace == nil || projection.remoteWorkspaceID == expectedWorkspace else { if let reservation, projection.panelID == reservation.panelID { // Stop an adopted transport but retain the manual pane for its diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index f8f00d84a3e8..7824a6842cfd 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -714,6 +714,8 @@ 5910F6B0C9704B8DB8607318 /* CloudDirectoryLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EFB28A7B5DF48ED8EF9CFB6 /* CloudDirectoryLifecycleTests.swift */; }; F3FAB983EE90414CAA90F970 /* CloudDirectoryTestFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFC7BA3C7C834D20B3173D36 /* CloudDirectoryTestFixture.swift */; }; 33E9D640ED2646A38673776D /* CloudDisplayCatalogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B7DF8AE123341DEBA066731 /* CloudDisplayCatalogTests.swift */; }; + 27BBEA55BF5579F93E5C55C3 /* CloudDisplayMembershipProjectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 635AF226CFD6879AC9ACFD09 /* CloudDisplayMembershipProjectionTests.swift */; }; + A2482459B25F92BBE1020EEB /* CloudDisplayMembershipSyncing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5E23BA1DDF8DA4B0BB766CA /* CloudDisplayMembershipSyncing.swift */; }; C10D0A200000000000000001 /* CloudDomainsCLIIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10D0A200000000000000002 /* CloudDomainsCLIIntegrationTests.swift */; }; 71FE9F238B1717D43FC01E63 /* CloudEqualCursorConflictRecoveryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D4316FCA6BA8B1260D8FFB6 /* CloudEqualCursorConflictRecoveryTests.swift */; }; 5EDE6BFD28BE4BD58EF84889 /* CloudFeatureAvailabilityObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 668F7B9BC77445B793C0298A /* CloudFeatureAvailabilityObserver.swift */; }; @@ -757,6 +759,7 @@ C11324810000000000000001 /* CloudManualMirrorMaterialization.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11324820000000000000001 /* CloudManualMirrorMaterialization.swift */; }; C12376010000000000000001 /* CloudManualMirrorPresentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12376020000000000000001 /* CloudManualMirrorPresentationTests.swift */; }; 95E93CB008517B3E2ABDC326 /* CloudManualMirrorSocketFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF70B0862D206C62CA5ACF65 /* CloudManualMirrorSocketFixture.swift */; }; + 865554BEE843813A69525FC0 /* CloudManualMirrorStartupRenderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD2286462BDC54589245BFF /* CloudManualMirrorStartupRenderingTests.swift */; }; C11322A10000000000000001 /* CloudManualMirrorTransportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11322A20000000000000001 /* CloudManualMirrorTransportTests.swift */; }; 0EBD4FE75B6A5B18E6F0F71D /* CloudMenuContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E5F492610421C2D48474B6 /* CloudMenuContent.swift */; }; F284C2BA21F3AB687A94D749 /* CloudMenuContentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ECDB7FADBB365A2212F1BB0 /* CloudMenuContentTests.swift */; }; @@ -779,6 +782,7 @@ A12480000000000000000005 /* CloudPaneCreationFailureStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12480000000000000000006 /* CloudPaneCreationFailureStore.swift */; }; A12480000000000000000003 /* CloudPaneCreationFailureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12480000000000000000004 /* CloudPaneCreationFailureView.swift */; }; C12537000000000000000215 /* CloudPaneCreationRetryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000216 /* CloudPaneCreationRetryTests.swift */; }; + 3D0803F8A42471FC25115802 /* CloudPlacementCoordinator+CloudDisplayMembership.swift in Sources */ = {isa = PBXBuildFile; fileRef = 981BED9BD8FFEE7B0CBFCD4B /* CloudPlacementCoordinator+CloudDisplayMembership.swift */; }; 118E574AA58D92C0A3FB893E /* CloudPlacementCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A22E92B91E0FFF066AE10237 /* CloudPlacementCoordinator.swift */; }; CB32AB7B7AB792F21B5C5B36 /* CloudPlacementCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0FD4E01EAF1B2F306A96F6F /* CloudPlacementCoordinatorTests.swift */; }; C5FB49903F3E8EA7192AB445 /* CloudPlacementTestProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAADF4DC604A997BD8DA561E /* CloudPlacementTestProvider.swift */; }; @@ -959,6 +963,7 @@ A12367000000000000000002 /* CloudTreeWorkspaceTitleLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12367000000000000000001 /* CloudTreeWorkspaceTitleLayoutTests.swift */; }; A838AA211BDA47268C683501 /* CloudTuiManualIOConnectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A838AA211BDA47268C683502 /* CloudTuiManualIOConnectionTests.swift */; }; 124760000000000000000006 /* CloudTuiManualMirrorSession+Capabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 124770000000000000000006 /* CloudTuiManualMirrorSession+Capabilities.swift */; }; + 79F5ACE2406FEBF9C900F333 /* CloudTuiManualMirrorSession+PendingReplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB12D123CA2A96F1DDBA3405 /* CloudTuiManualMirrorSession+PendingReplay.swift */; }; F181020CA0F7042CBC03B870 /* CloudTuiManualMirrorSession+ReplayFidelity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D8DA9E8FCC6A6A56E0B6435 /* CloudTuiManualMirrorSession+ReplayFidelity.swift */; }; A1329C030000000000000101 /* CloudTuiManualMirrorSession+SharedSizing.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1329C030000000000000001 /* CloudTuiManualMirrorSession+SharedSizing.swift */; }; C11322F10000000000000001 /* CloudTuiManualMirrorSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11322F20000000000000001 /* CloudTuiManualMirrorSession.swift */; }; @@ -1455,6 +1460,7 @@ 81A1621623F83B61D4C5FFA9 /* CmuxTuiSurfaceProvider+AttachmentRecovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8BE7D3234A1F44E2A1C52B /* CmuxTuiSurfaceProvider+AttachmentRecovery.swift */; }; 5633045E23E44075B22C08CA /* CmuxTuiSurfaceProvider+CloseTerminal.swift in Sources */ = {isa = PBXBuildFile; fileRef = E655249E82FC412B9EC2CD21 /* CmuxTuiSurfaceProvider+CloseTerminal.swift */; }; C10DC1000000000000000015 /* CmuxTuiSurfaceProvider+CloudDiagnosticFailure.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10DC1000000000000000016 /* CmuxTuiSurfaceProvider+CloudDiagnosticFailure.swift */; }; + C462743B6AB963C09DE86933 /* CmuxTuiSurfaceProvider+CloudDisplayMembership.swift in Sources */ = {isa = PBXBuildFile; fileRef = 905C49B3C3B96FE5AE1D9D0F /* CmuxTuiSurfaceProvider+CloudDisplayMembership.swift */; }; FB8AB3FDE68E4ABC9C72E6CB /* CmuxTuiSurfaceProvider+Displays.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED5ABA995260445394F81C79 /* CmuxTuiSurfaceProvider+Displays.swift */; }; AA92C99C529DB9A24064AF48 /* CmuxTuiSurfaceProvider+Environment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE30231700141C6F370FA63 /* CmuxTuiSurfaceProvider+Environment.swift */; }; A12093000000000000000002 /* CmuxTuiSurfaceProvider+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12093000000000000000001 /* CmuxTuiSurfaceProvider+Error.swift */; }; @@ -3329,6 +3335,8 @@ CFSF00000000000000000013 /* SurfaceCatalog+ChangeNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFSR00000000000000000013 /* SurfaceCatalog+ChangeNotifications.swift */; }; F11347000000000000000007 /* SurfaceCatalog+CloudBinding.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11347000000000000000008 /* SurfaceCatalog+CloudBinding.swift */; }; 78F8D449BA0D4860AE1162C7 /* SurfaceCatalog+CloudDirectoryMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B2248ED1422420F90E0D71D /* SurfaceCatalog+CloudDirectoryMetadata.swift */; }; + 11B3CE31BC9CCFE671CFC273 /* SurfaceCatalog+CloudDisplayMembership.swift in Sources */ = {isa = PBXBuildFile; fileRef = 316418AB818C4081081DA326 /* SurfaceCatalog+CloudDisplayMembership.swift */; }; + 968E42E7CCB67098FF50BB84 /* SurfaceCatalog+CloudDisplayProjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56434762F98EC43868C86D56 /* SurfaceCatalog+CloudDisplayProjection.swift */; }; F11347000000000000000001 /* SurfaceCatalog+CloudPorts.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11347000000000000000002 /* SurfaceCatalog+CloudPorts.swift */; }; E71A3B8404534D5699AA2E9B /* SurfaceCatalog+CloudRenameSubmission.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A4A80857B8A4530BD5BC498 /* SurfaceCatalog+CloudRenameSubmission.swift */; }; D4E2A030E899483F96AF0C9E /* SurfaceCatalog+CloudTerminalNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B841AA21EA4B58B34D228B /* SurfaceCatalog+CloudTerminalNavigation.swift */; }; @@ -5018,6 +5026,8 @@ 9EFB28A7B5DF48ED8EF9CFB6 /* CloudDirectoryLifecycleTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudDirectoryLifecycleTests.swift; sourceTree = ""; }; FFC7BA3C7C834D20B3173D36 /* CloudDirectoryTestFixture.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudDirectoryTestFixture.swift; sourceTree = ""; }; 6B7DF8AE123341DEBA066731 /* CloudDisplayCatalogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudDisplayCatalogTests.swift"; sourceTree = ""; }; + 635AF226CFD6879AC9ACFD09 /* CloudDisplayMembershipProjectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudDisplayMembershipProjectionTests.swift"; sourceTree = ""; }; + D5E23BA1DDF8DA4B0BB766CA /* CloudDisplayMembershipSyncing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudDisplayMembershipSyncing.swift; sourceTree = ""; }; C10D0A200000000000000002 /* CloudDomainsCLIIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudDomainsCLIIntegrationTests.swift; sourceTree = ""; }; 2D4316FCA6BA8B1260D8FFB6 /* CloudEqualCursorConflictRecoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudEqualCursorConflictRecoveryTests.swift"; sourceTree = ""; }; 668F7B9BC77445B793C0298A /* CloudFeatureAvailabilityObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudFeatureAvailabilityObserver.swift"; sourceTree = ""; }; @@ -5061,6 +5071,7 @@ C11324820000000000000001 /* CloudManualMirrorMaterialization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudManualMirrorMaterialization.swift; sourceTree = ""; }; C12376020000000000000001 /* CloudManualMirrorPresentationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudManualMirrorPresentationTests.swift; sourceTree = ""; }; EF70B0862D206C62CA5ACF65 /* CloudManualMirrorSocketFixture.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudManualMirrorSocketFixture.swift; sourceTree = ""; }; + FDD2286462BDC54589245BFF /* CloudManualMirrorStartupRenderingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudManualMirrorStartupRenderingTests.swift"; sourceTree = ""; }; C11322A20000000000000001 /* CloudManualMirrorTransportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudManualMirrorTransportTests.swift; sourceTree = ""; }; 03E5F492610421C2D48474B6 /* CloudMenuContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudMenuContent.swift; sourceTree = ""; }; 0ECDB7FADBB365A2212F1BB0 /* CloudMenuContentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudMenuContentTests.swift"; sourceTree = ""; }; @@ -5083,6 +5094,7 @@ A12480000000000000000006 /* CloudPaneCreationFailureStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudPaneCreationFailureStore.swift"; sourceTree = ""; }; A12480000000000000000004 /* CloudPaneCreationFailureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CloudPaneCreationFailureView.swift; sourceTree = ""; }; C12537000000000000000216 /* CloudPaneCreationRetryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudPaneCreationRetryTests.swift; sourceTree = ""; }; + 981BED9BD8FFEE7B0CBFCD4B /* CloudPlacementCoordinator+CloudDisplayMembership.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudPlacementCoordinator+CloudDisplayMembership.swift"; sourceTree = ""; }; A22E92B91E0FFF066AE10237 /* CloudPlacementCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudPlacementCoordinator.swift; sourceTree = ""; }; F0FD4E01EAF1B2F306A96F6F /* CloudPlacementCoordinatorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudPlacementCoordinatorTests.swift; sourceTree = ""; }; EAADF4DC604A997BD8DA561E /* CloudPlacementTestProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudPlacementTestProvider.swift"; sourceTree = ""; }; @@ -5263,6 +5275,7 @@ A12367000000000000000001 /* CloudTreeWorkspaceTitleLayoutTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudTreeWorkspaceTitleLayoutTests.swift; sourceTree = ""; }; A838AA211BDA47268C683502 /* CloudTuiManualIOConnectionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudTuiManualIOConnectionTests.swift; sourceTree = ""; }; 124770000000000000000006 /* CloudTuiManualMirrorSession+Capabilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sources/Cloud/CloudTuiManualMirrorSession+Capabilities.swift"; sourceTree = SOURCE_ROOT; }; + FB12D123CA2A96F1DDBA3405 /* CloudTuiManualMirrorSession+PendingReplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudTuiManualMirrorSession+PendingReplay.swift"; sourceTree = ""; }; 1D8DA9E8FCC6A6A56E0B6435 /* CloudTuiManualMirrorSession+ReplayFidelity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudTuiManualMirrorSession+ReplayFidelity.swift"; sourceTree = ""; }; A1329C030000000000000001 /* CloudTuiManualMirrorSession+SharedSizing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudTuiManualMirrorSession+SharedSizing.swift"; sourceTree = ""; }; C11322F20000000000000001 /* CloudTuiManualMirrorSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTuiManualMirrorSession.swift; sourceTree = ""; }; @@ -5642,6 +5655,7 @@ 4D8BE7D3234A1F44E2A1C52B /* CmuxTuiSurfaceProvider+AttachmentRecovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+AttachmentRecovery.swift"; sourceTree = ""; }; E655249E82FC412B9EC2CD21 /* CmuxTuiSurfaceProvider+CloseTerminal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+CloseTerminal.swift"; sourceTree = ""; }; C10DC1000000000000000016 /* CmuxTuiSurfaceProvider+CloudDiagnosticFailure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+CloudDiagnosticFailure.swift"; sourceTree = ""; }; + 905C49B3C3B96FE5AE1D9D0F /* CmuxTuiSurfaceProvider+CloudDisplayMembership.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+CloudDisplayMembership.swift"; sourceTree = ""; }; ED5ABA995260445394F81C79 /* CmuxTuiSurfaceProvider+Displays.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+Displays.swift"; sourceTree = ""; }; 4BE30231700141C6F370FA63 /* CmuxTuiSurfaceProvider+Environment.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+Environment.swift"; sourceTree = ""; }; A12093000000000000000001 /* CmuxTuiSurfaceProvider+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+Error.swift"; sourceTree = ""; }; @@ -7480,6 +7494,8 @@ CFSR00000000000000000013 /* SurfaceCatalog+ChangeNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceCatalog+ChangeNotifications.swift"; sourceTree = ""; }; F11347000000000000000008 /* SurfaceCatalog+CloudBinding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceCatalog+CloudBinding.swift"; sourceTree = ""; }; 7B2248ED1422420F90E0D71D /* SurfaceCatalog+CloudDirectoryMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceCatalog+CloudDirectoryMetadata.swift"; sourceTree = ""; }; + 316418AB818C4081081DA326 /* SurfaceCatalog+CloudDisplayMembership.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceCatalog+CloudDisplayMembership.swift"; sourceTree = ""; }; + 56434762F98EC43868C86D56 /* SurfaceCatalog+CloudDisplayProjection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceCatalog+CloudDisplayProjection.swift"; sourceTree = ""; }; F11347000000000000000002 /* SurfaceCatalog+CloudPorts.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "SurfaceCatalog+CloudPorts.swift"; sourceTree = ""; }; 4A4A80857B8A4530BD5BC498 /* SurfaceCatalog+CloudRenameSubmission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceCatalog+CloudRenameSubmission.swift"; sourceTree = ""; }; A9B841AA21EA4B58B34D228B /* SurfaceCatalog+CloudTerminalNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceCatalog+CloudTerminalNavigation.swift"; sourceTree = ""; }; @@ -8978,6 +8994,11 @@ F1BE71D511FF27AB7D0279A8 /* TerminalController+CloudMachinePayload.swift */, E52470C2BDCDB44AA3AF4B48 /* CloudWorkspaceCreationReveals.swift */, 16F81541F06BB9C88654913B /* SurfaceCatalog+MachineDeletion.swift */, + D5E23BA1DDF8DA4B0BB766CA /* CloudDisplayMembershipSyncing.swift */, + 981BED9BD8FFEE7B0CBFCD4B /* CloudPlacementCoordinator+CloudDisplayMembership.swift */, + 905C49B3C3B96FE5AE1D9D0F /* CmuxTuiSurfaceProvider+CloudDisplayMembership.swift */, + 316418AB818C4081081DA326 /* SurfaceCatalog+CloudDisplayMembership.swift */, + 56434762F98EC43868C86D56 /* SurfaceCatalog+CloudDisplayProjection.swift */, ); name = Surfaces; path = Surfaces; @@ -9148,6 +9169,7 @@ 8ACB13AE3FBA6BD011A6EFA5 /* CloudMenuEntry.swift */, A7C0532E8633357781BA4293 /* CloudMenuModel.swift */, 276B8742C711D7E718A5DC5A /* CloudTreePortPresentation.swift */, + FB12D123CA2A96F1DDBA3405 /* CloudTuiManualMirrorSession+PendingReplay.swift */, 8AB2CCE4C103CF610EDCF765 /* CloudTreeCreateAction.swift */, 97320634F8C38643D48FBA01 /* CloudTreeCreateActionBuilder.swift */, DBF12EF9962B6C396CFCFC9D /* CloudTreeDevicesSection+Controls.swift */, @@ -12709,6 +12731,8 @@ 9726312BCE6BBC2C13D0B289 /* CMUXOpenCommandDiffViewerRound1Tests.swift */, 09DC7DE176A93C580A263ADD /* PaneDropTargetIdentityTests.swift */, 08202CA6A24B08873B5CC6FD /* GhosttyDialogThemeTests.swift */, + 635AF226CFD6879AC9ACFD09 /* CloudDisplayMembershipProjectionTests.swift */, + FDD2286462BDC54589245BFF /* CloudManualMirrorStartupRenderingTests.swift */, 368256F9809FB229ABB03830 /* CloudTreeCategoryCreateActionTests.swift */, 4AD84F07ADB08D5E8EBAA10D /* DeviceDiscoverabilityGatingTests.swift */, 34A1987FFA112D2457B20A9C /* LocalTerminalSizingHostLifecycleTests.swift */, @@ -13884,6 +13908,7 @@ 11289AB9227D6786E6E48D33 /* CloudCreateTeamSheet.swift in Sources */, A43E15A5F6F23102AC639AFD /* CloudCreateTeamSheetPresenter.swift in Sources */, 32E508E45F3E7B0832FB99EA /* CloudDiagnosticsView.swift in Sources */, + A2482459B25F92BBE1020EEB /* CloudDisplayMembershipSyncing.swift in Sources */, 5EDE6BFD28BE4BD58EF84889 /* CloudFeatureAvailabilityObserver.swift in Sources */, CFSF00000000000000000001 /* CloudFileExplorerCommandRunning.swift in Sources */, F434C13ADFB45B7DD70B2191 /* CloudFileExplorerSearchQueue.swift in Sources */, @@ -13911,6 +13936,7 @@ 7C98953CC5230654F2A4D628 /* CloudOperationDetailsView.swift in Sources */, A12480000000000000000005 /* CloudPaneCreationFailureStore.swift in Sources */, A12480000000000000000003 /* CloudPaneCreationFailureView.swift in Sources */, + 3D0803F8A42471FC25115802 /* CloudPlacementCoordinator+CloudDisplayMembership.swift in Sources */, 118E574AA58D92C0A3FB893E /* CloudPlacementCoordinator.swift in Sources */, 462AE599482B727CBF10308F /* CloudPortAccessModel.swift in Sources */, 1EA5411EB2583A92811329BD /* CloudPortAccessStore.swift in Sources */, @@ -14009,6 +14035,7 @@ A12357000000000000000002 /* CloudTreeTerminalNavigationCoordinator.swift in Sources */, 12D5531F8CC84B0197E44AD6 /* CloudTreeTerminalRow+Directory.swift in Sources */, 124760000000000000000006 /* CloudTuiManualMirrorSession+Capabilities.swift in Sources */, + 79F5ACE2406FEBF9C900F333 /* CloudTuiManualMirrorSession+PendingReplay.swift in Sources */, F181020CA0F7042CBC03B870 /* CloudTuiManualMirrorSession+ReplayFidelity.swift in Sources */, A1329C030000000000000101 /* CloudTuiManualMirrorSession+SharedSizing.swift in Sources */, C11322F10000000000000001 /* CloudTuiManualMirrorSession.swift in Sources */, @@ -14130,6 +14157,7 @@ 81A1621623F83B61D4C5FFA9 /* CmuxTuiSurfaceProvider+AttachmentRecovery.swift in Sources */, 5633045E23E44075B22C08CA /* CmuxTuiSurfaceProvider+CloseTerminal.swift in Sources */, C10DC1000000000000000015 /* CmuxTuiSurfaceProvider+CloudDiagnosticFailure.swift in Sources */, + C462743B6AB963C09DE86933 /* CmuxTuiSurfaceProvider+CloudDisplayMembership.swift in Sources */, FB8AB3FDE68E4ABC9C72E6CB /* CmuxTuiSurfaceProvider+Displays.swift in Sources */, AA92C99C529DB9A24064AF48 /* CmuxTuiSurfaceProvider+Environment.swift in Sources */, A12093000000000000000002 /* CmuxTuiSurfaceProvider+Error.swift in Sources */, @@ -15321,6 +15349,8 @@ CFSF00000000000000000013 /* SurfaceCatalog+ChangeNotifications.swift in Sources */, F11347000000000000000007 /* SurfaceCatalog+CloudBinding.swift in Sources */, 78F8D449BA0D4860AE1162C7 /* SurfaceCatalog+CloudDirectoryMetadata.swift in Sources */, + 11B3CE31BC9CCFE671CFC273 /* SurfaceCatalog+CloudDisplayMembership.swift in Sources */, + 968E42E7CCB67098FF50BB84 /* SurfaceCatalog+CloudDisplayProjection.swift in Sources */, F11347000000000000000001 /* SurfaceCatalog+CloudPorts.swift in Sources */, E71A3B8404534D5699AA2E9B /* SurfaceCatalog+CloudRenameSubmission.swift in Sources */, D4E2A030E899483F96AF0C9E /* SurfaceCatalog+CloudTerminalNavigation.swift in Sources */, @@ -16587,6 +16617,7 @@ 5910F6B0C9704B8DB8607318 /* CloudDirectoryLifecycleTests.swift in Sources */, F3FAB983EE90414CAA90F970 /* CloudDirectoryTestFixture.swift in Sources */, 33E9D640ED2646A38673776D /* CloudDisplayCatalogTests.swift in Sources */, + 27BBEA55BF5579F93E5C55C3 /* CloudDisplayMembershipProjectionTests.swift in Sources */, C10D0A200000000000000001 /* CloudDomainsCLIIntegrationTests.swift in Sources */, 71FE9F238B1717D43FC01E63 /* CloudEqualCursorConflictRecoveryTests.swift in Sources */, C3CD2FF9F6F1489C8EDD0C7B /* CloudFeatureFlagTests.swift in Sources */, @@ -16613,6 +16644,7 @@ FA0000000000000000000001 /* CloudManualInputOptionBackspaceTests.swift in Sources */, C12376010000000000000001 /* CloudManualMirrorPresentationTests.swift in Sources */, 95E93CB008517B3E2ABDC326 /* CloudManualMirrorSocketFixture.swift in Sources */, + 865554BEE843813A69525FC0 /* CloudManualMirrorStartupRenderingTests.swift in Sources */, C11322A10000000000000001 /* CloudManualMirrorTransportTests.swift in Sources */, F284C2BA21F3AB687A94D749 /* CloudMenuContentTests.swift in Sources */, AF4D952CE6254D3D8D886EBC /* CloudNameAuthorityFixture.swift in Sources */, diff --git a/cmuxTests/CloudDesktopOpenTestProvider.swift b/cmuxTests/CloudDesktopOpenTestProvider.swift index 28913b383e45..02acbdc9acc7 100644 --- a/cmuxTests/CloudDesktopOpenTestProvider.swift +++ b/cmuxTests/CloudDesktopOpenTestProvider.swift @@ -37,8 +37,13 @@ final class CloudDesktopOpenTestProvider: SurfaceProvider { destinations.append(destination) try await beforeMaterialization?() let pane = try SurfacePaneFactory.makeBrowserPane(url: nil, at: destination, focus: focus) + // A Cloud display membership is a frontend workspace view, not a + // daemon tab. Mirror the real provider's materialization contract so + // the projection reconciler does not treat this view as an untracked + // remote placement and recreate it on every refresh. + let remoteTabID = remoteView?.isCloudDisplayMembershipView == true ? nil : remoteView?.tabID return SurfaceProjection(resource: resource.id, workspaceID: pane.workspaceID, panelID: pane.panelID, - remoteWorkspaceID: remoteView?.workspace.id, remoteTabID: remoteView?.tabID) + remoteWorkspaceID: remoteView?.workspace.id, remoteTabID: remoteTabID) } func createTerminal(command: [String]?, cwd: String?, name: String?, diff --git a/cmuxTests/CloudDisplayMembershipProjectionTests.swift b/cmuxTests/CloudDisplayMembershipProjectionTests.swift new file mode 100644 index 000000000000..9e1a661f64d5 --- /dev/null +++ b/cmuxTests/CloudDisplayMembershipProjectionTests.swift @@ -0,0 +1,192 @@ +import CmuxCloud +import CmuxSurfaceCatalogModel +import Foundation +import Testing +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite("Cloud display membership projection") +struct CloudDisplayMembershipProjectionTests { + private let machine = SurfaceMachineID.cloud("display-membership-vm") + private let workspaceID = "ws_cloud" + private let displayID = "display:1" + + private func document( + revision: Int, + memberships: [[String: Any]] = [], + display: String = "display:1", + generation: String = "membership", + frontendID: String = CloudVMDisplayMembership.projectionFrontendID, + projectionGeneration: String = CloudVMDisplayMembership.projectionGeneration, + windowID: String? = nil, + sessionID: String? = nil + ) -> [String: Any] { + var snapshot: [String: Any] = [ + "cursor": ["generation": generation, "revision": String(revision)], + "workspaces": [["id": workspaceID, "name": "Cloud", "index": 0, "focused": true]], + "screens": [["id": "screen_cloud", "workspace_id": workspaceID]], + "panes": [["id": "pane_cloud", "screen_id": "screen_cloud"]], + "tabs": [["id": "tab_terminal", "pane_id": "pane_cloud", "index": 0, + "focused": true, "content_kind": "terminal", "content_id": "term_cloud"]], + "terminals": [["id": "term_cloud", "tab_id": "tab_terminal", "title": "terminal", "lifecycle": "running"]], + "browsers": [], + "agents": [], + "frontend_projections": [[ + "id": "projection_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "session_id": "session_cloud", + "frontend_id": frontendID, + "window_id": windowID ?? CloudVMDisplayMembership.projectionWindowID(machine: machine, workspaceID: workspaceID), + "generation": projectionGeneration, + "projection_revision": "\(revision)", + "projection": [ + "schema": "cmux.cloud.workspace-displays.v1", + "machine_id": machine.rawValue, + "workspace_id": workspaceID, + "memberships": memberships, + ], + ]], + "display_hint": display, + ] + if let sessionID { + snapshot["session"] = ["id": sessionID] + } + return snapshot + } + + private func state( + revision: Int = 1, + memberships: [[String: Any]] = [["display_id": "display:1", "client_id": "mac-a", "view_id": "panel-a"]], + generation: String = "membership" + ) throws -> CloudVMState { + try #require(CmuxTuiSnapshotParser.state( + fromSnapshot: document(revision: revision, memberships: memberships, generation: generation), + machine: machine + )) + } + + private func info(_ state: CloudVMState) -> SurfaceMachineInfo { + SurfaceMachineInfo( + id: machine, name: "Display VM", status: "running", image: nil, hasDesktop: true, + memoryMb: nil, diskMb: nil, linkState: .connected, linkError: nil, + cpuPercent: nil, memoryUsedMb: nil, diskUsedMb: nil, + remoteWorkspaces: state.workspaces.map { + SurfaceRemoteWorkspace(id: $0.id, name: $0.name, index: $0.index, focused: $0.focused) + } + ) + } + + private func resources(_ state: CloudVMState) -> [SurfaceResource] { + [CmuxTuiSnapshotParser.display(machine: machine), + CmuxTuiSnapshotParser.resources(from: state).first { $0.kind == .terminal }].compactMap { $0 } + } + + @Test("A frontend projection gives every client the same workspace display row") + func sameAcceptedSnapshotProjectsOnTwoClients() throws { + let state = try state() + let resources = resources(state) + let first = SurfaceCatalog() + let second = SurfaceCatalog() + first.replaceCloudState(state, resources: resources, info: info(state)) + second.replaceCloudState(state, resources: resources, info: info(state)) + for catalog in [first, second] { + let tree = CloudTreeNodeBuilder.flattened(CloudTreeNodeBuilder.nodes( + machines: [MachineSnapshot(id: machine.rawValue, provider: "test", image: "test", isDesktop: true, activity: .ready, createdAt: nil, label: "Display VM")], + snapshot: catalog.snapshot, localWorkspaces: [], includeLocalMachine: false + )) + let workspace = try #require(tree.first { $0.id == CloudTreeNodeBuilder.nodeID(workspace: workspaceID, machine: machine) }) + #expect(workspace.children.contains { node in + if case .display(let resource, _, _) = node.kind { return resource.id.key == displayID } + return false + }) + let pool = try #require(tree.first { $0.id == CloudTreeNodeBuilder.nodeID(displaysPool: machine) }) + #expect(pool.children.count == 1) + } + } + + @Test("Workspace open retains the exact synthetic display membership identity") + func workspaceGroupResolvesMembershipView() throws { + let accepted = try state() + let catalog = SurfaceCatalog() + catalog.replaceCloudState(accepted, resources: resources(accepted), info: info(accepted)) + let group = try catalog.remoteWorkspaceGroup(machine: machine, workspaceID: workspaceID) + let display = try #require(group.placements.first { $0.resource.kind == .display }) + #expect(display.cloudDisplayMembershipViewID == "panel-a") + let view = try #require(try catalog.remoteView(for: display, fallbackWorkspaceID: workspaceID)) + #expect(view.isCloudDisplayMembershipView) + #expect(view.cloudDisplayMembershipViewID == "panel-a") + } + + @Test("Installing a newer snapshot replaces membership rows without touching the display pool") + func catalogSnapshotRefreshReconcilesMembership() throws { + let initial = try state() + let catalog = SurfaceCatalog() + catalog.replaceCloudState(initial, resources: resources(initial), info: info(initial)) + #expect(catalog.snapshot.cloudDisplayMemberships.count == 1) + let next = try state(revision: 2, memberships: []) + catalog.replaceCloudState(next, resources: resources(next), info: info(next)) + #expect(catalog.snapshot.cloudDisplayMemberships.isEmpty) + #expect(catalog.snapshot.resources(on: machine).filter { $0.kind == .display }.count == 1) + #expect(catalog.snapshot.cloudWorkspaceResources(on: machine).filter { $0.id.key == displayID }.count == 1) + } + + @Test("Foreign and unknown display provenance stays out of workspace membership") + func ownershipIsCheckedAtProjectionBoundary() throws { + let state = try state(memberships: [ + ["display_id": "display:1", "client_id": "mac-a", "view_id": "panel-a"], + ["display_id": "display:99", "client_id": "foreign-vm", "view_id": "panel-b"], + ]) + let snapshot = SurfaceCatalogSnapshot( + machines: [info(state)], resources: resources(state), projections: [], + cloudDisplayMemberships: state.displayMemberships + ) + let workspaceResources = snapshot.cloudWorkspaceResources(on: machine) + #expect(workspaceResources.filter { $0.id.key == displayID }.count == 2) + #expect(workspaceResources.last?.remoteViews?.first?.isCloudDisplayMembershipView == true) + #expect(!workspaceResources.contains { $0.id.key == "display:99" }) + } + + @Test("Frontend provenance fences reject rows from another client implementation") + func rejectsForeignProjectionProvenance() throws { + for mutation in [ + ("frontend", document(revision: 1, memberships: [["display_id": displayID, "client_id": "mac-a", "view_id": "panel-a"]], frontendID: "other-frontend")), + ("generation", document(revision: 1, memberships: [["display_id": displayID, "client_id": "mac-a", "view_id": "panel-a"]], projectionGeneration: "old-generation")), + ("window", document(revision: 1, memberships: [["display_id": displayID, "client_id": "mac-a", "view_id": "panel-a"]], windowID: "cloud-workspace:other-vm:ws_cloud")), + ("session", document(revision: 1, memberships: [["display_id": displayID, "client_id": "mac-a", "view_id": "panel-a"]], sessionID: "other-session")), + ] { + let parsed = try #require(CmuxTuiSnapshotParser.state(fromSnapshot: mutation.1, machine: machine)) + #expect(parsed.displayMemberships.isEmpty, "\(mutation.0) provenance must not enter the accepted state") + } + } + + @Test("Revision updates replace display membership and stale deltas cannot win") + func revisionOrderingPreservesAcceptedProjection() throws { + let initial = try state() + let nextDocument = document(revision: 2, memberships: [], generation: "membership") + let next = try #require(CmuxTuiSnapshotParser.state(fromSnapshot: nextDocument, machine: machine)) + #expect(next.displayMemberships.isEmpty) + #expect(CloudVMStateSyncDecision.forDelta( + generation: "membership", previousRevision: 1, revision: 2, current: initial.cursor + ) == .installSnapshot) + #expect(CloudVMStateSyncDecision.forDelta( + generation: "membership", previousRevision: 0, revision: 1, current: next.cursor + ) == .ignoreStale) + #expect(CloudVMStateSyncDecision.forSnapshot(incoming: initial.cursor, current: next.cursor) == .ignoreStale) + } + + @Test("Reconnect keeps the durable membership while client view tokens change") + func reconnectRetainsWorkspacePlacement() throws { + let first = try state(memberships: [["display_id": displayID, "client_id": "mac-a", "view_id": "panel-a"]]) + let reconnected = try state( + revision: 1, + memberships: [["display_id": displayID, "client_id": "mac-b", "view_id": "panel-b"]], + generation: "reconnected" + ) + #expect(first.displayMemberships.first?.displayID == reconnected.displayMemberships.first?.displayID) + #expect(first.displayMemberships.first?.workspaceID == reconnected.displayMemberships.first?.workspaceID) + #expect(first.displayMemberships.first?.viewID != reconnected.displayMemberships.first?.viewID) + } +} diff --git a/cmuxTests/CloudManualMirrorStartupRenderingTests.swift b/cmuxTests/CloudManualMirrorStartupRenderingTests.swift new file mode 100644 index 000000000000..f7b012884ac2 --- /dev/null +++ b/cmuxTests/CloudManualMirrorStartupRenderingTests.swift @@ -0,0 +1,27 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Cloud attach replay must survive the short interval before a native pane +/// binds its ``TerminalSurface``. The prompt intentionally has no newline, the +/// same shape as the partial-line report in #3079. +@Suite("Cloud manual mirror startup rendering") +struct CloudManualMirrorStartupRenderingTests { + @Test @MainActor + func partialPromptReceivedBeforeSurfaceBindingIsRendered() async throws { + let fixture = try CloudRestoreReplayFixture(bindSurface: false) + defer { fixture.close() } + + try await fixture.attachBeforeSurfaceBinding(replay: Data("prompt$ ".utf8)) + try await fixture.deliver( + Data("ready".utf8), event: "output", marker: "ready", waitForSurface: false + ) + fixture.bindSurface() + try await fixture.waitForText("prompt$ ready") + } +} diff --git a/cmuxTests/CloudRestoreReplayFixture.swift b/cmuxTests/CloudRestoreReplayFixture.swift index b9aff59408f7..2d2a9deea38d 100644 --- a/cmuxTests/CloudRestoreReplayFixture.swift +++ b/cmuxTests/CloudRestoreReplayFixture.swift @@ -19,7 +19,7 @@ final class CloudRestoreReplayFixture { let socket: CloudManualMirrorSocketFixture private let session: CloudTuiManualMirrorSession - init(initiallyClaimsGeometry: Bool = true) throws { + init(initiallyClaimsGeometry: Bool = true, bindSurface: Bool = true) throws { _ = NSApplication.shared socket = try CloudManualMirrorSocketFixture() session = CloudTuiManualMirrorSession( @@ -44,7 +44,7 @@ final class CloudRestoreReplayFixture { content.layoutSubtreeIfNeeded() hosted.setVisibleInUI(false) hosted.setActive(false) - session.bind(surface: surface) + if bindSurface { session.bind(surface: surface) } } func setGrid(columns: Int, rows: Int) async throws { @@ -70,6 +70,20 @@ final class CloudRestoreReplayFixture { try await waitUntil { self.session.phase == .attached } } + func attachBeforeSurfaceBinding(replay: Data, columns: Int = 80, rows: Int = 24) async throws { + session.reconnect(socketPath: socket.socketPath) + let attach = try await answerHandshake() + socket.send(["id": attach.id, "ok": true, "data": [:]]) + try await deliver(replay, event: "vt-state", marker: "prompt", columns: columns, rows: rows, waitForSurface: false) + try await waitUntil { self.session.lastRemoteGrid != nil } + } + + func bindSurface() { session.bind(surface: surface) } + + func waitForText(_ text: String) async throws { + try await waitUntil { self.surface.readText(region: .screen)?.contains(text) == true } + } + /// Answers identify and client registration on the session's newest /// connection and returns its unanswered attach-surface request. func answerHandshake() async throws -> CloudManualMirrorFixtureCommand { @@ -122,7 +136,7 @@ final class CloudRestoreReplayFixture { func deliver( _ bytes: Data, event: String, marker: String, colors: [String: Any]? = nil, - columns: Int = 80, rows: Int = 24, pending: Data? = nil + columns: Int = 80, rows: Int = 24, waitForSurface: Bool = true, pending: Data? = nil ) async throws { var payload: [String: Any] = [ "event": event, "surface": 17, "cols": columns, "rows": rows, @@ -131,7 +145,7 @@ final class CloudRestoreReplayFixture { if let colors { payload["colors"] = colors } if let pending { payload["pending"] = pending.base64EncodedString() } socket.send(payload) - try await waitUntil { self.surface.readText(region: .screen)?.contains(marker) == true } + if waitForSurface { try await waitUntil { self.surface.readText(region: .screen)?.contains(marker) == true } } } func close() {