From e0d9683acf4e956fa6b7f3953d7786a828f4a5b3 Mon Sep 17 00:00:00 2001 From: DorsaRoh Date: Mon, 29 Jun 2026 22:07:29 -0700 Subject: [PATCH 1/5] m --- CLI/cmux.swift | 77 ++ .../Shared/CmuxCollaboration/Package.swift | 36 + .../CmuxCollaboration/CharacterID.swift | 25 + .../CollaborationConnectionState.swift | 15 + .../CollaborationDocumentSnapshot.swift | 22 + .../CollaborationEvent.swift | 15 + .../CollaborationFileStoring.swift | 20 + .../CollaborationInvite.swift | 22 + .../CollaborationPeerIdentity.swift | 57 ++ .../CollaborationRelayFrame.swift | 15 + .../CollaborationSession.swift | 238 +++++ .../CollaborationSessionError.swift | 7 + .../CollaborationTextDocument.swift | 143 +++ .../CmuxCollaboration/DiskReconciler.swift | 59 ++ .../DiskReconciliationResult.swift | 9 + .../LocalCollaborationFileStore.swift | 19 + .../OpenCollaborationDocument.swift | 18 + .../CmuxCollaboration/PresenceState.swift | 38 + .../SharedFileDescriptor.swift | 29 + .../CmuxCollaboration/TextElement.swift | 9 + .../Sources/CmuxCollaboration/TextHash.swift | 16 + .../CmuxCollaboration/TextOperation.swift | 9 + .../CollaborationPeerIdentityTests.swift | 41 + .../CollaborationRelayStateTests.swift | 35 + .../CollaborationSessionDiskTests.swift | 55 ++ .../CollaborationTextDocumentTests.swift | 52 + Resources/Localizable.xcstrings | 340 +++++++ Sources/CollaborationRuntime.swift | 919 ++++++++++++++++++ Sources/Panels/FilePreviewPanel.swift | 21 + Sources/Panels/FilePreviewTextEditor.swift | 13 +- Sources/Panels/MarkdownPanel.swift | 21 + Sources/Panels/MarkdownPanelView.swift | 2 + Sources/TerminalController.swift | 72 ++ cmux.xcodeproj/project.pbxproj | 20 + cmux.xcworkspace/contents.xcworkspacedata | 3 + docs/multiplayer-phase1-design.md | 205 ++++ .../miniflare-CacheObject/metadata.sqlite | Bin 0 -> 4096 bytes .../miniflare-CacheObject/metadata.sqlite-shm | Bin 0 -> 32768 bytes .../miniflare-CacheObject/metadata.sqlite-wal | Bin 0 -> 8272 bytes ...e7688fe85c50c637943aedc0fce1e635763.sqlite | Bin 0 -> 16384 bytes ...cc7ab46b4432a117d2a30026628566ef84b.sqlite | Bin 0 -> 8192 bytes ...dbc40983daecb04523b77aa8be3b26ce5dc.sqlite | Bin 0 -> 8192 bytes ...6c58467086c2fab35fbb14f2fa6d2fc61bd.sqlite | Bin 0 -> 8192 bytes ...cb7ca0be2bd005c8c2e7dec9244caa883d8.sqlite | Bin 0 -> 8192 bytes ...1ba5ba0f3012e307b9758f712787e9f860b.sqlite | Bin 0 -> 8192 bytes ...625c2d5563786ecc264864a6ddff28587ea.sqlite | Bin 0 -> 4096 bytes ...2d5563786ecc264864a6ddff28587ea.sqlite-shm | Bin 0 -> 32768 bytes ...2d5563786ecc264864a6ddff28587ea.sqlite-wal | Bin 0 -> 41232 bytes ...9bd0798dc17e527f5848fdce647d9d060a5.sqlite | Bin 0 -> 8192 bytes ...d410fe62b08dfa812034a686760ba44ace3.sqlite | Bin 0 -> 8192 bytes .../metadata.sqlite | Bin 0 -> 4096 bytes .../metadata.sqlite-shm | Bin 0 -> 32768 bytes .../metadata.sqlite-wal | Bin 0 -> 57712 bytes workers/collaboration/README.md | 74 ++ workers/collaboration/bun.lock | 223 +++++ workers/collaboration/package.json | 19 + workers/collaboration/src/handler.ts | 38 + workers/collaboration/src/index.ts | 14 + workers/collaboration/src/protocol.ts | 64 ++ workers/collaboration/src/session-metadata.ts | 34 + workers/collaboration/src/session-state.ts | 83 ++ workers/collaboration/src/session.ts | 73 ++ workers/collaboration/test/index.test.ts | 68 ++ workers/collaboration/test/protocol.test.ts | 25 + .../test/session-metadata.test.ts | 34 + .../collaboration/test/session-state.test.ts | 107 ++ workers/collaboration/tsconfig.json | 12 + workers/collaboration/tsconfig.test.json | 7 + workers/collaboration/wrangler.toml | 11 + 69 files changed, 3582 insertions(+), 1 deletion(-) create mode 100644 Packages/Shared/CmuxCollaboration/Package.swift create mode 100644 Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CharacterID.swift create mode 100644 Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationConnectionState.swift create mode 100644 Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationDocumentSnapshot.swift create mode 100644 Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationEvent.swift create mode 100644 Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationFileStoring.swift create mode 100644 Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationInvite.swift create mode 100644 Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationPeerIdentity.swift create mode 100644 Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationRelayFrame.swift create mode 100644 Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationSession.swift create mode 100644 Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationSessionError.swift create mode 100644 Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationTextDocument.swift create mode 100644 Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/DiskReconciler.swift create mode 100644 Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/DiskReconciliationResult.swift create mode 100644 Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/LocalCollaborationFileStore.swift create mode 100644 Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/OpenCollaborationDocument.swift create mode 100644 Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/PresenceState.swift create mode 100644 Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/SharedFileDescriptor.swift create mode 100644 Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/TextElement.swift create mode 100644 Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/TextHash.swift create mode 100644 Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/TextOperation.swift create mode 100644 Packages/Shared/CmuxCollaboration/Tests/CmuxCollaborationTests/CollaborationPeerIdentityTests.swift create mode 100644 Packages/Shared/CmuxCollaboration/Tests/CmuxCollaborationTests/CollaborationRelayStateTests.swift create mode 100644 Packages/Shared/CmuxCollaboration/Tests/CmuxCollaborationTests/CollaborationSessionDiskTests.swift create mode 100644 Packages/Shared/CmuxCollaboration/Tests/CmuxCollaborationTests/CollaborationTextDocumentTests.swift create mode 100644 Sources/CollaborationRuntime.swift create mode 100644 docs/multiplayer-phase1-design.md create mode 100644 workers/collaboration/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite create mode 100644 workers/collaboration/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm create mode 100644 workers/collaboration/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/03a17dc68f0f82924c88fb48b167de7688fe85c50c637943aedc0fce1e635763.sqlite create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/14a9ad456cd030aaa651a2dafa385cc7ab46b4432a117d2a30026628566ef84b.sqlite create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/4a9cc69e531b0224ce740caab4560dbc40983daecb04523b77aa8be3b26ce5dc.sqlite create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/8228cb14196d0a45f0d8293c7e8326c58467086c2fab35fbb14f2fa6d2fc61bd.sqlite create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/8965da945b0071453b7afa1ca8e96cb7ca0be2bd005c8c2e7dec9244caa883d8.sqlite create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/99b843e894d821ea0d842307990cf1ba5ba0f3012e307b9758f712787e9f860b.sqlite create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/a9e8393d05838637ddaf02a111008625c2d5563786ecc264864a6ddff28587ea.sqlite create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/a9e8393d05838637ddaf02a111008625c2d5563786ecc264864a6ddff28587ea.sqlite-shm create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/a9e8393d05838637ddaf02a111008625c2d5563786ecc264864a6ddff28587ea.sqlite-wal create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/c063813275fd1c69ce347418adea09bd0798dc17e527f5848fdce647d9d060a5.sqlite create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/ffc0d014dfa96fc8b6e230d9a7676d410fe62b08dfa812034a686760ba44ace3.sqlite create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/metadata.sqlite create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/metadata.sqlite-shm create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/metadata.sqlite-wal create mode 100644 workers/collaboration/README.md create mode 100644 workers/collaboration/bun.lock create mode 100644 workers/collaboration/package.json create mode 100644 workers/collaboration/src/handler.ts create mode 100644 workers/collaboration/src/index.ts create mode 100644 workers/collaboration/src/protocol.ts create mode 100644 workers/collaboration/src/session-metadata.ts create mode 100644 workers/collaboration/src/session-state.ts create mode 100644 workers/collaboration/src/session.ts create mode 100644 workers/collaboration/test/index.test.ts create mode 100644 workers/collaboration/test/protocol.test.ts create mode 100644 workers/collaboration/test/session-metadata.test.ts create mode 100644 workers/collaboration/test/session-state.test.ts create mode 100644 workers/collaboration/tsconfig.json create mode 100644 workers/collaboration/tsconfig.test.json create mode 100644 workers/collaboration/wrangler.toml diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 17b748d7efaa..e20ebb3a54d8 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -3522,6 +3522,83 @@ struct CMUXCLI { let response = try client.sendV2(method: "system.capabilities") print(jsonString(formatIDs(response, mode: idFormat))) + case "collaboration", "collab", "multiplayer": + let sub = commandArgs.first?.lowercased() ?? "status" + let rest = Array(commandArgs.dropFirst()) + switch sub { + case "status": + let response = try client.sendV2(method: "collaboration.status") + if jsonOutput { + print(jsonString(response)) + break + } + print("Collaboration: \((response["status"] as? String) ?? "unknown")") + if let code = response["session_code"] as? String { + print(" session_code: \(code)") + } + if let token = response["invite_token"] as? String { + print(" invite_token: \(token)") + } + print(" relay_url: \((response["relay_url"] as? String) ?? "?")") + print(" shared_files: \((response["shared_documents"] as? Int) ?? 0)") + + case "create", "new": + let (relayURL, remaining) = parseOption(rest, name: "--relay-url") + if let extra = remaining.first { + throw CLIError(message: "cmux collaboration create: unexpected argument '\(extra)'") + } + var params: [String: Any] = [:] + if let relayURL { params["relay_url"] = relayURL } + let response = try client.sendV2( + method: "collaboration.session.create", + params: params, + responseTimeout: 35 + ) + if jsonOutput { + print(jsonString(response)) + break + } + print("Created collaboration session.") + print(" relay_url: \((response["relay_url"] as? String) ?? "?")") + print(" session_code: \((response["session_code"] as? String) ?? "?")") + print(" invite_token: \((response["invite_token"] as? String) ?? "?")") + + case "join": + let (relayURL, afterRelay) = parseOption(rest, name: "--relay-url") + let (sessionCode, afterCode) = parseOption(afterRelay, name: "--session-code") + let (inviteToken, remaining) = parseOption(afterCode, name: "--invite-token") + if let extra = remaining.first { + throw CLIError(message: "cmux collaboration join: unexpected argument '\(extra)'") + } + guard let sessionCode, let inviteToken else { + throw CLIError(message: "Usage: cmux collaboration join --session-code --invite-token [--relay-url ]") + } + var params: [String: Any] = [ + "session_code": sessionCode, + "invite_token": inviteToken, + ] + if let relayURL { params["relay_url"] = relayURL } + let response = try client.sendV2(method: "collaboration.session.join", params: params) + if jsonOutput { + print(jsonString(response)) + break + } + print("Joined collaboration session.") + print(" relay_url: \((response["relay_url"] as? String) ?? "?")") + print(" session_code: \((response["session_code"] as? String) ?? "?")") + + case "leave": + let response = try client.sendV2(method: "collaboration.session.leave") + if jsonOutput { + print(jsonString(response)) + break + } + print("Left collaboration session.") + + default: + throw CLIError(message: "Usage: cmux collaboration ") + } + case "agent-hibernation": try runAgentHibernation(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput) diff --git a/Packages/Shared/CmuxCollaboration/Package.swift b/Packages/Shared/CmuxCollaboration/Package.swift new file mode 100644 index 000000000000..d76db9aabaa7 --- /dev/null +++ b/Packages/Shared/CmuxCollaboration/Package.swift @@ -0,0 +1,36 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "CmuxCollaboration", + platforms: [ + .iOS(.v18), + .macOS(.v14), + ], + products: [ + .library( + name: "CmuxCollaboration", + targets: ["CmuxCollaboration"] + ), + ], + targets: [ + .target( + name: "CmuxCollaboration", + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + .testTarget( + name: "CmuxCollaborationTests", + dependencies: ["CmuxCollaboration"], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + ] +) diff --git a/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CharacterID.swift b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CharacterID.swift new file mode 100644 index 000000000000..356410e464d3 --- /dev/null +++ b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CharacterID.swift @@ -0,0 +1,25 @@ +import Foundation + +/// A stable identifier for one CRDT text element. +public struct CharacterID: Codable, Comparable, Hashable, Sendable { + /// The peer that created the element. + public let peerID: String + /// The peer-local monotonically increasing counter. + public let counter: Int + + /// Creates a character identifier. + /// - Parameters: + /// - peerID: The peer that created the element. + /// - counter: The peer-local monotonically increasing counter. + public init(peerID: String, counter: Int) { + self.peerID = peerID + self.counter = counter + } + + public static func < (lhs: CharacterID, rhs: CharacterID) -> Bool { + if lhs.counter != rhs.counter { + return lhs.counter < rhs.counter + } + return lhs.peerID < rhs.peerID + } +} diff --git a/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationConnectionState.swift b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationConnectionState.swift new file mode 100644 index 000000000000..06dda1117812 --- /dev/null +++ b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationConnectionState.swift @@ -0,0 +1,15 @@ +import Foundation + +/// High-level relay connection state. +public enum CollaborationConnectionState: Equatable, Sendable { + /// The session has not connected to a relay. + case idle + /// The client is connected to the relay. + case connected + /// The relay is unavailable at session start. + case relayUnavailable + /// The relay disconnected after a session had started. + case disconnected + /// The client reconnected and is resynchronizing documents. + case resynchronizing +} diff --git a/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationDocumentSnapshot.swift b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationDocumentSnapshot.swift new file mode 100644 index 000000000000..a4e143fd0888 --- /dev/null +++ b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationDocumentSnapshot.swift @@ -0,0 +1,22 @@ +import Foundation + +/// A visible snapshot of a collaboration document. +public struct CollaborationDocumentSnapshot: Codable, Equatable, Sendable { + /// The stable document identifier. + public let documentID: String + /// The current CRDT-resolved text. + public let text: String + /// A deterministic hash of the current text. + public let textHash: String + + /// Creates a document snapshot. + /// - Parameters: + /// - documentID: The stable document identifier. + /// - text: The current CRDT-resolved text. + /// - textHash: A deterministic hash of the current text. + public init(documentID: String, text: String, textHash: String) { + self.documentID = documentID + self.text = text + self.textHash = textHash + } +} diff --git a/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationEvent.swift b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationEvent.swift new file mode 100644 index 000000000000..a3822f61cfe7 --- /dev/null +++ b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationEvent.swift @@ -0,0 +1,15 @@ +import Foundation + +/// Events emitted by a collaboration session. +public enum CollaborationEvent: Equatable, Sendable { + /// A local or remote document changed. + case documentChanged(CollaborationDocumentSnapshot) + /// A peer's ephemeral presence changed. + case presenceChanged(PresenceState) + /// A peer left or timed out. + case presenceCleared(peerID: String) + /// The relay connection state changed. + case connectionChanged(CollaborationConnectionState) + /// Disk reconciliation produced a result for a file. + case diskReconciled(DiskReconciliationResult) +} diff --git a/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationFileStoring.swift b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationFileStoring.swift new file mode 100644 index 000000000000..8bf4309aadcc --- /dev/null +++ b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationFileStoring.swift @@ -0,0 +1,20 @@ +public import Foundation + +/// Filesystem operations used by collaboration disk reconciliation. +public protocol CollaborationFileStoring: Sendable { + /// Reads a UTF-8 text file. + /// - Parameter url: The file URL to read. + /// - Returns: The file contents. + func readText(at url: URL) async throws -> String + + /// Writes a UTF-8 text file atomically. + /// - Parameters: + /// - text: The text to write. + /// - url: The destination file URL. + func writeText(_ text: String, to url: URL) async throws + + /// Returns whether a file exists. + /// - Parameter url: The file URL to check. + /// - Returns: Whether the file exists. + func fileExists(at url: URL) async -> Bool +} diff --git a/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationInvite.swift b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationInvite.swift new file mode 100644 index 000000000000..4e785200f3cc --- /dev/null +++ b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationInvite.swift @@ -0,0 +1,22 @@ +public import Foundation + +/// Invite details used to join a relay-backed collaboration session. +public struct CollaborationInvite: Codable, Equatable, Sendable { + /// The relay WebSocket endpoint. + public let relayURL: URL + /// The short user-shareable session code. + public let sessionCode: String + /// The bearer token associated with the session code. + public let token: String + + /// Creates collaboration invite details. + /// - Parameters: + /// - relayURL: The relay WebSocket endpoint. + /// - sessionCode: The short user-shareable session code. + /// - token: The bearer token associated with the session code. + public init(relayURL: URL, sessionCode: String, token: String) { + self.relayURL = relayURL + self.sessionCode = sessionCode + self.token = token + } +} diff --git a/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationPeerIdentity.swift b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationPeerIdentity.swift new file mode 100644 index 000000000000..66a9245a3e04 --- /dev/null +++ b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationPeerIdentity.swift @@ -0,0 +1,57 @@ +public import Foundation + +/// Relay-visible identity metadata for one running collaboration peer. +public struct CollaborationPeerIdentity: Equatable, Sendable { + /// The default color palette used to distinguish peers in collaboration UI. + public static let defaultColorPalette = ["#7A5CFF", "#0A84FF", "#34C759", "#FF9F0A", "#FF375F"] + + /// A relay-unique peer identifier for this app process. + public let peerID: String + /// The display name shown to collaborators. + public let displayName: String + /// The display color assigned to this peer. + public let color: String + + /// Creates peer identity metadata. + /// - Parameters: + /// - peerID: A relay-unique peer identifier. + /// - displayName: The display name shown to collaborators. + /// - color: The display color assigned to this peer. + public init(peerID: String, displayName: String, color: String) { + self.peerID = peerID + self.displayName = displayName + self.color = color + } + + /// Creates a fresh peer identity for a single running app process. + /// + /// Collaboration relays key active connections by peer ID, so two local app + /// windows that join the same session must not reuse a persisted bundle-wide + /// identifier. Generate this once at process startup and reuse it for that + /// process's collaboration connections. + /// - Parameters: + /// - displayName: The display name shown to collaborators. + /// - colorPalette: The palette used to derive the peer color. + /// - idProvider: Supplies the process-local peer UUID. + /// - Returns: Fresh relay identity metadata for one app process. + public static func ephemeral( + displayName: String, + colorPalette: [String] = Self.defaultColorPalette, + idProvider: @Sendable () -> UUID = { UUID() } + ) -> CollaborationPeerIdentity { + let peerID = idProvider().uuidString + let palette = colorPalette.isEmpty ? Self.defaultColorPalette : colorPalette + return CollaborationPeerIdentity( + peerID: peerID, + displayName: displayName, + color: palette[Self.colorIndex(for: peerID, count: palette.count)] + ) + } + + private static func colorIndex(for peerID: String, count: Int) -> Int { + let total = peerID.utf8.reduce(0) { partial, byte in + partial + Int(byte) + } + return total % count + } +} diff --git a/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationRelayFrame.swift b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationRelayFrame.swift new file mode 100644 index 000000000000..30060b5a8149 --- /dev/null +++ b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationRelayFrame.swift @@ -0,0 +1,15 @@ +import Foundation + +/// Client-side relay frame representation used by tests and transports. +public enum CollaborationRelayFrame: Codable, Equatable, Sendable { + /// A CRDT update for a document. + case documentUpdate(documentID: String, updateID: String, operations: [TextOperation]) + /// A full CRDT snapshot for a document. + case documentSnapshot(documentID: String, requestID: String?, operations: [TextOperation], textHash: String) + /// A request for any peer to send a full CRDT snapshot. + case documentSnapshotRequest(documentID: String, requestID: String) + /// Ephemeral peer presence. + case presence(PresenceState) + /// A peer disconnected or timed out. + case peerLeft(peerID: String) +} diff --git a/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationSession.swift b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationSession.swift new file mode 100644 index 000000000000..e82d2624cb2a --- /dev/null +++ b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationSession.swift @@ -0,0 +1,238 @@ +import Foundation + +/// UI-agnostic collaboration session state and document mutation API. +public actor CollaborationSession { + private let peerID: String + private let displayName: String + private let color: String + private let sessionID: String + private let store: any CollaborationFileStoring + private let reconciler: DiskReconciler + private let hash = TextHash() + private var documents: [String: OpenCollaborationDocument] = [:] + private var presenceByPeer: [String: PresenceState] = [:] + private var presenceSequence = 0 + private var connectionState: CollaborationConnectionState = .idle + private var continuation: AsyncStream.Continuation? + + /// Creates a collaboration session. + /// - Parameters: + /// - peerID: The local peer identifier. + /// - displayName: The local peer's display name. + /// - color: The local peer's display color. + /// - sessionID: The collaboration session identifier. + /// - store: File storage used for open/close reconciliation. + public init( + peerID: String, + displayName: String, + color: String, + sessionID: String, + store: any CollaborationFileStoring = LocalCollaborationFileStore() + ) { + self.peerID = peerID + self.displayName = displayName + self.color = color + self.sessionID = sessionID + self.store = store + self.reconciler = DiskReconciler(store: store) + } + + /// Events emitted by this session. + public var events: AsyncStream { + AsyncStream { continuation in + self.continuation = continuation + } + } + + /// Marks the relay as connected. + public func markConnected() { + setConnectionState(.connected) + } + + /// Returns the current relay connection state. + /// - Returns: The current relay connection state. + public func currentConnectionState() -> CollaborationConnectionState { + connectionState + } + + /// Marks the relay as unavailable before session start. + public func markRelayUnavailable() { + setConnectionState(.relayUnavailable) + } + + /// Marks the relay as disconnected after session start. + public func markDisconnected() { + setConnectionState(.disconnected) + for peer in presenceByPeer.keys { + continuation?.yield(.presenceCleared(peerID: peer)) + } + presenceByPeer.removeAll() + } + + /// Opens a local file as a collaboration document. + /// - Parameter file: The file to open. + /// - Returns: The initial document snapshot. + public func open(file: SharedFileDescriptor) async throws -> CollaborationDocumentSnapshot { + let text = try await store.readText(at: file.localURL) + let baselineHash = hash.hash(text) + let documentID = file.documentID(sessionID: sessionID) + let open = OpenCollaborationDocument( + file: file, + document: CollaborationTextDocument(text: text, peerID: peerID), + baselineHash: baselineHash, + lastWrittenHash: nil + ) + documents[documentID] = open + let snapshot = CollaborationDocumentSnapshot(documentID: documentID, text: text, textHash: baselineHash) + continuation?.yield(.documentChanged(snapshot)) + return snapshot + } + + /// Applies a local edit and returns the CRDT update frame to broadcast. + /// - Parameters: + /// - file: The edited file. + /// - range: The visible character range to replace. + /// - replacement: The replacement text. + /// - Returns: A relay frame containing generated CRDT operations. + public func applyLocalEdit( + file: SharedFileDescriptor, + range: Range, + replacement: String + ) async throws -> CollaborationRelayFrame { + let documentID = file.documentID(sessionID: sessionID) + guard var open = documents[documentID] else { + throw CollaborationSessionError.documentNotOpen(documentID) + } + let operations = open.document.replace(range: range, with: replacement) + documents[documentID] = open + let snapshot = CollaborationDocumentSnapshot( + documentID: documentID, + text: open.document.text, + textHash: hash.hash(open.document.text) + ) + continuation?.yield(.documentChanged(snapshot)) + return .documentUpdate( + documentID: documentID, + updateID: "\(peerID)-\(UUID().uuidString)", + operations: operations + ) + } + + /// Applies a remote relay frame. + /// - Parameter frame: The frame received from the relay. + public func applyRemoteFrame(_ frame: CollaborationRelayFrame) async throws { + switch frame { + case let .documentUpdate(documentID, _, operations): + try merge(operations: operations, documentID: documentID) + case let .documentSnapshot(documentID, _, operations, _): + try merge(operations: operations, documentID: documentID) + case .documentSnapshotRequest: + break + case let .presence(state): + let previous = presenceByPeer[state.peerID] + guard previous == nil || previous!.sequence < state.sequence else { return } + presenceByPeer[state.peerID] = state + continuation?.yield(.presenceChanged(state)) + case let .peerLeft(peerID): + presenceByPeer.removeValue(forKey: peerID) + continuation?.yield(.presenceCleared(peerID: peerID)) + } + } + + /// Exports a full-state snapshot frame for a file. + /// - Parameters: + /// - file: The file to snapshot. + /// - requestID: The snapshot request identifier, if any. + /// - Returns: A relay snapshot frame. + public func snapshotFrame( + for file: SharedFileDescriptor, + requestID: String? = nil + ) async throws -> CollaborationRelayFrame { + let documentID = file.documentID(sessionID: sessionID) + guard let open = documents[documentID] else { + throw CollaborationSessionError.documentNotOpen(documentID) + } + return .documentSnapshot( + documentID: documentID, + requestID: requestID, + operations: open.document.snapshotOperations(), + textHash: hash.hash(open.document.text) + ) + } + + /// Updates local presence and returns the frame to broadcast. + /// - Parameters: + /// - file: The active file, if any. + /// - cursor: The UTF-16 cursor offset. + /// - selection: The UTF-16 selection range, if any. + /// - Returns: A relay frame containing ephemeral presence. + public func setLocalSelection( + file: SharedFileDescriptor?, + cursor: Int, + selection: Range? + ) -> CollaborationRelayFrame { + presenceSequence += 1 + let state = PresenceState( + peerID: peerID, + displayName: displayName, + color: color, + activeFile: file?.relativePath, + cursor: cursor, + selection: selection, + sequence: presenceSequence + ) + return .presence(state) + } + + /// Closes a file and reconciles its resolved CRDT text to disk. + /// - Parameter file: The file to close. + /// - Returns: The disk reconciliation result. + public func close(file: SharedFileDescriptor) async throws -> DiskReconciliationResult { + let documentID = file.documentID(sessionID: sessionID) + guard let open = documents.removeValue(forKey: documentID) else { + throw CollaborationSessionError.documentNotOpen(documentID) + } + let result = try await reconciler.reconcile( + text: open.document.text, + fileURL: file.localURL, + baselineHash: open.baselineHash, + lastWrittenHash: open.lastWrittenHash + ) + continuation?.yield(.diskReconciled(result)) + return result + } + + /// Returns a visible snapshot for the file. + /// - Parameter file: The file to inspect. + /// - Returns: The current visible document snapshot. + public func snapshot(for file: SharedFileDescriptor) throws -> CollaborationDocumentSnapshot { + let documentID = file.documentID(sessionID: sessionID) + guard let open = documents[documentID] else { + throw CollaborationSessionError.documentNotOpen(documentID) + } + return CollaborationDocumentSnapshot( + documentID: documentID, + text: open.document.text, + textHash: hash.hash(open.document.text) + ) + } + + private func merge(operations: [TextOperation], documentID: String) throws { + guard var open = documents[documentID] else { + throw CollaborationSessionError.documentNotOpen(documentID) + } + open.document.merge(operations) + documents[documentID] = open + let snapshot = CollaborationDocumentSnapshot( + documentID: documentID, + text: open.document.text, + textHash: hash.hash(open.document.text) + ) + continuation?.yield(.documentChanged(snapshot)) + } + + private func setConnectionState(_ state: CollaborationConnectionState) { + connectionState = state + continuation?.yield(.connectionChanged(state)) + } +} diff --git a/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationSessionError.swift b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationSessionError.swift new file mode 100644 index 000000000000..8bf58d2d36b0 --- /dev/null +++ b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationSessionError.swift @@ -0,0 +1,7 @@ +import Foundation + +/// Errors thrown by collaboration session operations. +public enum CollaborationSessionError: Error, Equatable, Sendable { + /// The requested document is not open in this local session. + case documentNotOpen(String) +} diff --git a/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationTextDocument.swift b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationTextDocument.swift new file mode 100644 index 000000000000..0031e96f3872 --- /dev/null +++ b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationTextDocument.swift @@ -0,0 +1,143 @@ +import Foundation + +/// A deterministic text CRDT used by the Phase 1 collaboration harness. +/// +/// The document stores one immutable element per inserted character and marks +/// deletes with tombstones. Concurrent inserts after the same predecessor are +/// rendered by stable identifier order, so replicas converge even when +/// operations arrive out of order. +public struct CollaborationTextDocument: Codable, Equatable, Sendable { + private var peerID: String + private var nextCounter: Int + private var elements: [CharacterID: TextElement] + private var waitingByPredecessor: [CharacterID: [TextOperation]] + + /// Creates an empty text document for a peer. + /// - Parameter peerID: The local peer identifier used for generated operations. + public init(peerID: String) { + self.peerID = peerID + self.nextCounter = 0 + self.elements = [:] + self.waitingByPredecessor = [:] + } + + /// Creates a document initialized with plain text from the local peer. + /// - Parameters: + /// - text: The initial document text. + /// - peerID: The local peer identifier used for generated operations. + public init(text: String, peerID: String) { + self.init(peerID: peerID) + _ = replace(range: 0..<0, with: text) + } + + /// The current visible text after applying all known operations. + public var text: String { + visibleElements().map(\.value).joined() + } + + /// Replaces a visible UTF-8 character range with text and returns generated operations. + /// - Parameters: + /// - range: The character-offset range to replace. + /// - replacement: The replacement text. + /// - Returns: The operations that should be broadcast to other peers. + @discardableResult + public mutating func replace(range: Range, with replacement: String) -> [TextOperation] { + let visible = visibleElements() + let lowerBound = min(visible.count, max(0, range.lowerBound)) + let upperBound = min(visible.count, max(lowerBound, range.upperBound)) + let boundedRange = lowerBound.. [TextOperation] { + var operations: [TextOperation] = [] + for element in elements.values.sorted(by: { $0.id < $1.id }) { + operations.append(.insert(id: element.id, after: element.after, value: element.value)) + } + for element in elements.values.sorted(by: { $0.id < $1.id }) where element.isDeleted { + operations.append(.delete(id: element.id)) + } + return operations + } + + private mutating func apply(_ operation: TextOperation) { + switch operation { + case let .insert(id, after, value): + guard elements[id] == nil else { return } + if let after, elements[after] == nil { + waitingByPredecessor[after, default: []].append(operation) + return + } + elements[id] = TextElement(id: id, after: after, value: value, isDeleted: false) + if nextCounter <= id.counter && id.peerID == peerID { + nextCounter = id.counter + 1 + } + drainWaiting(after: id) + case let .delete(id): + guard var element = elements[id] else { + waitingByPredecessor[id, default: []].append(operation) + return + } + element.isDeleted = true + elements[id] = element + } + } + + private mutating func drainWaiting(after predecessor: CharacterID) { + guard let waiting = waitingByPredecessor.removeValue(forKey: predecessor) else { return } + for operation in waiting { + apply(operation) + } + } + + private func visibleElements() -> [TextElement] { + orderedElements().filter { !$0.isDeleted } + } + + private func orderedElements() -> [TextElement] { + let children = Dictionary(grouping: elements.values, by: \.after) + .mapValues { $0.sorted { $0.id < $1.id } } + var ordered: [TextElement] = [] + appendChildren(after: nil, children: children, into: &ordered) + return ordered + } + + private func appendChildren( + after predecessor: CharacterID?, + children: [CharacterID?: [TextElement]], + into ordered: inout [TextElement] + ) { + for child in children[predecessor] ?? [] { + ordered.append(child) + appendChildren(after: child.id, children: children, into: &ordered) + } + } +} diff --git a/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/DiskReconciler.swift b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/DiskReconciler.swift new file mode 100644 index 000000000000..dd3a52fa80da --- /dev/null +++ b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/DiskReconciler.swift @@ -0,0 +1,59 @@ +public import Foundation + +/// Reconciles a resolved collaboration document with the peer's local file. +public struct DiskReconciler: Sendable { + private let store: any CollaborationFileStoring + private let hash: TextHash + + /// Creates a disk reconciler. + /// - Parameters: + /// - store: File storage operations to use. + /// - hash: Stable text hash implementation. + init(store: any CollaborationFileStoring, hash: TextHash = TextHash()) { + self.store = store + self.hash = hash + } + + /// Writes collaboration text to disk or a conflict sibling. + /// - Parameters: + /// - text: The resolved collaboration text. + /// - fileURL: The original file URL. + /// - baselineHash: The hash recorded when collaboration opened. + /// - lastWrittenHash: The hash from the previous collaboration write. + /// - Returns: The reconciliation result. + public func reconcile( + text: String, + fileURL: URL, + baselineHash: String, + lastWrittenHash: String? + ) async throws -> DiskReconciliationResult { + let currentText = try await store.readText(at: fileURL) + let currentHash = hash.hash(currentText) + let collaborationHash = hash.hash(text) + if currentHash == baselineHash || currentHash == lastWrittenHash { + try await store.writeText(text, to: fileURL) + return .wroteOriginal(fileURL: fileURL, textHash: collaborationHash) + } + + let conflictURL = conflictURL(for: fileURL) + try await store.writeText(text, to: conflictURL) + return .wroteConflict( + originalURL: fileURL, + conflictURL: conflictURL, + originalHash: currentHash, + collaborationHash: collaborationHash + ) + } + + private func conflictURL(for fileURL: URL) -> URL { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let stamp = formatter.string(from: Date()) + .replacingOccurrences(of: ":", with: "-") + let base = fileURL.deletingPathExtension() + let ext = fileURL.pathExtension + let name = "\(base.lastPathComponent).cmux-collab-conflict-\(stamp)" + let finalName = ext.isEmpty ? name : "\(name).\(ext)" + return base.deletingLastPathComponent().appendingPathComponent(finalName) + } +} diff --git a/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/DiskReconciliationResult.swift b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/DiskReconciliationResult.swift new file mode 100644 index 000000000000..b577607f5eb0 --- /dev/null +++ b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/DiskReconciliationResult.swift @@ -0,0 +1,9 @@ +public import Foundation + +/// The outcome of writing a resolved collaboration document back to disk. +public enum DiskReconciliationResult: Equatable, Sendable { + /// The resolved CRDT text was written to the original file. + case wroteOriginal(fileURL: URL, textHash: String) + /// The original changed out-of-band, so the CRDT text was written beside it. + case wroteConflict(originalURL: URL, conflictURL: URL, originalHash: String, collaborationHash: String) +} diff --git a/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/LocalCollaborationFileStore.swift b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/LocalCollaborationFileStore.swift new file mode 100644 index 000000000000..161973ed7d0e --- /dev/null +++ b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/LocalCollaborationFileStore.swift @@ -0,0 +1,19 @@ +public import Foundation + +/// Filesystem-backed storage for collaboration documents. +public struct LocalCollaborationFileStore: CollaborationFileStoring { + /// Creates a local file store backed by `FileManager.default`. + public init() {} + + public func readText(at url: URL) async throws -> String { + try String(contentsOf: url, encoding: .utf8) + } + + public func writeText(_ text: String, to url: URL) async throws { + try text.write(to: url, atomically: true, encoding: .utf8) + } + + public func fileExists(at url: URL) async -> Bool { + FileManager.default.fileExists(atPath: url.path) + } +} diff --git a/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/OpenCollaborationDocument.swift b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/OpenCollaborationDocument.swift new file mode 100644 index 000000000000..4660f8fe9344 --- /dev/null +++ b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/OpenCollaborationDocument.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Local state for one open shared file. +struct OpenCollaborationDocument: Sendable { + let file: SharedFileDescriptor + var document: CollaborationTextDocument + let baselineHash: String + var lastWrittenHash: String? + + var snapshot: CollaborationDocumentSnapshot { + let text = document.text + return CollaborationDocumentSnapshot( + documentID: file.documentID(sessionID: ""), + text: text, + textHash: TextHash().hash(text) + ) + } +} diff --git a/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/PresenceState.swift b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/PresenceState.swift new file mode 100644 index 000000000000..6591d9dd40cc --- /dev/null +++ b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/PresenceState.swift @@ -0,0 +1,38 @@ +import Foundation + +/// Ephemeral cursor and selection state for one collaboration peer. +public struct PresenceState: Codable, Equatable, Sendable { + /// The peer that owns this presence state. + public let peerID: String + /// The peer's display name. + public let displayName: String + /// The peer's display color. + public let color: String + /// The active repository-relative file path. + public let activeFile: String? + /// The UTF-16 cursor offset used by AppKit text views. + public let cursor: Int + /// The UTF-16 selection range, if any. + public let selection: Range? + /// A peer-local monotonic sequence number for stale-presence rejection. + public let sequence: Int + + /// Creates peer presence state. + public init( + peerID: String, + displayName: String, + color: String, + activeFile: String?, + cursor: Int, + selection: Range?, + sequence: Int + ) { + self.peerID = peerID + self.displayName = displayName + self.color = color + self.activeFile = activeFile + self.cursor = cursor + self.selection = selection + self.sequence = sequence + } +} diff --git a/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/SharedFileDescriptor.swift b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/SharedFileDescriptor.swift new file mode 100644 index 000000000000..d8a98ed23403 --- /dev/null +++ b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/SharedFileDescriptor.swift @@ -0,0 +1,29 @@ +public import Foundation + +/// Describes one repository-relative file shared in a collaboration session. +public struct SharedFileDescriptor: Codable, Equatable, Hashable, Sendable { + /// A local label for the repository clone. + public let repositoryID: String + /// The file path relative to the repository root. + public let relativePath: String + /// The local absolute file URL for this peer. + public let localURL: URL + + /// Creates a shared file descriptor. + /// - Parameters: + /// - repositoryID: A local label for the repository clone. + /// - relativePath: The file path relative to the repository root. + /// - localURL: The local absolute file URL for this peer. + public init(repositoryID: String, relativePath: String, localURL: URL) { + self.repositoryID = repositoryID + self.relativePath = relativePath + self.localURL = localURL + } + + /// The stable collaboration document identifier for a session. + /// - Parameter sessionID: The current collaboration session identifier. + /// - Returns: A document identifier scoped to the session. + public func documentID(sessionID: String) -> String { + "\(sessionID):\(repositoryID):\(relativePath)" + } +} diff --git a/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/TextElement.swift b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/TextElement.swift new file mode 100644 index 000000000000..c5f0af560aaa --- /dev/null +++ b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/TextElement.swift @@ -0,0 +1,9 @@ +import Foundation + +/// One stored element in the collaboration text CRDT. +struct TextElement: Codable, Equatable, Sendable { + let id: CharacterID + let after: CharacterID? + let value: String + var isDeleted: Bool +} diff --git a/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/TextHash.swift b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/TextHash.swift new file mode 100644 index 000000000000..6e7003b682a3 --- /dev/null +++ b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/TextHash.swift @@ -0,0 +1,16 @@ +import Foundation + +/// Computes stable non-cryptographic text hashes for disk baselines. +struct TextHash: Sendable { + /// Returns a stable hexadecimal FNV-1a hash for text. + /// - Parameter text: Text to hash. + /// - Returns: A stable hexadecimal hash string. + func hash(_ text: String) -> String { + var value: UInt64 = 0xcbf29ce484222325 + for byte in text.utf8 { + value ^= UInt64(byte) + value &*= 0x100000001b3 + } + return String(value, radix: 16) + } +} diff --git a/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/TextOperation.swift b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/TextOperation.swift new file mode 100644 index 000000000000..de9afb54b6b1 --- /dev/null +++ b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/TextOperation.swift @@ -0,0 +1,9 @@ +import Foundation + +/// A CRDT text operation broadcast between peers. +public enum TextOperation: Codable, Equatable, Sendable { + /// Inserts one character after the referenced predecessor. + case insert(id: CharacterID, after: CharacterID?, value: String) + /// Deletes an existing character by tombstoning its identifier. + case delete(id: CharacterID) +} diff --git a/Packages/Shared/CmuxCollaboration/Tests/CmuxCollaborationTests/CollaborationPeerIdentityTests.swift b/Packages/Shared/CmuxCollaboration/Tests/CmuxCollaborationTests/CollaborationPeerIdentityTests.swift new file mode 100644 index 000000000000..4b2dd5bc19d9 --- /dev/null +++ b/Packages/Shared/CmuxCollaboration/Tests/CmuxCollaborationTests/CollaborationPeerIdentityTests.swift @@ -0,0 +1,41 @@ +import CmuxCollaboration +import Foundation +import Testing + +@Suite +struct CollaborationPeerIdentityTests { + @Test + func ephemeralIdentitiesAreDistinctForSeparateLocalPeers() throws { + let firstUUID = try #require(UUID(uuidString: "00000000-0000-0000-0000-000000000001")) + let secondUUID = try #require(UUID(uuidString: "00000000-0000-0000-0000-000000000002")) + + let first = CollaborationPeerIdentity.ephemeral( + displayName: "Dorsa", + colorPalette: ["#111111"], + idProvider: { firstUUID } + ) + let second = CollaborationPeerIdentity.ephemeral( + displayName: "Dorsa", + colorPalette: ["#111111"], + idProvider: { secondUUID } + ) + + #expect(first.peerID != second.peerID) + #expect(first.displayName == second.displayName) + #expect(first.color == "#111111") + #expect(second.color == "#111111") + } + + @Test + func emptyPaletteFallsBackToDefaultColor() throws { + let uuid = try #require(UUID(uuidString: "00000000-0000-0000-0000-000000000003")) + + let identity = CollaborationPeerIdentity.ephemeral( + displayName: "Dorsa", + colorPalette: [], + idProvider: { uuid } + ) + + #expect(CollaborationPeerIdentity.defaultColorPalette.contains(identity.color)) + } +} diff --git a/Packages/Shared/CmuxCollaboration/Tests/CmuxCollaborationTests/CollaborationRelayStateTests.swift b/Packages/Shared/CmuxCollaboration/Tests/CmuxCollaborationTests/CollaborationRelayStateTests.swift new file mode 100644 index 000000000000..c1786685535a --- /dev/null +++ b/Packages/Shared/CmuxCollaboration/Tests/CmuxCollaborationTests/CollaborationRelayStateTests.swift @@ -0,0 +1,35 @@ +import CmuxCollaboration +import Foundation +import Testing + +@Suite +struct CollaborationRelayStateTests { + @Test + func relayUnavailableIsExplicitState() async { + let session = CollaborationSession(peerID: "a", displayName: "A", color: "#111111", sessionID: "s") + + await session.markRelayUnavailable() + + #expect(await session.currentConnectionState() == .relayUnavailable) + } + + @Test + func relayDisconnectClearsRemotePresence() async throws { + let session = CollaborationSession(peerID: "a", displayName: "A", color: "#111111", sessionID: "s") + let remote = PresenceState( + peerID: "b", + displayName: "B", + color: "#222222", + activeFile: "note.txt", + cursor: 1, + selection: nil, + sequence: 1 + ) + + try await session.applyRemoteFrame(.presence(remote)) + await session.markConnected() + await session.markDisconnected() + + #expect(await session.currentConnectionState() == .disconnected) + } +} diff --git a/Packages/Shared/CmuxCollaboration/Tests/CmuxCollaborationTests/CollaborationSessionDiskTests.swift b/Packages/Shared/CmuxCollaboration/Tests/CmuxCollaborationTests/CollaborationSessionDiskTests.swift new file mode 100644 index 000000000000..38e4338f1346 --- /dev/null +++ b/Packages/Shared/CmuxCollaboration/Tests/CmuxCollaborationTests/CollaborationSessionDiskTests.swift @@ -0,0 +1,55 @@ +import CmuxCollaboration +import Foundation +import Testing + +@Suite(.serialized) +struct CollaborationSessionDiskTests { + @Test + func closeWritesResolvedTextWhenDiskBaselineIsUnchanged() async throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appendingPathComponent("note.txt") + try "hello".write(to: file, atomically: true, encoding: .utf8) + let descriptor = SharedFileDescriptor(repositoryID: "repo", relativePath: "note.txt", localURL: file) + let session = CollaborationSession(peerID: "a", displayName: "A", color: "#111111", sessionID: "s") + + _ = try await session.open(file: descriptor) + _ = try await session.applyLocalEdit(file: descriptor, range: 5..<5, replacement: " world") + let result = try await session.close(file: descriptor) + + #expect(try String(contentsOf: file, encoding: .utf8) == "hello world") + if case .wroteOriginal = result {} else { + Issue.record("Expected original write, got \(result)") + } + } + + @Test + func closeWritesConflictSiblingWhenDiskChangedOutOfBand() async throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appendingPathComponent("note.txt") + try "hello".write(to: file, atomically: true, encoding: .utf8) + let descriptor = SharedFileDescriptor(repositoryID: "repo", relativePath: "note.txt", localURL: file) + let session = CollaborationSession(peerID: "a", displayName: "A", color: "#111111", sessionID: "s") + + _ = try await session.open(file: descriptor) + _ = try await session.applyLocalEdit(file: descriptor, range: 5..<5, replacement: " shared") + try "external".write(to: file, atomically: true, encoding: .utf8) + let result = try await session.close(file: descriptor) + + #expect(try String(contentsOf: file, encoding: .utf8) == "external") + guard case let .wroteConflict(_, conflictURL, _, _) = result else { + Issue.record("Expected conflict write, got \(result)") + return + } + #expect(try String(contentsOf: conflictURL, encoding: .utf8) == "hello shared") + } + + private func temporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-collaboration-tests") + .appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } +} diff --git a/Packages/Shared/CmuxCollaboration/Tests/CmuxCollaborationTests/CollaborationTextDocumentTests.swift b/Packages/Shared/CmuxCollaboration/Tests/CmuxCollaborationTests/CollaborationTextDocumentTests.swift new file mode 100644 index 000000000000..c3ef95914b06 --- /dev/null +++ b/Packages/Shared/CmuxCollaboration/Tests/CmuxCollaborationTests/CollaborationTextDocumentTests.swift @@ -0,0 +1,52 @@ +import CmuxCollaboration +import Testing + +@Suite +struct CollaborationTextDocumentTests { + @Test + func concurrentEditsConvergeInDifferentOrders() { + let author = CollaborationTextDocument(text: "abcd", peerID: "a") + var left = CollaborationTextDocument(peerID: "b") + var right = CollaborationTextDocument(peerID: "c") + let bootstrap = author.snapshotOperations() + left.merge(bootstrap) + right.merge(bootstrap) + + let leftOps = left.replace(range: 1..<3, with: "XY") + let rightOps = right.replace(range: 2..<2, with: "!") + + var replicaOne = CollaborationTextDocument(peerID: "one") + replicaOne.merge(bootstrap) + replicaOne.merge(leftOps) + replicaOne.merge(rightOps) + + var replicaTwo = CollaborationTextDocument(peerID: "two") + replicaTwo.merge(bootstrap) + replicaTwo.merge(rightOps) + replicaTwo.merge(leftOps) + + var replicaThree = author + replicaThree.merge(leftOps.shuffled()) + replicaThree.merge(rightOps.shuffled()) + + #expect(replicaOne.text == replicaTwo.text) + #expect(replicaTwo.text == replicaThree.text) + } + + @Test + func offlineSnapshotResyncConverges() { + var peerA = CollaborationTextDocument(text: "hello", peerID: "a") + var peerB = CollaborationTextDocument(peerID: "b") + peerB.merge(peerA.snapshotOperations()) + + _ = peerA.replace(range: 5..<5, with: " from A") + _ = peerB.replace(range: 0..<0, with: "B says ") + + peerA.merge(peerB.snapshotOperations()) + peerB.merge(peerA.snapshotOperations()) + + #expect(peerA.text == peerB.text) + #expect(peerA.text.contains("from A")) + #expect(peerA.text.contains("B says")) + } +} diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 7f1c089d3a7c..0e8387056ada 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -216983,6 +216983,346 @@ } } } + }, + "collaboration.action.cancel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" + } + } + } + }, + "collaboration.action.createSession": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create Session" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッションを作成" + } + } + } + }, + "collaboration.action.joinSession": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Join Session" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッションに参加" + } + } + } + }, + "collaboration.error.invalidRelayURL": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Invalid relay URL." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リレー URL が無効です。" + } + } + } + }, + "collaboration.error.relayRejected": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The relay rejected the request." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リレーがリクエストを拒否しました。" + } + } + } + }, + "collaboration.join.inviteTokenPlaceholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Invite token" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "招待トークン" + } + } + } + }, + "collaboration.join.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter the session code and invite token from the collaborator." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "共同編集者から受け取ったセッションコードと招待トークンを入力してください。" + } + } + } + }, + "collaboration.join.sessionCodePlaceholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Session code" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッションコード" + } + } + } + }, + "collaboration.peers.count": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%d peers" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d 人のピア" + } + } + } + }, + "collaboration.peers.none": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No peers" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ピアなし" + } + } + } + }, + "collaboration.peers.one": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "1 peer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1 人のピア" + } + } + } + }, + "collaboration.start.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a relay URL, then create a new invite or join an existing one." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リレー URL を入力して、新しい招待を作成するか既存の招待に参加してください。" + } + } + } + }, + "collaboration.start.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Start Collaboration" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "共同編集を開始" + } + } + } + }, + "collaboration.status.connected": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connected" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続済み" + } + } + } + }, + "collaboration.status.connecting": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connecting..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続中..." + } + } + } + }, + "collaboration.status.connectionFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connection failed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続に失敗しました" + } + } + } + }, + "collaboration.status.disconnected": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not connected" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未接続" + } + } + } + }, + "collaboration.status.resynchronizing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Resynchronizing" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再同期中" + } + } + } + }, + "collaboration.status.shared": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shared" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "共有中" + } + } + } + }, + "collaboration.toolbar.collaborate": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Collaborate" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "共同編集" + } + } + } } }, "version": "1.0" diff --git a/Sources/CollaborationRuntime.swift b/Sources/CollaborationRuntime.swift new file mode 100644 index 000000000000..5b62c2c62dec --- /dev/null +++ b/Sources/CollaborationRuntime.swift @@ -0,0 +1,919 @@ +import AppKit +import CmuxCollaboration +import Foundation +import Observation +import SwiftUI + +@MainActor +protocol CollaborationEditablePanel: AnyObject { + var collaborationFileURL: URL { get } + var collaborationFilePath: String { get } + var collaborationText: String { get } + + func applyCollaborationText(_ text: String) +} + +struct CollaborationDocumentHeaderState: Equatable { + var isShared = false + var statusText = "" + var peerSummary = "" +} + +private struct CollaborationCreateSessionResponse: Decodable { + let sessionID: String + let sessionCode: String + let token: String +} + +private struct CollaborationPeerWire: Codable { + let peerID: String + let displayName: String + let color: String +} + +private struct CollaborationJoinedWire: Decodable { + let sessionID: String + let peers: [CollaborationPeerWire] +} + +private struct CollaborationFrameType: Decodable { + let type: String +} + +private struct CollaborationHeartbeatWire: Codable { + let type = "peer.heartbeat" +} + +private struct CollaborationDocumentUpdateWire: Codable { + let type: String + let documentID: String + let updateID: String + let operations: [TextOperation] +} + +private struct CollaborationDocumentSnapshotWire: Codable { + let type: String + let documentID: String + let requestID: String? + let operations: [TextOperation] + let textHash: String +} + +private struct CollaborationDocumentSnapshotRequestWire: Codable { + let type: String + let documentID: String + let requestID: String +} + +private struct CollaborationPresenceWire: Codable { + let type: String + let peerID: String + let displayName: String + let color: String + let activeFile: String? + let cursor: Int + let selectionLowerBound: Int? + let selectionUpperBound: Int? + let sequence: Int + + init(state: PresenceState) { + self.type = "presence.update" + self.peerID = state.peerID + self.displayName = state.displayName + self.color = state.color + self.activeFile = state.activeFile + self.cursor = state.cursor + self.selectionLowerBound = state.selection?.lowerBound + self.selectionUpperBound = state.selection?.upperBound + self.sequence = state.sequence + } + + var presenceState: PresenceState { + let range: Range? + if let selectionLowerBound, let selectionUpperBound { + range = selectionLowerBound..? + private var heartbeatTask: Task? + private var panelsByDocumentID: [String: WeakCollaborationPanel] = [:] + private var descriptorsByDocumentID: [String: SharedFileDescriptor] = [:] + private var statesByDocumentID: [String: CollaborationDocumentHeaderState] = [:] + private var peersByID: [String: CollaborationPeerWire] = [:] + private var snapshotFallbackTasks: [String: Task] = [:] + + private init() { + let displayName = NSFullUserName().isEmpty ? Host.current().localizedName ?? "cmux" : NSFullUserName() + peerIdentity = CollaborationPeerIdentity.ephemeral(displayName: displayName) + } + + func state(for panel: any CollaborationEditablePanel) -> CollaborationDocumentHeaderState { + let descriptor = descriptor(for: panel) + let documentID = descriptor.documentID(sessionID: sessionCode ?? "") + return statesByDocumentID[documentID] ?? CollaborationDocumentHeaderState( + isShared: false, + statusText: connectionLabel, + peerSummary: peerSummary + ) + } + + func configureOrShare(panel: any CollaborationEditablePanel) { + if session == nil { + presentStartDialog(thenShare: panel) + return + } + share(panel: panel) + } + + func leave(panel: any CollaborationEditablePanel) { + guard let session else { return } + let descriptor = descriptor(for: panel) + let documentID = descriptor.documentID(sessionID: sessionCode ?? "") + panelsByDocumentID.removeValue(forKey: documentID) + descriptorsByDocumentID.removeValue(forKey: documentID) + statesByDocumentID.removeValue(forKey: documentID) + snapshotFallbackTasks[documentID]?.cancel() + snapshotFallbackTasks.removeValue(forKey: documentID) + Task { + _ = try? await session.close(file: descriptor) + } + } + + func noteLocalTextChange(panel: any CollaborationEditablePanel, previousText: String, nextText: String) { + guard let session else { return } + let descriptor = descriptor(for: panel) + let documentID = descriptor.documentID(sessionID: sessionCode ?? "") + guard panelsByDocumentID[documentID]?.panel != nil else { return } + let edit = CollaborationTextDiff.diff(previous: previousText, next: nextText) + Task { + do { + let frame = try await session.applyLocalEdit( + file: descriptor, + range: edit.range, + replacement: edit.replacement + ) + try await send(frame) + } catch { + lastErrorMessage = error.localizedDescription + } + } + } + + func noteLocalSelection(panel: any CollaborationEditablePanel, textView: NSTextView) { + guard let session else { return } + let selectedRange = textView.selectedRange() + let selection: Range? + if selectedRange.length > 0 { + selection = selectedRange.location..<(selectedRange.location + selectedRange.length) + } else { + selection = nil + } + let descriptor = descriptor(for: panel) + Task { + let frame = await session.setLocalSelection( + file: descriptor, + cursor: selectedRange.location, + selection: selection + ) + try? await send(frame) + } + } + + func statusSummary() -> String { + if let sessionCode { + return "\(CollaborationStrings.connected): \(sessionCode)" + } + return connectionLabel + } + + func statusPayload() -> [String: Any] { + [ + "connected": session != nil, + "relay_url": relayURLString, + "session_code": sessionCode ?? NSNull(), + "invite_token": inviteToken ?? NSNull(), + "status": connectionLabel, + "shared_documents": statesByDocumentID.values.filter(\.isShared).count, + "peers": peersByID.values.map { peer in + [ + "peer_id": peer.peerID, + "display_name": peer.displayName, + "color": peer.color, + ] + }, + ] + } + + func createSessionForAutomation(relayURL: String?) async -> [String: Any] { + if let relayURL, !relayURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + relayURLString = relayURL.trimmingCharacters(in: .whitespacesAndNewlines) + } + do { + let response = try await createSession() + await connect(sessionID: response.sessionID, code: response.sessionCode, token: response.token) + var payload = statusPayload() + payload["session_code"] = response.sessionCode + payload["invite_token"] = response.token + return payload + } catch { + lastErrorMessage = error.localizedDescription + connectionLabel = CollaborationStrings.connectionFailed + return [ + "connected": false, + "status": connectionLabel, + "error": error.localizedDescription, + ] + } + } + + func createSessionForAutomationRequest(relayURL: String?) -> [String: Any] { + Task { @MainActor in + _ = await createSessionForAutomation(relayURL: relayURL) + } + return [ + "requested": true, + "status": CollaborationStrings.connecting, + ] + } + + func joinSessionForAutomation(relayURL: String?, code: String, token: String) async -> [String: Any] { + if let relayURL, !relayURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + relayURLString = relayURL.trimmingCharacters(in: .whitespacesAndNewlines) + } + await joinSession(code: code, token: token) + return statusPayload() + } + + func joinSessionForAutomationRequest(relayURL: String?, code: String, token: String) -> [String: Any] { + Task { @MainActor in + _ = await joinSessionForAutomation(relayURL: relayURL, code: code, token: token) + } + return [ + "requested": true, + "session_code": code, + "status": CollaborationStrings.connecting, + ] + } + + func leaveSessionForAutomation() -> [String: Any] { + disconnectWebSocket() + session = nil + sessionCode = nil + inviteToken = nil + panelsByDocumentID.removeAll() + descriptorsByDocumentID.removeAll() + statesByDocumentID.removeAll() + peersByID.removeAll() + connectionLabel = CollaborationStrings.disconnected + return statusPayload() + } + + private func presentStartDialog(thenShare panel: any CollaborationEditablePanel) { + let alert = NSAlert() + alert.messageText = CollaborationStrings.startTitle + alert.informativeText = CollaborationStrings.startMessage + alert.addButton(withTitle: CollaborationStrings.createSession) + alert.addButton(withTitle: CollaborationStrings.joinSession) + alert.addButton(withTitle: CollaborationStrings.cancel) + + let relayField = NSTextField(string: relayURLString) + relayField.placeholderString = "http://localhost:8787" + relayField.frame = NSRect(x: 0, y: 0, width: 360, height: 24) + alert.accessoryView = relayField + + let response = alert.runModal() + relayURLString = relayField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + switch response { + case .alertFirstButtonReturn: + Task { await createSessionAndShare(panel: panel) } + case .alertSecondButtonReturn: + presentJoinDialog(thenShare: panel) + default: + break + } + } + + private func presentJoinDialog(thenShare panel: any CollaborationEditablePanel) { + let alert = NSAlert() + alert.messageText = CollaborationStrings.joinSession + alert.informativeText = CollaborationStrings.joinMessage + alert.addButton(withTitle: CollaborationStrings.joinSession) + alert.addButton(withTitle: CollaborationStrings.cancel) + + let stack = NSStackView() + stack.orientation = .vertical + stack.spacing = 8 + stack.frame = NSRect(x: 0, y: 0, width: 360, height: 64) + let codeField = NSTextField(string: "") + codeField.placeholderString = CollaborationStrings.sessionCodePlaceholder + let tokenField = NSTextField(string: "") + tokenField.placeholderString = CollaborationStrings.inviteTokenPlaceholder + stack.addArrangedSubview(codeField) + stack.addArrangedSubview(tokenField) + alert.accessoryView = stack + + guard alert.runModal() == .alertFirstButtonReturn else { return } + let code = codeField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + let token = tokenField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + Task { + await joinSession(code: code, token: token) + share(panel: panel) + } + } + + private func createSessionAndShare(panel: any CollaborationEditablePanel) async { + do { + let response = try await createSession() + await connect(sessionID: response.sessionID, code: response.sessionCode, token: response.token) + share(panel: panel) + } catch { + lastErrorMessage = error.localizedDescription + connectionLabel = CollaborationStrings.connectionFailed + } + } + + private func joinSession(code: String, token: String) async { + await connect(sessionID: code, code: code, token: token) + } + + private func createSession() async throws -> CollaborationCreateSessionResponse { + guard let url = URL(string: relayURLString)? + .appending(path: "v1") + .appending(path: "collaboration") + .appending(path: "sessions") else { + throw CollaborationRuntimeError.invalidRelayURL + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw CollaborationRuntimeError.relayRejected + } + return try decoder.decode(CollaborationCreateSessionResponse.self, from: data) + } + + private func connect(sessionID: String, code: String, token: String) async { + disconnectWebSocket() + sessionCode = code + inviteToken = token + connectionLabel = CollaborationStrings.connecting + let nextSession = CollaborationSession( + peerID: peerIdentity.peerID, + displayName: peerIdentity.displayName, + color: peerIdentity.color, + sessionID: sessionID + ) + session = nextSession + observe(session: nextSession) + + guard let url = connectURL(code: code, token: token) else { + connectionLabel = CollaborationStrings.connectionFailed + await nextSession.markRelayUnavailable() + return + } + let task = URLSession.shared.webSocketTask(with: url) + webSocketTask = task + task.resume() + receiveNextMessage() + startHeartbeatLoop() + await nextSession.markConnected() + connectionLabel = CollaborationStrings.connected + reopenSharedDocumentsForCurrentSession() + } + + private func connectURL(code: String, token: String) -> URL? { + guard var components = URLComponents(string: relayURLString) else { return nil } + components.scheme = components.scheme == "https" ? "wss" : "ws" + components.path = "/v1/collaboration/sessions/\(code)/connect" + components.queryItems = [ + URLQueryItem(name: "token", value: token), + URLQueryItem(name: "peerID", value: peerIdentity.peerID), + URLQueryItem(name: "displayName", value: peerIdentity.displayName), + URLQueryItem(name: "color", value: peerIdentity.color), + ] + return components.url + } + + private func observe(session: CollaborationSession) { + sessionEventsTask?.cancel() + sessionEventsTask = Task { [weak self] in + let events = await session.events + for await event in events { + await self?.handle(event: event) + } + } + } + + private func share(panel: any CollaborationEditablePanel) { + guard let session, let sessionCode else { return } + let descriptor = descriptor(for: panel) + let documentID = descriptor.documentID(sessionID: sessionCode) + panelsByDocumentID[documentID] = WeakCollaborationPanel(panel) + descriptorsByDocumentID[documentID] = descriptor + statesByDocumentID[documentID] = CollaborationDocumentHeaderState( + isShared: true, + statusText: CollaborationStrings.shared, + peerSummary: peerSummary + ) + Task { + do { + _ = try await session.open(file: descriptor) + if peersByID.isEmpty { + try await send(try await session.snapshotFrame(for: descriptor)) + } else { + let requestID = UUID().uuidString + try await sendSnapshotRequest(documentID: documentID, requestID: requestID) + scheduleSnapshotFallback(descriptor: descriptor, documentID: documentID) + } + } catch { + lastErrorMessage = error.localizedDescription + } + } + } + + private func reopenSharedDocumentsForCurrentSession() { + guard let session, let sessionCode else { return } + let openPanels = panelsByDocumentID.values.compactMap(\.panel) + guard !openPanels.isEmpty else { return } + + snapshotFallbackTasks.values.forEach { $0.cancel() } + snapshotFallbackTasks.removeAll() + + panelsByDocumentID.removeAll() + descriptorsByDocumentID.removeAll() + statesByDocumentID.removeAll() + + for panel in openPanels { + let descriptor = descriptor(for: panel) + let documentID = descriptor.documentID(sessionID: sessionCode) + panelsByDocumentID[documentID] = WeakCollaborationPanel(panel) + descriptorsByDocumentID[documentID] = descriptor + statesByDocumentID[documentID] = CollaborationDocumentHeaderState( + isShared: true, + statusText: CollaborationStrings.shared, + peerSummary: peerSummary + ) + Task { + do { + _ = try await session.open(file: descriptor) + try await send(try await session.snapshotFrame(for: descriptor)) + } catch { + lastErrorMessage = error.localizedDescription + } + } + } + } + + private func scheduleSnapshotFallback(descriptor: SharedFileDescriptor, documentID: String) { + snapshotFallbackTasks[documentID]?.cancel() + snapshotFallbackTasks[documentID] = Task { [weak self] in + try? await Task.sleep(for: .seconds(2)) + guard !Task.isCancelled else { return } + await self?.sendLocalSnapshotIfOpen(descriptor: descriptor) + } + } + + private func sendLocalSnapshotIfOpen(descriptor: SharedFileDescriptor) async { + guard let session else { return } + do { + try await send(try await session.snapshotFrame(for: descriptor)) + } catch { + lastErrorMessage = error.localizedDescription + } + } + + private func handle(event: CollaborationEvent) async { + switch event { + case .documentChanged(let snapshot): + guard let panel = panelsByDocumentID[snapshot.documentID]?.panel else { return } + panel.applyCollaborationText(snapshot.text) + updateState(documentID: snapshot.documentID, isShared: true) + case .presenceChanged: + refreshPeerSummaries() + case .presenceCleared(let peerID): + peersByID.removeValue(forKey: peerID) + refreshPeerSummaries() + case .connectionChanged(let state): + connectionLabel = label(for: state) + case .diskReconciled: + break + } + } + + private func receiveNextMessage() { + guard let task = webSocketTask else { return } + task.receive { [weak self] result in + Task { @MainActor in + await self?.handleReceive(result) + } + } + } + + private func handleReceive(_ result: Result) async { + switch result { + case .failure(let error): + lastErrorMessage = error.localizedDescription + connectionLabel = CollaborationStrings.disconnected + await session?.markDisconnected() + case .success(let message): + do { + let data: Data + switch message { + case .string(let string): + data = Data(string.utf8) + case .data(let frameData): + data = frameData + @unknown default: + receiveNextMessage() + return + } + try await handleFrameData(data) + } catch { + lastErrorMessage = error.localizedDescription + } + receiveNextMessage() + } + } + + private func handleFrameData(_ data: Data) async throws { + let frameType = try decoder.decode(CollaborationFrameType.self, from: data) + switch frameType.type { + case "session.joined": + let joined = try decoder.decode(CollaborationJoinedWire.self, from: data) + peersByID = Dictionary(uniqueKeysWithValues: joined.peers.filter { $0.peerID != peerIdentity.peerID }.map { ($0.peerID, $0) }) + refreshPeerSummaries() + case "peer.joined": + let peer = try decoder.decode(CollaborationPeerJoinedWire.self, from: data).peer + if peer.peerID != peerIdentity.peerID { + peersByID[peer.peerID] = peer + refreshPeerSummaries() + } + case "peer.left": + let left = try decoder.decode(CollaborationPeerLeftWire.self, from: data) + peersByID.removeValue(forKey: left.peerID) + refreshPeerSummaries() + try await session?.applyRemoteFrame(.peerLeft(peerID: left.peerID)) + case "document.update": + let update = try decoder.decode(CollaborationDocumentUpdateWire.self, from: data) + try await session?.applyRemoteFrame(.documentUpdate( + documentID: update.documentID, + updateID: update.updateID, + operations: update.operations + )) + case "document.snapshot": + let snapshot = try decoder.decode(CollaborationDocumentSnapshotWire.self, from: data) + snapshotFallbackTasks[snapshot.documentID]?.cancel() + snapshotFallbackTasks.removeValue(forKey: snapshot.documentID) + try await session?.applyRemoteFrame(.documentSnapshot( + documentID: snapshot.documentID, + requestID: snapshot.requestID, + operations: snapshot.operations, + textHash: snapshot.textHash + )) + case "document.snapshot.request": + let request = try decoder.decode(CollaborationDocumentSnapshotRequestWire.self, from: data) + if let descriptor = descriptorsByDocumentID[request.documentID], + let session { + try await send(try await session.snapshotFrame(for: descriptor, requestID: request.requestID)) + } + case "presence.update": + let presence = try decoder.decode(CollaborationPresenceWire.self, from: data) + try await session?.applyRemoteFrame(.presence(presence.presenceState)) + default: + break + } + } + + private func send(_ frame: CollaborationRelayFrame) async throws { + switch frame { + case .documentUpdate(let documentID, let updateID, let operations): + try await send(CollaborationDocumentUpdateWire( + type: "document.update", + documentID: documentID, + updateID: updateID, + operations: operations + )) + case .documentSnapshot(let documentID, let requestID, let operations, let textHash): + try await send(CollaborationDocumentSnapshotWire( + type: "document.snapshot", + documentID: documentID, + requestID: requestID, + operations: operations, + textHash: textHash + )) + case .documentSnapshotRequest(let documentID, let requestID): + try await sendSnapshotRequest(documentID: documentID, requestID: requestID) + case .presence(let state): + try await send(CollaborationPresenceWire(state: state)) + case .peerLeft: + break + } + } + + private func sendSnapshotRequest(documentID: String, requestID: String) async throws { + try await send(CollaborationDocumentSnapshotRequestWire( + type: "document.snapshot.request", + documentID: documentID, + requestID: requestID + )) + } + + private func send(_ frame: T) async throws { + guard let webSocketTask else { throw CollaborationRuntimeError.notConnected } + let data = try encoder.encode(frame) + let text = String(decoding: data, as: UTF8.self) + try await webSocketTask.send(.string(text)) + } + + private func startHeartbeatLoop() { + heartbeatTask?.cancel() + heartbeatTask = Task { [weak self] in + while !Task.isCancelled { + do { + try await self?.send(CollaborationHeartbeatWire()) + // Collaboration relay expires peers after 30 seconds; 10 seconds tolerates missed beats. + try await Task.sleep(for: .seconds(10)) + } catch is CancellationError { + return + } catch { + await self?.recordHeartbeatFailure(error) + return + } + } + } + } + + private func recordHeartbeatFailure(_ error: any Error) { + lastErrorMessage = error.localizedDescription + } + + private func updateState(documentID: String, isShared: Bool) { + statesByDocumentID[documentID] = CollaborationDocumentHeaderState( + isShared: isShared, + statusText: isShared ? CollaborationStrings.shared : connectionLabel, + peerSummary: peerSummary + ) + } + + private func refreshPeerSummaries() { + for documentID in statesByDocumentID.keys { + updateState(documentID: documentID, isShared: statesByDocumentID[documentID]?.isShared ?? false) + } + } + + private var peerSummary: String { + if peersByID.isEmpty { return CollaborationStrings.noPeers } + if peersByID.count == 1 { return CollaborationStrings.onePeer } + return String(format: CollaborationStrings.peerCountFormat, peersByID.count) + } + + private func label(for state: CollaborationConnectionState) -> String { + switch state { + case .idle: + return CollaborationStrings.disconnected + case .connected: + return CollaborationStrings.connected + case .relayUnavailable: + return CollaborationStrings.connectionFailed + case .disconnected: + return CollaborationStrings.disconnected + case .resynchronizing: + return CollaborationStrings.resynchronizing + } + } + + private func descriptor(for panel: any CollaborationEditablePanel) -> SharedFileDescriptor { + let root = CollaborationRepositoryResolver.repositoryRoot(for: panel.collaborationFileURL) + let relativePath: String + if let root { + relativePath = panel.collaborationFileURL.path.replacingOccurrences( + of: root.path.hasSuffix("/") ? root.path : root.path + "/", + with: "" + ) + } else { + relativePath = panel.collaborationFileURL.lastPathComponent + } + return SharedFileDescriptor( + repositoryID: root?.lastPathComponent ?? panel.collaborationFileURL.deletingLastPathComponent().lastPathComponent, + relativePath: relativePath, + localURL: panel.collaborationFileURL + ) + } + + private func disconnectWebSocket() { + heartbeatTask?.cancel() + heartbeatTask = nil + webSocketTask?.cancel(with: .goingAway, reason: nil) + webSocketTask = nil + sessionEventsTask?.cancel() + sessionEventsTask = nil + } +} + +private struct CollaborationPeerJoinedWire: Decodable { + let peer: CollaborationPeerWire +} + +private enum CollaborationRuntimeError: LocalizedError { + case invalidRelayURL + case relayRejected + case notConnected + + var errorDescription: String? { + switch self { + case .invalidRelayURL: + return CollaborationStrings.invalidRelayURL + case .relayRejected: + return CollaborationStrings.relayRejected + case .notConnected: + return CollaborationStrings.disconnected + } + } +} + +private enum CollaborationRepositoryResolver { + static func repositoryRoot(for fileURL: URL) -> URL? { + var current = fileURL.deletingLastPathComponent() + while current.path != "/" { + let gitURL = current.appendingPathComponent(".git") + if FileManager.default.fileExists(atPath: gitURL.path) { + return current + } + current.deleteLastPathComponent() + } + return nil + } +} + +private enum CollaborationTextDiff { + static func diff(previous: String, next: String) -> (range: Range, replacement: String) { + let previousCharacters = Array(previous) + let nextCharacters = Array(next) + var prefix = 0 + while prefix < previousCharacters.count, + prefix < nextCharacters.count, + previousCharacters[prefix] == nextCharacters[prefix] { + prefix += 1 + } + var previousSuffix = previousCharacters.count + var nextSuffix = nextCharacters.count + while previousSuffix > prefix, + nextSuffix > prefix, + previousCharacters[previousSuffix - 1] == nextCharacters[nextSuffix - 1] { + previousSuffix -= 1 + nextSuffix -= 1 + } + return (prefix..: View where PanelModel: CollaborationEditablePanel { + @State private var runtime = CollaborationRuntime.shared + let panel: PanelModel + + var body: some View { + let state = runtime.state(for: panel) + HStack(spacing: 6) { + if state.isShared { + Text(state.peerSummary) + .font(.caption2) + .foregroundStyle(.secondary) + } + PanelHeaderIconButton( + systemName: state.isShared ? "person.2.fill" : "person.2", + label: state.isShared ? "\(state.statusText) - \(state.peerSummary)" : CollaborationStrings.collaborate, + isDisabled: false, + action: { + if state.isShared { + runtime.leave(panel: panel) + } else { + runtime.configureOrShare(panel: panel) + } + } + ) + } + } +} diff --git a/Sources/Panels/FilePreviewPanel.swift b/Sources/Panels/FilePreviewPanel.swift index 3bc754a2cd90..2bd68b68be0b 100644 --- a/Sources/Panels/FilePreviewPanel.swift +++ b/Sources/Panels/FilePreviewPanel.swift @@ -1007,6 +1007,18 @@ final class FilePreviewPanel: Panel, ObservableObject, FilePreviewTextEditingPan URL(fileURLWithPath: filePath) } + var collaborationFileURL: URL { + fileURL + } + + var collaborationFilePath: String { + filePath + } + + var collaborationText: String { + textContent + } + init( workspaceId: UUID, filePath: String, @@ -1147,6 +1159,13 @@ final class FilePreviewPanel: Panel, ObservableObject, FilePreviewTextEditingPan isDirty = nextContent != originalTextContent } + func applyCollaborationText(_ text: String) { + guard textContent != text else { return } + textContent = text + textView?.string = text + isDirty = text != originalTextContent + } + private func prepareContentForPreviewMode() { if previewMode == .text { loadTextContent(replacingDirtyContent: false) @@ -1332,6 +1351,8 @@ struct FilePreviewPanelView: View { foregroundColor: themeForegroundColor ) { if panel.previewMode == .text { + CollaborationHeaderControls(panel: panel) + PanelHeaderIconButton( systemName: "arrow.counterclockwise", label: String(localized: "filePreview.revert", defaultValue: "Revert"), diff --git a/Sources/Panels/FilePreviewTextEditor.swift b/Sources/Panels/FilePreviewTextEditor.swift index 23af4b615a31..79ad715206f6 100644 --- a/Sources/Panels/FilePreviewTextEditor.swift +++ b/Sources/Panels/FilePreviewTextEditor.swift @@ -3,7 +3,7 @@ import CmuxFoundation import SwiftUI @MainActor -protocol FilePreviewTextEditingPanel: AnyObject { +protocol FilePreviewTextEditingPanel: CollaborationEditablePanel { var textContent: String { get } func attachTextView(_ textView: NSTextView) @@ -106,7 +106,18 @@ struct FilePreviewTextEditor: NSViewRepresentable where PanelModel: func textDidChange(_ notification: Notification) { guard !isApplyingPanelUpdate, let textView = notification.object as? NSTextView else { return } + let previousText = panel.textContent panel.updateTextContent(textView.string) + CollaborationRuntime.shared.noteLocalTextChange( + panel: panel, + previousText: previousText, + nextText: textView.string + ) + } + + func textViewDidChangeSelection(_ notification: Notification) { + guard let textView = notification.object as? NSTextView else { return } + CollaborationRuntime.shared.noteLocalSelection(panel: panel, textView: textView) } } } diff --git a/Sources/Panels/MarkdownPanel.swift b/Sources/Panels/MarkdownPanel.swift index 4b5140ec2322..7f2eda9d0739 100644 --- a/Sources/Panels/MarkdownPanel.swift +++ b/Sources/Panels/MarkdownPanel.swift @@ -20,6 +20,18 @@ final class MarkdownPanel: Panel, ObservableObject, FilePreviewTextEditingPanel /// Absolute path to the markdown file being displayed. let filePath: String + var collaborationFileURL: URL { + URL(fileURLWithPath: filePath) + } + + var collaborationFilePath: String { + filePath + } + + var collaborationText: String { + textContent + } + /// The workspace this panel belongs to. private(set) var workspaceId: UUID @@ -291,6 +303,15 @@ final class MarkdownPanel: Panel, ObservableObject, FilePreviewTextEditingPanel GlobalSearchCoordinator.shared.captureMarkdownPanel(self) } + func applyCollaborationText(_ text: String) { + guard textContent != text else { return } + textContent = text + content = text + textView?.string = text + isDirty = text != originalTextContent + GlobalSearchCoordinator.shared.captureMarkdownPanel(self) + } + @discardableResult func loadTextContent(replacingDirtyContent: Bool = true) -> Task? { loadFileContent(replacingDirtyContent: replacingDirtyContent) diff --git a/Sources/Panels/MarkdownPanelView.swift b/Sources/Panels/MarkdownPanelView.swift index 54458b161888..2bce1a3b373e 100644 --- a/Sources/Panels/MarkdownPanelView.swift +++ b/Sources/Panels/MarkdownPanelView.swift @@ -118,6 +118,8 @@ struct MarkdownPanelView: View { foregroundColor: themeForegroundColor ) { if panel.displayMode == .text { + CollaborationHeaderControls(panel: panel) + PanelHeaderIconButton( systemName: "arrow.counterclockwise", label: String(localized: "markdown.toolbar.revert", defaultValue: "Revert"), diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 94f772ca623b..01c87f98c149 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1105,6 +1105,43 @@ class TerminalController { return v2Ok(id: request.id, result: ["pong": true]) case "system.capabilities": return v2Ok(id: request.id, result: v2Capabilities()) + case "collaboration.status": + return v2AsyncResultCall(id: request.id, timeoutSeconds: 5) { + let payload = await CollaborationRuntime.shared.statusPayload() + return .ok(payload) + } + case "collaboration.session.create": + let relayURL = request.params["relay_url"] as? String + return v2AsyncResultCall(id: request.id, timeoutSeconds: 30) { + let payload = await CollaborationRuntime.shared.createSessionForAutomation(relayURL: relayURL) + return .ok(payload) + } + case "collaboration.session.join": + guard let code = request.params["session_code"] as? String, + let token = request.params["invite_token"] as? String else { + return v2Result( + id: request.id, + .err( + code: "invalid_params", + message: "collaboration.session.join requires session_code and invite_token", + data: nil + ) + ) + } + let relayURL = request.params["relay_url"] as? String + return v2AsyncResultCall(id: request.id, timeoutSeconds: 15) { + let payload = await CollaborationRuntime.shared.joinSessionForAutomation( + relayURL: relayURL, + code: code, + token: token + ) + return .ok(payload) + } + case "collaboration.session.leave": + return v2AsyncResultCall(id: request.id, timeoutSeconds: 5) { + let payload = await CollaborationRuntime.shared.leaveSessionForAutomation() + return .ok(payload) + } case "system.top": return v2Result(id: request.id, v2SystemTop(params: request.params)) case "system.memory": @@ -1805,6 +1842,37 @@ class TerminalController { return v2Ok(id: id, result: ["pong": true]) case "system.capabilities": return v2Ok(id: id, result: v2Capabilities()) + case "collaboration.status": + return v2Ok(id: id, result: CollaborationRuntime.shared.statusPayload()) + case "collaboration.session.create": + let relayURL = params["relay_url"] as? String + return v2Result( + id: id, + .ok(CollaborationRuntime.shared.createSessionForAutomationRequest(relayURL: relayURL)) + ) + case "collaboration.session.join": + guard let code = params["session_code"] as? String, + let token = params["invite_token"] as? String else { + return v2Result( + id: id, + .err( + code: "invalid_params", + message: "collaboration.session.join requires session_code and invite_token", + data: nil + ) + ) + } + let relayURL = params["relay_url"] as? String + return v2Result( + id: id, + .ok(CollaborationRuntime.shared.joinSessionForAutomationRequest( + relayURL: relayURL, + code: code, + token: token + )) + ) + case "collaboration.session.leave": + return v2Ok(id: id, result: CollaborationRuntime.shared.leaveSessionForAutomation()) // mobile.host.status/mobile.workspace.list/mobile.terminal.* (+terminal.* // aliases), mobile.terminal.paste/terminal.paste, and chat.sessions.dump // handled by ControlCommandCoordinator (bodies stay; shared with @@ -1946,6 +2014,10 @@ class TerminalController { var methods: [String] = [ "system.ping", "system.capabilities", + "collaboration.status", + "collaboration.session.create", + "collaboration.session.join", + "collaboration.session.leave", "system.identify", "system.tree", "sidebar.custom.open", diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 7f824d8bdf5d..055c33c75940 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -299,6 +299,8 @@ C12985000000000000000004 /* CMUXCLISentryTelemetryRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12985000000000000000003 /* CMUXCLISentryTelemetryRegressionTests.swift */; }; C0DE64950000000000000005 /* CMUXCLISessionsListTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE64950000000000000006 /* CMUXCLISessionsListTests.swift */; }; C0DE64950000000000000003 /* CMUXCLITestAssertions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE64950000000000000004 /* CMUXCLITestAssertions.swift */; }; + C011AB010000000000000001 /* CmuxCollaboration in Frameworks */ = {isa = PBXBuildFile; productRef = C011AB010000000000000002 /* CmuxCollaboration */; }; + C011AB010000000000000004 /* CmuxCollaboration in Frameworks */ = {isa = PBXBuildFile; productRef = C011AB010000000000000002 /* CmuxCollaboration */; }; 5E55500000000000000000E3 /* CmuxCommandPalette in Frameworks */ = {isa = PBXBuildFile; productRef = 5E55500000000000000000E2 /* CmuxCommandPalette */; }; 5E55500000000000000000E4 /* CmuxCommandPalette in Frameworks */ = {isa = PBXBuildFile; productRef = 5E55500000000000000000E2 /* CmuxCommandPalette */; }; A5001650 /* CmuxConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001651 /* CmuxConfig.swift */; }; @@ -400,6 +402,7 @@ A9E040000000000000000002 /* CodexAppServerSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E040000000000000000001 /* CodexAppServerSessionTests.swift */; }; C0DECAFE0000000000000001 /* CodexTeamsApprovalBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DECAFE0000000000000002 /* CodexTeamsApprovalBridge.swift */; }; C0DECAFE0000000000000003 /* CodexTeamsApprovalBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DECAFE0000000000000002 /* CodexTeamsApprovalBridge.swift */; }; + C011AB020000000000000001 /* CollaborationRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = C011AB020000000000000002 /* CollaborationRuntime.swift */; }; C4041001000000000000001B /* CommandClickFileOpenRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4041001000000000000001A /* CommandClickFileOpenRouter.swift */; }; C0DEC0DE000000000000F302 /* CommandPaletteEmojiTitleSearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEC0DE000000000000F301 /* CommandPaletteEmojiTitleSearchTests.swift */; }; C0DE32470000000000000005 /* CommandPaletteIdentifierClipboardUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE32470000000000000006 /* CommandPaletteIdentifierClipboardUITests.swift */; }; @@ -1613,6 +1616,7 @@ A9E01000000000000000000E /* CodexAppServerSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CodexAppServerSession.swift; sourceTree = ""; }; A9E040000000000000000001 /* CodexAppServerSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexAppServerSessionTests.swift; sourceTree = ""; }; C0DECAFE0000000000000002 /* CodexTeamsApprovalBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexTeamsApprovalBridge.swift; sourceTree = ""; }; + C011AB020000000000000002 /* CollaborationRuntime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollaborationRuntime.swift; sourceTree = ""; }; C4041001000000000000001A /* CommandClickFileOpenRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandClickFileOpenRouter.swift; sourceTree = ""; }; C0DEC0DE000000000000F301 /* CommandPaletteEmojiTitleSearchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteEmojiTitleSearchTests.swift; sourceTree = ""; }; C0DE32470000000000000006 /* CommandPaletteIdentifierClipboardUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteIdentifierClipboardUITests.swift; sourceTree = ""; }; @@ -2413,6 +2417,7 @@ E3B7A30000000000000000C3 /* CmuxBrowser in Frameworks */, CA52A003CA52A003CA52A003 /* CmuxCanvas in Frameworks */, CA53A003CA53A003CA53A003 /* CmuxCanvasUI in Frameworks */, + C011AB010000000000000001 /* CmuxCollaboration in Frameworks */, 5E55500000000000000000E3 /* CmuxCommandPalette in Frameworks */, C750100000000000000000A3 /* CmuxControlSocket in Frameworks */, CC0DE00000000000000000A3 /* CmuxCore in Frameworks */, @@ -2490,6 +2495,7 @@ C9A2B00000000000000000C3 /* CmuxAppKitSupportUI in Frameworks */, D7032A060000000000000001 /* CmuxBrowser in Frameworks */, CA52A007CA52A007CA52A007 /* CmuxCanvas in Frameworks */, + C011AB010000000000000004 /* CmuxCollaboration in Frameworks */, 5E55500000000000000000E4 /* CmuxCommandPalette in Frameworks */, CC0DE00000000000000000A4 /* CmuxCore in Frameworks */, 5E2701040000000000000004 /* CmuxFoundation in Frameworks */, @@ -2674,6 +2680,7 @@ A5001011 /* cmuxApp.swift */, CA5CADF00000000000000001 /* CanvasDebugMenuButtons.swift */, C46790000000000000000004 /* CLIForwardingLaunchRouter.swift */, + C011AB020000000000000002 /* CollaborationRuntime.swift */, C46790000000000000000006 /* RosettaNativeRelaunch.swift */, 5E2701020000000000000002 /* SentryEventScrubber.swift */, D9CCF001D9CCF001D9CCF001 /* SettingsLazyLoadHelpers.swift */, @@ -3777,6 +3784,7 @@ A5B00002A1B2C3D4E5F60718 /* CMUXAgentLaunch */, CA52A005CA52A005CA52A005 /* CmuxCanvas */, CA53A005CA53A005CA53A005 /* CmuxCanvasUI */, + C011AB010000000000000002 /* CmuxCollaboration */, C5A1FED000000000000000C3 /* CmuxSwiftRender */, C5A1FED100000000000000D3 /* CmuxSwiftRenderUI */, C5A1FED200000000000000E3 /* CmuxSidebarInterpreterClient */, @@ -3893,6 +3901,7 @@ C9A2B00000000000000000C2 /* CmuxAppKitSupportUI */, D7032A060000000000000002 /* CmuxBrowser */, 5E55500000000000000000E2 /* CmuxCommandPalette */, + C011AB010000000000000002 /* CmuxCollaboration */, C0DE4A000000000000000004 /* CmuxSidebarProviderKit */, CC0DE00000000000000000A2 /* CmuxCore */, A5001262 /* Bonsplit */, @@ -3946,6 +3955,7 @@ 40B63BB4A170F0BD2D1DEFD1 /* XCLocalSwiftPackageReference "CMUXAuthCore" */, 53750001A0B1C2D3E4F50001 /* XCLocalSwiftPackageReference "CmuxAuthRuntime" */, AC4A6E7C4A700001AC4A0003 /* XCLocalSwiftPackageReference "CmuxAgentChat" */, + C011AB010000000000000003 /* XCLocalSwiftPackageReference "CmuxCollaboration" */, C0DE45000000000000000003 /* XCLocalSwiftPackageReference "CmuxExtensionKit" */, C0DE4A000000000000000003 /* XCLocalSwiftPackageReference "CmuxSidebarProviderKit" */, C0DE49000000000000000003 /* XCLocalSwiftPackageReference "CmuxExtensionSidebarExamples" */, @@ -4339,6 +4349,7 @@ E30750000000000000000004 /* CmuxWorkspaceDefinition.swift in Sources */, A9F200000000000000000016 /* CodexAppServerQueuedInput.swift in Sources */, A9E02000000000000000000E /* CodexAppServerSession.swift in Sources */, + C011AB020000000000000001 /* CollaborationRuntime.swift in Sources */, C4041001000000000000001B /* CommandClickFileOpenRouter.swift in Sources */, C0DEFF200000000000000001 /* CommandPaletteOverlay.swift in Sources */, C0DEF4110000000000000001 /* CommandPaletteSettingsToggle.swift in Sources */, @@ -5729,6 +5740,10 @@ isa = XCLocalSwiftPackageReference; relativePath = Packages/Shared/CmuxAgentChat; }; + C011AB010000000000000003 /* XCLocalSwiftPackageReference "CmuxCollaboration" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/Shared/CmuxCollaboration; + }; C0DE45000000000000000003 /* XCLocalSwiftPackageReference "CmuxExtensionKit" */ = { isa = XCLocalSwiftPackageReference; relativePath = Packages/macOS/CmuxExtensionKit; @@ -5902,6 +5917,11 @@ package = AC4A6E7C4A700001AC4A0003 /* XCLocalSwiftPackageReference "CmuxAgentChat" */; productName = CmuxAgentChat; }; + C011AB010000000000000002 /* CmuxCollaboration */ = { + isa = XCSwiftPackageProductDependency; + package = C011AB010000000000000003 /* XCLocalSwiftPackageReference "CmuxCollaboration" */; + productName = CmuxCollaboration; + }; C0DE45000000000000000002 /* CmuxExtensionKit */ = { isa = XCSwiftPackageProductDependency; package = C0DE45000000000000000003 /* XCLocalSwiftPackageReference "CmuxExtensionKit" */; diff --git a/cmux.xcworkspace/contents.xcworkspacedata b/cmux.xcworkspace/contents.xcworkspacedata index 55a0ea44b544..d13b3ab44df4 100644 --- a/cmux.xcworkspace/contents.xcworkspacedata +++ b/cmux.xcworkspace/contents.xcworkspacedata @@ -19,6 +19,9 @@ + + diff --git a/docs/multiplayer-phase1-design.md b/docs/multiplayer-phase1-design.md new file mode 100644 index 000000000000..6c455e21568d --- /dev/null +++ b/docs/multiplayer-phase1-design.md @@ -0,0 +1,205 @@ +# cmux Multiplayer Phase 1: Collaborative Editing and Presence + +## Status + +This document is the implementation contract for Phase 1. It deliberately covers only actively shared file buffers and ephemeral cursor/selection presence. It does not design whole-repository sync, git automation, peer-to-peer transport, terminal sharing, real accounts/ACLs, or polished conflict UX. + +## Repository Findings + +cmux is a shipped macOS application whose primary client is Swift 6 with SwiftUI, AppKit, and Observation. The macOS app still has a large app target under `Sources/`, plus a growing Swift Package Manager graph under `Packages/macOS/`, `Packages/Shared/`, and `Packages/iOS/`. The build is Xcode-driven through `cmux.xcodeproj`/`cmux.xcworkspace`; local debug validation uses `./scripts/reload.sh --tag `. JavaScript/TypeScript exists for the web app, embedded webviews, and Cloudflare Workers. There is also Zig for Ghostty, Go for the remote daemon, Rust for native FFI, and Python for integration tests. + +cmux already has an editable file-content surface: `FilePreviewTextEditor` and `SavingTextView` in `Sources/Panels/FilePreviewTextEditor.swift`, hosted by `FilePreviewPanel` and `MarkdownPanel`. This is a production plain-text `NSTextView` editor for file preview panels, not Monaco, CodeMirror, Ace, LSP, syntax highlighting, or a full IDE editor. Phase 1 therefore wires collaboration to the existing plain-text file editor and does not build cmux's first full code editor. + +cmux already has a Unix-domain socket and CLI API. The preferred command surface is v2 line-delimited JSON RPC through `CmuxControlSocket` and the CLI in `CLI/cmux.swift`; `cmux rpc ` already exposes new v2 methods. Phase 1 must not add a conflicting app-control transport. The collaboration relay is a separate outward WebSocket service used only for multiplayer document traffic, while local configuration/control should fit the existing socket/CLI conventions later. + +cmux also has a Cloudflare Workers presence service in `workers/presence/`, backed by Durable Objects. That service is team/device presence, not document collaboration. Phase 1 follows its deployment style, but keeps document collaboration on a separate minimal relay so CRDT payloads remain opaque and the relay does not inherit device-registry semantics. + +## CRDT Selection + +### Options Considered + +Automerge Swift (`automerge/automerge-swift`) is the strongest native fit. It wraps the maintained Automerge Rust core for Apple platforms, is MIT licensed, supports Swift Package Manager, exposes Automerge text/list/map CRDTs, and is designed for offline-first sync. It has credible multi-document support when paired with Automerge Repo Swift, but Phase 1 does not need Automerge Repo's full storage/network stack because cmux needs a dumb relay protocol and app-specific disk reconciliation. Integration cost is moderate: add an SPM dependency, wrap it in a cmux package, define binary update messages, and map `NSTextView` edits to Automerge text changes. + +Yjs is extremely mature in JavaScript, MIT licensed, and has excellent editor ecosystem support. In this repo it would either require a JavaScript runtime/sidecar or YSwift/Yrs bindings. YSwift exists but is explicitly work-in-progress, brings a Rust/XCFramework binding path similar to Automerge, and naturally pairs with Yjs-specific WebSocket providers that are more opinionated than this relay-only design. Choosing Yjs would bias cmux toward a JS collaboration stack despite the host editor being Swift/AppKit. + +Automerge via a Node or Rust sidecar would work technically, but adds process management, IPC, packaging, crash recovery, and socket security concerns. It also duplicates the app's existing native state-management direction. This is unjustified for Phase 1 because a native Swift binding exists. + +A small in-repo text CRDT is possible for tests and prototyping, but it is a long-term maintenance burden. It would need years of edge-case hardening to match Automerge/Yjs, especially around large files, undo metadata, and cross-language interoperability. + +### Decision + +Use Automerge Swift as the intended CRDT library for the production Phase 1 client module. The cmux-facing API is a thin `CmuxCollaboration` package so the rest of the app never depends directly on Automerge types. The relay treats Automerge binary changes and snapshots as opaque base64 strings. + +Undo/redo remains local-editor undo for Phase 1. Automerge preserves causal operations, but collaborative undo semantics are not polished here; remote changes clear or segment local undo groups rather than promising Google Docs-grade shared undo. + +The first implementation may include a very small pure-Swift text CRDT test harness only where it keeps the package testable before the Automerge dependency is fully wired. That harness is not the selected production CRDT and must not leak into the public app integration contract. + +## Document Model + +Each actively co-edited file is one CRDT document. A document identity is: + +- `sessionID` +- `repositoryID`, a user-visible stable label for the clone, normally the workspace root path normalized locally +- `filePath`, the repository-relative path + +Absolute paths are never broadcast. Peers with different local clone roots map the same repository-relative path to their own filesystem. + +A document is created when a peer explicitly shares a file in a multiplayer session. The first sharing peer reads its local file from disk, initializes an Automerge text object with that content, records the file's last observed metadata and content hash, then broadcasts a `document.snapshot` frame. + +A second peer joins when it opens the same relative file in the same session and opts into collaboration. It does not trust its local disk content as current state. It requests a snapshot and replaces the in-memory shared buffer with the received CRDT state. The peer's on-screen editor updates from the CRDT-resolved text. + +A document is torn down locally when the peer closes the shared file or leaves the session. If this peer still has the document open only remotely, nothing is written for that peer. If this peer is closing its last local view of the document, the client writes the current CRDT-resolved text to that peer's real on-disk file. + +Disk reconciliation is deterministic: + +1. When opening, record `baseDiskHash` and file metadata. +2. While the CRDT document is live, watch or poll the file metadata at save/close boundaries. +3. On close, compute `currentDiskHash`. +4. If `currentDiskHash == baseDiskHash` or `currentDiskHash == lastWrittenHash`, write the CRDT text atomically and record the new hash. +5. If `currentDiskHash` differs, an out-of-band edit occurred, such as an external editor save or `git checkout`. Phase 1 does not merge that external file with the CRDT. It writes the CRDT text to a sibling conflict file named `.cmux-collab-conflict-` and leaves the externally changed original untouched. The user sees a clear conflict state. This avoids silent data loss. + +Files not explicitly opened and shared are never read, watched, updated, or written by this system. + +## Relay Protocol + +Transport is WebSocket. Every peer connects outward to the relay. No peer accepts inbound connections. WebSocket is chosen because it is already the natural transport for Cloudflare Durable Objects, supports bidirectional low-latency messages, and avoids introducing a custom TCP framing protocol. + +The relay stores only active sessions and connected peers in memory: + +- `sessionID -> { tokenHash, peers }` +- `peerID -> websocket, display metadata, lastHeartbeatAt` + +The relay does not parse, order, transform, or merge CRDT payloads. It validates envelope size and session membership, then forwards frames to other peers in the same session. + +### Session Messages + +Client to relay: + +```json +{ "type": "session.create", "peer": { "peerID": "...", "displayName": "...", "color": "#7A5CFF" } } +{ "type": "session.join", "sessionCode": "ABCD-1234", "token": "...", "peer": { "peerID": "...", "displayName": "...", "color": "#7A5CFF" } } +{ "type": "peer.heartbeat" } +{ "type": "document.update", "documentID": "...", "updateID": "...", "payloadBase64": "..." } +{ "type": "document.snapshot.request", "documentID": "...", "requestID": "..." } +{ "type": "document.snapshot", "documentID": "...", "requestID": "...", "payloadBase64": "...", "textHash": "..." } +{ "type": "presence.update", "activeFile": "Sources/Foo.swift", "cursor": 128, "selection": { "anchor": 128, "head": 142 } } +``` + +Relay to client: + +```json +{ "type": "session.created", "sessionID": "...", "sessionCode": "ABCD-1234", "token": "..." } +{ "type": "session.joined", "sessionID": "...", "peers": [...] } +{ "type": "peer.joined", "peer": {...} } +{ "type": "peer.left", "peerID": "...", "reason": "disconnect|timeout|leave" } +{ "type": "relay.error", "code": "invalid_token|session_not_found|too_large|rate_limited", "message": "..." } +``` + +Document and presence frames are forwarded unchanged except for relay-added `fromPeerID` and `receivedAt`. + +### Catch-Up Strategy + +Reconnect and join use full document state snapshots, not buffered replay. The relay is stateless with respect to document content and therefore cannot replay missed CRDT updates. On reconnect, the peer rejoins the session, announces the document IDs it has open, and sends `document.snapshot.request` for each live document. Existing peers respond with full snapshots. The reconnecting peer applies the first valid snapshot whose `documentID` matches and whose text hash is well-formed. Later duplicate snapshots are merged through Automerge if they represent the same document; if they decode to different histories but same text, the client keeps the first and logs a warning. + +If no peer answers a snapshot request within five seconds, the document enters `resyncUnavailable`. Local editing may continue offline against the peer's current CRDT state, but the UI shows that collaboration is disconnected. The next successful snapshot/update merge clears the state. + +## Bootstrapping a New Peer + +When peer C joins a session where A and B are already editing file X, C's disk content is only its clone's current file. C must not broadcast that file as an update. It sends `document.snapshot.request` for X. Existing peers deterministically decide who answers: + +1. The peer with the lexicographically smallest `peerID` among peers that have X open sends the snapshot after 50 ms. +2. Other peers schedule responses after 250 ms plus a peerID-derived jitter. +3. If C receives a valid snapshot before its timer fires, each later peer cancels its response. + +If multiple snapshots race, C applies the first valid snapshot and then merges any later valid snapshot as a normal CRDT merge. Automerge convergence makes duplicate snapshots safe. C then displays the resolved text and records its local disk baseline for close-time reconciliation. + +## Presence Protocol + +Presence is ephemeral and not stored in the CRDT document. It includes: + +- `peerID` +- display name +- color +- active repository-relative file path +- cursor UTF-16 offset for `NSTextView` compatibility +- selection anchor/head UTF-16 offsets +- monotonic sequence number +- sent timestamp + +Presence is cleared on disconnect, session leave, or heartbeat timeout. It is never written to disk and never included in Automerge snapshots. + +Rate limits: + +- Cursor/selection moves: at most 20 updates per second, coalesced to the latest state. +- Active-file changes: immediate. +- Display metadata changes: at most 1 update per second. +- Document CRDT updates are not delayed by presence throttling. Presence and document messages share the WebSocket but have independent queues; document updates have priority when the send buffer is backed up. + +## Client Module Interface + +The Swift package exposes a UI-framework-agnostic actor and value model: + +```swift +public actor CollaborationSession { + public func connect(invite: CollaborationInvite) async throws + public func disconnect() async + public func open(file: SharedFileDescriptor) async throws -> CollaborationDocumentSnapshot + public func close(file: SharedFileDescriptor) async throws -> DiskReconciliationResult + public func applyLocalEdit(file: SharedFileDescriptor, range: Range, replacement: String) async throws + public func setLocalSelection(file: SharedFileDescriptor, cursor: Int, selection: Range?) async + public var events: AsyncStream { get } +} +``` + +The package has no SwiftUI or AppKit dependency. AppKit-specific range mapping and rendering remote cursors live in a small macOS adapter around `SavingTextView`. + +## Failure Modes + +Relay unreachable at session start: session creation/join fails fast with `relayUnavailable`. No local file is changed. The file preview editor continues as a normal local editor. + +Relay drops mid-session: the client marks the session `disconnected`, clears remote presence after the heartbeat timeout, and keeps local CRDT editing available. It queues local CRDT changes in memory. On reconnect it rejoins and requests full snapshots for every open shared document, then merges queued local changes. If the app quits before reconnect, unsaved live CRDT state is reconciled to disk through the normal close/termination path. + +Local file changes on disk while a CRDT doc is live: the CRDT buffer remains authoritative for the shared session. On close/save reconciliation, if the disk hash changed outside cmux, cmux writes the CRDT text to a conflict sibling and leaves the changed original untouched. This is the deterministic Phase 1 answer; it does not attempt a three-way merge. + +Two peers both try to be first to share the same file: both create CRDT docs from their local file content and broadcast snapshots. The lower `peerID` snapshot is treated as the canonical bootstrap if histories are unrelated. The higher `peerID` peer discards its just-created doc and joins the canonical snapshot, then reapplies any local edits it made after the share attempt. This can briefly replace the higher peer's view. This is accepted in Phase 1 to avoid split-brain document IDs. + +A peer closes their laptop for an hour and reopens: other peers see `peer.left` after heartbeat timeout and continue. The sleeping peer reconnects, clears stale remote presence, requests full snapshots for all open docs, merges any local offline edits, and then resumes live updates. If its local file was externally changed while asleep, the close-time conflict-file rule still applies. + +Relay restarts: all sessions die because Phase 1 relay state is in memory only. Clients detect socket close, mark `relayRestarted`, and require the user or CLI to create/join a new session. Open local CRDT buffers remain usable and can be saved locally. + +Malformed or oversized relay frame: the relay closes that peer's socket with an error and broadcasts `peer.left`. Other peers continue. + +## Explicit Non-Guarantees + +Phase 1 does not guarantee whole-repository consistency, branch consistency, or that every collaborator is on the same commit. + +Phase 1 does not guarantee external disk edits are merged. It prevents silent overwrite by writing a conflict sibling when an out-of-band change is detected. + +Phase 1 does not guarantee a peer will never see a brief revert during simultaneous first-share or reconnect snapshot races. + +Phase 1 does not provide durable relay persistence. Relay restart kills active sessions. + +Phase 1 does not provide authentication beyond an invite token. Anyone with the token can join. + +Phase 1 does not share terminal I/O, shell state, git state, diagnostics, or agent sessions. + +## Test Plan + +Automated tests must cover: + +- Concurrent overlapping text edits applied in different orders across three replicas converge to identical content. +- Offline/reconnect with independent edits on both sides converges after full snapshot exchange. +- Last-local-close writes CRDT-resolved text to disk when the disk baseline is unchanged. +- Out-of-band disk modification produces a conflict sibling and leaves the original file untouched. +- Relay unreachable and relay disconnect produce explicit client states rather than hanging. + +Manual validation before trusting real sessions: + +- Two tagged cmux app instances against two separate local clones, sharing one file through the relay. +- Remote cursor rendering in the plain-text file preview editor. +- Save/close/reopen behavior on both clones. +- Relay restart and app sleep/wake. + +## Phase 2 and Later + +Phase 2 can add whole-repository sync for explicitly saved but not co-edited files, richer conflict UI, durable relay/session persistence, stronger authentication/authorization, NAT traversal or P2P transport if relay-only becomes insufficient, and terminal sharing as a separate protocol. Git operations remain manual unless a later design explicitly changes that rule. diff --git a/workers/collaboration/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite b/workers/collaboration/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..0b58f0dca7d6ce95907cb0b322dc4802f04688c0 GIT binary patch literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AVoG{WYDXN;00+HAlr;ljiVtj n8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O6ovo**AfQj literal 0 HcmV?d00001 diff --git a/workers/collaboration/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm b/workers/collaboration/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm new file mode 100644 index 0000000000000000000000000000000000000000..20419c74b7c0ab15384505694faad4779c2167ff GIT binary patch literal 32768 zcmeI)F$%&k6b9fZIJoK3_7ZNc9>I&bxCp&SLAtuSiC(~|Q;(qb34yL|LcbqEl1F$C zz5}FsZc@%HrPX}6#A=t9?_i35r45Kmeql1pplc2U0hzCu`#hEF)1fC zJ~=Jk(Z?~!7tUaE4svx2aa9O$bnVa4CR6Vsc4-QG8~KLWpZbh(bV+r>|pB zq=L6=qz+U;adJj#N@-4NN_n1Ec7`-=cChqA?_WFC{sa{|``@!sUdscS{2tWV=5P$##AOHafKmY;|fPfbG zW~DQWi$?EVBAYGcABFxd7f#6$&geL`<#9)F5>BzgX>^8`rf3ky>I=Dhf`8y1ee~R2 z$2+7lKU^6uIFqeYMa4?BI7ls@n(55koYCXM>g8I0_j1h|t5~kxsU>(rJhTwiU9nyL zB*?4Q-O^_xoyq5o-t+6Semm+!VN0GUKZyN~Y%6~}W!`?E@ddOL1%Ew1^>?KH(=PYl z%?71U()m@X!j0gob^ZPSirH6>cR*Bv l00bZa0SG_<0uX=z1Rwwb2teSU3ry=D0G_vW7?h?kG(uRNl z1Rwwb2tWV=5P$##AOHaf&$xQ_z4v!G-Ut) literal 0 HcmV?d00001 diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/4a9cc69e531b0224ce740caab4560dbc40983daecb04523b77aa8be3b26ce5dc.sqlite b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/4a9cc69e531b0224ce740caab4560dbc40983daecb04523b77aa8be3b26ce5dc.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..c6d0e52467396e6bd988f5c8231e2c86c044b5b4 GIT binary patch literal 8192 zcmeI#u?oU45C-5&6kL?Lh@&?us7qbktQthDqA^2tDHSUetb!EW1Sj9uC$LpV!Cn6& z$t6b;$hT#=aHAyT-SHF$3ENaADXGUyMDpF!^bC=X`S(0a+4@^ZYG1rkx=UC$N*e+K z5P$##AOHafKmY;|fB*y_@XrEgQK;IsxK@&2vk!egjtsWv_y88NG`au) literal 0 HcmV?d00001 diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/8228cb14196d0a45f0d8293c7e8326c58467086c2fab35fbb14f2fa6d2fc61bd.sqlite b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/8228cb14196d0a45f0d8293c7e8326c58467086c2fab35fbb14f2fa6d2fc61bd.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..ba2c2da6411ce00ecb6e0290cfc3e5b1b692364c GIT binary patch literal 8192 zcmeI#JqyAx5C-5&6kHTt#L*iS)TJPfR*52hpf!VaDV4TRuogpx6`U`B;QE=Dy zNOH-M1oCXzZWdwca}Xzyo3cSQl9GnZL?qumOV1G5SbWd(l&`;qq~2v$$##kAjk1P- z00bZa0SG_<0uX=z1Rwwb2>i3aS(F-vA+Gh*Jsf?j>?U|W!ao8aXC^%vNxqu{RZ zk>rvi3FO(bSucXb=Rl!+r^VTgtkqUGUw`vEmGA$|kGDer0uX=z1Rwwb a2tWV=5P$##eode#a#VM^Q>#7b8T|(q6*P1J literal 0 HcmV?d00001 diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/99b843e894d821ea0d842307990cf1ba5ba0f3012e307b9758f712787e9f860b.sqlite b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/99b843e894d821ea0d842307990cf1ba5ba0f3012e307b9758f712787e9f860b.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..7a0cb2d6cc7f9f97596c4e5316509dd35e4c11dd GIT binary patch literal 8192 zcmeI#JqyAx5C-5&6kHTt#L*iS)TM%}Rf32gXw0B4rD6+(R-qK!1SkKmzra=<3-0;1lvk2tt!$&% z*rU1PoHN`Cjyzo&HBp*Zdb+Unj%RAu;p|2>YHQl>zxjizkALRJ+aUk}2tWV=5P$## ZAOHafKmY>2CQudyYU;&k-<|aPgC`cyG?M@T literal 0 HcmV?d00001 diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/a9e8393d05838637ddaf02a111008625c2d5563786ecc264864a6ddff28587ea.sqlite b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/a9e8393d05838637ddaf02a111008625c2d5563786ecc264864a6ddff28587ea.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..0b58f0dca7d6ce95907cb0b322dc4802f04688c0 GIT binary patch literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AVoG{WYDXN;00+HAlr;ljiVtj n8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O6ovo**AfQj literal 0 HcmV?d00001 diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/a9e8393d05838637ddaf02a111008625c2d5563786ecc264864a6ddff28587ea.sqlite-shm b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/a9e8393d05838637ddaf02a111008625c2d5563786ecc264864a6ddff28587ea.sqlite-shm new file mode 100644 index 0000000000000000000000000000000000000000..cf0f9b4326a5655f43366ef24fd102e1419e52cd GIT binary patch literal 32768 zcmeI)I}U+C6b8`oel(O8pjB9iM5V9+l~Qa)t=7_M?Saw?XtdlB5z*;{^CkDc$z<}+ z%vr$g9D6CJ7F9^8-Hcw2TAcUO)_S>`j5eo1e>R+7y4T(Ku`jWX5RK;_OOr7O3bD=iSwm=pKt0=HXLtVBKN4SvxYEGfYqYf^M8|@J*6pDts!W|K#+X|VnP?r&%FXX5RYJOc4y1%+! z6Z0G+)`d+G*rjXRwV-GU1{(aqHc_Xwtrzwgg|4;gr4l`HpP$SZXde&n>=AkH>3jAG z#Wk^zFAHl&009ILKmY**5I_I{1Q0+V(*zc)PGx27&gQ1FwtZC9_0v}qR4Lus3 z8vjMVfHOtK&MW+JzktJ4Ao~U6hJgSA2q1s}0tg_000IagfWUts(Cf%4EGkk5wdunB zfsRn!-cVxeOfSzTVq#sMm>HeckwgVPtuQ~k_plM^wM2D&WA#j^RI~*29gTZ*5o~M; z)c(tU0s9``nd}!h@4ZlPC^ot^PX<-j6-B-n2q1s}0tg_000IagfB*srAn^ADB3Ua2 z>!ViOFpaqJ)fFqw?Xh}#!m+MEZE@~wp}#nPCR!6St&yo85x1KNH zTzWatFCae{2q1s}0tg_000IagfB*srNP(lyoWhb4b#TjU#lAt){k4AGZ`bXU^Q<4L z_sKoq)$r&0|N5Vl@fTpffPGJ&mFyQd-TTm-(k~57KVM){aZU20L;wK<5I_I{1Q0*~ z0R#|00D%k=$d?Zo$X@<L#T3#Tcj=0A5Y~D{^AcL+rOF;ku1Q0*~0R#|0 z009ILKwwT_HZQP!@k^f!r{x9u^G9#oyf%J}yuch7GXxMo009ILKmY**5I_I{1Tsh< znHM-!;~wzIb*1G6iYmwMzkf1$oxDH>U2&Fz00IagfB*srAb74X)c-zDv~als5zf zAOHafKmY;|fB*y_009U<;GYF9qS7)9acidj{uFp#6rRK5$xj0B5PN4o`c#rrN10l& zHYW?kAI@+mJg~LWYOWk!JJ!;4c5JI%kBb{wtF7+7{^l9gvw!Bt+aUk}2tWV=5P$## ZAOHafKmY>2CQub6YP7U}9o$P*7lCU|@t|AVoG{WYDXN;00+HAlr;ljiVtj n8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O6ovo**AfQj literal 0 HcmV?d00001 diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/metadata.sqlite-shm b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/metadata.sqlite-shm new file mode 100644 index 0000000000000000000000000000000000000000..e43b9f8570715c9401131950385266ba96fccf11 GIT binary patch literal 32768 zcmeI)KWYL|5C`CKqtX1?V7VfAgMh8au{CADtLPbW2eH`FOUM>_fM9LjTT-OmN+jP8 z=IyY&-GT1_Q#J3A^O~v`F@8(WBGvlX%y-}Q{%d)u7n9TUbGG?dZO@0QaUla9M;Pe;F$wR@@_6s}PWedkv=bBh zLrXt3ghEq7ZW%B}hm(z8W?g!36}twuToI?jmFJh-o!Wo3X3^m0O>Lb8TXMR4_8SFB z5l=@Vp_C+vdn3PJ?qhCopC4bY{dz0+SJD1gH4<8OyuC(T3D*`v;-Daa00IagfB*sr zAbL_&Xx24wgxlD5=TfVqZ z+SZzD*{~%iWjAbVT`kbdpY#9Z1)sm?=he*{I$Pzg#;+)0%&ZF&MF0T=5I_I{1Q0*~ z0R#|0Umh5 zdUAmeH>Niqyw-b^T%banDgp=~fB*srAbKgRD;L-~c;`t;Ov{rC{Mb?d z?){lHQF4LcEI1=U009ILKmY**5I_I{1Q0-=Lcp(Fz0YdF!rfjsF)2%yKgj1Q0*~0R#|0009ILKmY**JObrh zz)0G#A*gnA1Q20R#|0009ILKmY**5I`U(0>0$}&68iI z#H2jAz`)n*>V?POc9RPPWw{v#0tg_000IagfB*srAb0|Gc;gKS_ kKv0&OaUg&I0tg_000IagfB*srAm9=3D;GGw;>fGiPZJ}+WB>pF literal 0 HcmV?d00001 diff --git a/workers/collaboration/README.md b/workers/collaboration/README.md new file mode 100644 index 000000000000..807b667f44a8 --- /dev/null +++ b/workers/collaboration/README.md @@ -0,0 +1,74 @@ +# cmux Collaboration Relay + +This Worker is the Phase 1 cmux Multiplayer relay. It is deliberately small: it creates invite-token gated sessions, accepts WebSocket peers, forwards opaque collaboration frames, and drops peers that stop heartbeating. + +## Local Development + +```bash +bun install +bun run typecheck +bun test +bun run dev +``` + +The local relay defaults to `http://localhost:8787`. In cmux, open a plain-text file preview, click **Collaborate**, enter that relay URL, and choose **Create Session**. A second cmux instance can join with the displayed session code and invite token. + +## Deploy + +```bash +bun run check +bun run deploy +``` + +`wrangler.toml` binds the `COLLABORATION_SESSIONS` Durable Object namespace. Production deployments should expose the Worker behind HTTPS; the macOS client converts `https://` relay URLs to `wss://` for WebSocket joins. + +## HTTP API + +### `GET /healthz` + +Returns a static health response: + +```json +{ "ok": true, "service": "cmux-collaboration" } +``` + +### `POST /v1/collaboration/sessions` + +Creates an in-memory relay session and returns: + +```json +{ + "sessionID": "ABCD-1234", + "sessionCode": "ABCD-1234", + "token": "invite-token" +} +``` + +### `GET /v1/collaboration/sessions/:sessionCode/connect` + +Upgrades to WebSocket. Required query parameters: + +- `token`: invite token returned by session creation. +- `peerID`: stable local peer ID. +- `displayName`: peer display name. +- `color`: presence color. + +## Forwarded Frames + +The relay treats non-heartbeat frames as opaque JSON envelopes with a string `type` field. It forwards them to every other peer with `fromPeerID` and `receivedAt` added. Phase 1 clients currently use: + +- `document.update` +- `document.snapshot.request` +- `document.snapshot` +- `presence.update` + +`peer.heartbeat` updates liveness and is not forwarded. + +## Phase 1 Non-Guarantees + +- No repository-wide file sync. +- No Git automation. +- No account auth or ACLs beyond the invite token. +- No NAT traversal or direct peer-to-peer transport. +- No terminal sharing. +- Durable Object active memory is the session state; document content is never persisted by the relay. diff --git a/workers/collaboration/bun.lock b/workers/collaboration/bun.lock new file mode 100644 index 000000000000..aa5dc1b4ef7f --- /dev/null +++ b/workers/collaboration/bun.lock @@ -0,0 +1,223 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "cmux-collaboration-worker", + "devDependencies": { + "@cloudflare/workers-types": "^4.20260525.0", + "@types/bun": "^1.3.14", + "@typescript/native-preview": "7.0.0-dev.20260616.1", + "typescript": "^5.8.0", + "wrangler": "^4.20.0", + }, + }, + }, + "packages": { + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="], + + "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], + + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260625.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-naCfBv0WnnTQIQPTniqMoUlklOIFjrAcSn1X+IAOhY8aFLF/xGYtFjs1eEE8sFib3ZuChGGpU23FFORVczqr0A=="], + + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260625.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jmH6zjp6Wrux46+qtFwDwrj+vd7s5bdwEqeGvdnwE0a4IEeAhKs0L42HQOyID+g5lkrHq9m55+AbhtmRAm63Pw=="], + + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260625.1", "", { "os": "linux", "cpu": "x64" }, "sha512-MiQkpA/dX8d83Zp64pzHUKfd6ca4cvwxnNobSP6CnXvfESvnNI9pfa+nfwnParla36sPmnYntNkjR7NjRuDeKQ=="], + + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260625.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-LxxW7Qv60Xvv37+w6gUSDpYZziyqMy+cZWd9IvSA5ehVgKAxmzEaYPMiSZlxk32nbIWL9u/tfjXYCOKJ4Lo+XQ=="], + + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260625.1", "", { "os": "win32", "cpu": "x64" }, "sha512-LH6iIX1HHaTwVKV5VokDxxUErXJzQoNZFRwVm7Vx/3fB/ApcTcRCUaMqcxI4as94jEUqg+pmX5czOndiveohow=="], + + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260630.1", "", {}, "sha512-yl+c9vwvko9UZ0frmtsHuwOh3BRHvNjLrfelAp5Akpqe1+Ho1UWekr3nmjJ1D64CH0Yb0K0oRMV4i7npOFzsog=="], + + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], + + "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], + + "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], + + "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], + + "@speed-highlight/core": ["@speed-highlight/core@1.2.17", "", {}, "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], + + "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260616.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260616.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260616.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260616.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260616.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260616.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260616.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260616.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-+AuZUl7nkLPXL1rwsyZZF7iasu0HkrL5O6aWwUC0cObD5EvNgxz3hXbBhsSvuJ8tb2JRpVwFsE0BHPcYVe5GkA=="], + + "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260616.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9SwegwwE7fYutnZJjTi2PeUXhKGFg82MGjSpCFD2cj5v9YQcs5oO5QsmeeMit4fMG8Z83Jy8knOF97d9NaQ7Bg=="], + + "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260616.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-gl7R8OiwEBNxzs5wjbM9XOibTs5b6/gAgu0+En9pLpYuWR/EITs8Eh0mNQui4JYTp1SkoHvn09aRZKTQf21XTg=="], + + "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260616.1", "", { "os": "linux", "cpu": "arm" }, "sha512-QWXQS2CrhSpXbng7vBtCVDszFgwVBuJU8MCFhxZL0hH6s+XjQDSNNkGO1oHueBr+7HbSkkyqfNYVNXvgVMUJLQ=="], + + "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260616.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CJjplWoE+EeYtbyNeP4fyuh0QcPiQBZJSLqPS07E3ugo3d9M/IG8WnL+3GFQ0g1p4c6QC/+OC0oTTQnGcNdd2Q=="], + + "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260616.1", "", { "os": "linux", "cpu": "x64" }, "sha512-UVySBNnGTAnul2kO2/EhlVZl0CeTDcd6Lzi+WkvgyCgapTcU0BX1selQ4MEPtbVWKUbt9HBhxNku2dyaCcmKVw=="], + + "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260616.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-kdX0QcDXiESH0o5DFdSWw15Hth0EtQobT9tX28ofqBUwGU1FFhwTKzA6qo7chaYUSW5MMd6XIME9rBwk+WizLw=="], + + "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260616.1", "", { "os": "win32", "cpu": "x64" }, "sha512-EB0Pj/0+nXifS3+wN0HdR1mKu7IieSpjMXoDjdXtJAdiPGlmKazBgHj97qn6CBvXisgLx0Eyb7tLCEQNDG53cA=="], + + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], + + "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "miniflare": ["miniflare@4.20260625.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.28.0", "workerd": "1.20260625.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-3kKXwRUObJsnBYPBgR0NiNZYKF/yv8GFyha1cx2EeAEraxNODgRVcyeRo+F1ok1tg5Mg7iUpOWSkknQTHuFhwA=="], + + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + + "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], + + "workerd": ["workerd@1.20260625.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260625.1", "@cloudflare/workerd-darwin-arm64": "1.20260625.1", "@cloudflare/workerd-linux-64": "1.20260625.1", "@cloudflare/workerd-linux-arm64": "1.20260625.1", "@cloudflare/workerd-windows-64": "1.20260625.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-GApQvFX52SDM6L4u0+RRnUDB1wJOnEwoXjinkmOPtIyofWBxrlZckdegJSYc1leg++lLZ3+DQ4zMVmBqYVtzfA=="], + + "wrangler": ["wrangler@4.105.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260625.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260625.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260625.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "cf-wrangler": "bin/cf-wrangler.js", "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-7dXFH6OLj1Fv0y6ZeRPUxFTkp+duWD7/xxVi/1c0vfOeEYwIFKWB7cdqnY05DvY1Ta3BnqAwRkXfLs8PDj538g=="], + + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + + "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], + + "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], + } +} diff --git a/workers/collaboration/package.json b/workers/collaboration/package.json new file mode 100644 index 000000000000..9a54211d89b3 --- /dev/null +++ b/workers/collaboration/package.json @@ -0,0 +1,19 @@ +{ + "name": "cmux-collaboration-worker", + "private": true, + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "test": "bun test", + "typecheck": "tsgo --noEmit && tsgo --noEmit -p tsconfig.test.json", + "check": "bun run typecheck && bun test && wrangler deploy --dry-run --outdir dist" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260525.0", + "@types/bun": "^1.3.14", + "@typescript/native-preview": "7.0.0-dev.20260616.1", + "typescript": "^5.8.0", + "wrangler": "^4.20.0" + } +} diff --git a/workers/collaboration/src/handler.ts b/workers/collaboration/src/handler.ts new file mode 100644 index 000000000000..a36c7764e934 --- /dev/null +++ b/workers/collaboration/src/handler.ts @@ -0,0 +1,38 @@ +import { json, randomSessionCode, type SessionCreateResponse } from "./protocol"; + +interface CollaborationSessionStub { + create(sessionCode: string): Promise; + fetch(request: Request): Promise; +} + +interface CollaborationSessionNamespace { + idFromName(name: string): unknown; + get(id: unknown): CollaborationSessionStub; +} + +export interface CollaborationWorkerEnv { + COLLABORATION_SESSIONS: CollaborationSessionNamespace; +} + +export async function collaborationFetch(request: Request, env: CollaborationWorkerEnv): Promise { + const url = new URL(request.url); + + if (url.pathname === "/healthz") { + return json({ ok: true, service: "cmux-collaboration" }); + } + + if (url.pathname === "/v1/collaboration/sessions" && request.method === "POST") { + const sessionCode = randomSessionCode(); + const stub = env.COLLABORATION_SESSIONS.get(env.COLLABORATION_SESSIONS.idFromName(sessionCode)); + return json(await stub.create(sessionCode), 201); + } + + const match = url.pathname.match(/^\/v1\/collaboration\/sessions\/([A-Z0-9-]+)\/connect$/); + if (match && request.method === "GET") { + const sessionCode = match[1]; + const stub = env.COLLABORATION_SESSIONS.get(env.COLLABORATION_SESSIONS.idFromName(sessionCode)); + return stub.fetch(request); + } + + return json({ error: "not_found" }, 404); +} diff --git a/workers/collaboration/src/index.ts b/workers/collaboration/src/index.ts new file mode 100644 index 000000000000..e2492b8c644b --- /dev/null +++ b/workers/collaboration/src/index.ts @@ -0,0 +1,14 @@ +import { CollaborationSessionObject } from "./session"; +import { collaborationFetch } from "./handler"; + +export { CollaborationSessionObject }; + +export interface Env { + COLLABORATION_SESSIONS: DurableObjectNamespace; +} + +export default { + async fetch(request: Request, env: Env): Promise { + return collaborationFetch(request, env); + }, +} satisfies ExportedHandler; diff --git a/workers/collaboration/src/protocol.ts b/workers/collaboration/src/protocol.ts new file mode 100644 index 000000000000..0410b4b9df3d --- /dev/null +++ b/workers/collaboration/src/protocol.ts @@ -0,0 +1,64 @@ +export interface PeerInfo { + peerID: string; + displayName: string; + color: string; +} + +export interface RelayEnvelope { + type: string; + [key: string]: unknown; +} + +export interface SessionCreateResponse { + sessionID: string; + sessionCode: string; + token: string; +} + +export function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +export function parsePeer(value: unknown): PeerInfo | null { + if (typeof value !== "object" || value === null) return null; + const record = value as Record; + if (typeof record.peerID !== "string" || record.peerID.trim() === "") return null; + if (typeof record.displayName !== "string" || record.displayName.trim() === "") return null; + if (typeof record.color !== "string" || record.color.trim() === "") return null; + return { + peerID: record.peerID, + displayName: record.displayName, + color: record.color, + }; +} + +export function parseEnvelope(message: string | ArrayBuffer): RelayEnvelope | null { + const text = typeof message === "string" ? message : new TextDecoder().decode(message); + if (text.length > 1024 * 1024) return null; + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + const record = parsed as Record; + return typeof record.type === "string" ? (record as RelayEnvelope) : null; +} + +export function randomToken(bytes = 18): string { + const values = new Uint8Array(bytes); + crypto.getRandomValues(values); + return [...values].map((value) => value.toString(16).padStart(2, "0")).join(""); +} + +export function randomSessionCode(): string { + const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + const values = new Uint8Array(8); + crypto.getRandomValues(values); + const chars = [...values].map((value) => alphabet[value % alphabet.length]); + return `${chars.slice(0, 4).join("")}-${chars.slice(4).join("")}`; +} diff --git a/workers/collaboration/src/session-metadata.ts b/workers/collaboration/src/session-metadata.ts new file mode 100644 index 000000000000..9e0a292ed017 --- /dev/null +++ b/workers/collaboration/src/session-metadata.ts @@ -0,0 +1,34 @@ +import { randomToken } from "./protocol"; + +export interface SessionMetadata { + sessionID: string; + sessionCode: string; + token: string; +} + +export interface SessionMetadataStorage { + get(key: string): Promise; + put(key: string, value: T): Promise; +} + +const METADATA_KEY = "metadata"; + +export async function createSessionMetadata( + storage: SessionMetadataStorage, + sessionCode: string +): Promise { + const existing = await readSessionMetadata(storage); + if (existing) return existing; + + const metadata = { + sessionID: sessionCode, + sessionCode, + token: randomToken(), + }; + await storage.put(METADATA_KEY, metadata); + return metadata; +} + +export async function readSessionMetadata(storage: SessionMetadataStorage): Promise { + return await storage.get(METADATA_KEY) ?? null; +} diff --git a/workers/collaboration/src/session-state.ts b/workers/collaboration/src/session-state.ts new file mode 100644 index 000000000000..bf662b992c57 --- /dev/null +++ b/workers/collaboration/src/session-state.ts @@ -0,0 +1,83 @@ +import { parseEnvelope, type PeerInfo } from "./protocol"; + +export interface RelaySocket { + send(data: string): void; + close(code: number, reason: string): void; +} + +interface PeerConnection { + peer: PeerInfo; + socket: RelaySocket; + lastHeartbeatAt: number; +} + +export class CollaborationRelaySessionState { + private peers = new Map(); + + get peerCount(): number { + return this.peers.size; + } + + addPeer(sessionID: string, peer: PeerInfo, socket: RelaySocket, now: number): void { + this.peers.set(peer.peerID, { peer, socket, lastHeartbeatAt: now }); + socket.send(JSON.stringify({ + type: "session.joined", + sessionID, + peers: [...this.peers.values()].map((entry) => entry.peer), + })); + this.broadcast(peer.peerID, { type: "peer.joined", peer }); + } + + handleMessage(peerID: string, data: string | ArrayBuffer, now: number): void { + const entry = this.peers.get(peerID); + if (!entry) return; + const envelope = parseEnvelope(data); + if (envelope === null) { + this.closePeer(peerID, 1003, "invalid frame"); + this.dropPeer(peerID, "disconnect"); + return; + } + if (envelope.type === "peer.heartbeat") { + entry.lastHeartbeatAt = now; + this.peers.set(peerID, entry); + return; + } + this.broadcast(peerID, { ...envelope, fromPeerID: peerID, receivedAt: now }); + } + + expire(now: number, timeoutMs: number): void { + for (const [peerID, entry] of this.peers) { + if (now - entry.lastHeartbeatAt > timeoutMs) { + this.closePeer(peerID, 1001, "heartbeat timeout"); + this.dropPeer(peerID, "timeout"); + } + } + } + + dropPeer(peerID: string, reason: "disconnect" | "timeout" | "leave"): void { + if (!this.peers.delete(peerID)) return; + this.broadcast(peerID, { type: "peer.left", peerID, reason }); + } + + private broadcast(fromPeerID: string, body: unknown): void { + const encoded = JSON.stringify(body); + for (const [peerID, entry] of this.peers) { + if (peerID === fromPeerID) continue; + try { + entry.socket.send(encoded); + } catch { + this.dropPeer(peerID, "disconnect"); + } + } + } + + private closePeer(peerID: string, code: number, reason: string): void { + const entry = this.peers.get(peerID); + if (!entry) return; + try { + entry.socket.close(code, reason); + } catch { + // Already closed. + } + } +} diff --git a/workers/collaboration/src/session.ts b/workers/collaboration/src/session.ts new file mode 100644 index 000000000000..22a9e0f620e0 --- /dev/null +++ b/workers/collaboration/src/session.ts @@ -0,0 +1,73 @@ +import { DurableObject } from "cloudflare:workers"; +import { parsePeer } from "./protocol"; +import { CollaborationRelaySessionState } from "./session-state"; +import { createSessionMetadata, readSessionMetadata, type SessionMetadata } from "./session-metadata"; + +const HEARTBEAT_TIMEOUT_MS = 30_000; + +export class CollaborationSessionObject extends DurableObject { + private metadata: SessionMetadata | null = null; + private state = new CollaborationRelaySessionState(); + + async create(sessionCode: string): Promise { + if (this.metadata !== null) return this.metadata; + this.metadata = await createSessionMetadata(this.ctx.storage, sessionCode); + return this.metadata; + } + + override async fetch(request: Request): Promise { + const url = new URL(request.url); + if (url.pathname.endsWith("/connect")) { + return this.handleConnect(request); + } + return new Response("not found", { status: 404 }); + } + + private async handleConnect(request: Request): Promise { + if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") { + return new Response("expected websocket", { status: 426 }); + } + const metadata = await this.loadMetadata(); + if (metadata === null) { + return new Response(JSON.stringify({ error: "session_not_found" }), { status: 404 }); + } + const url = new URL(request.url); + if (url.searchParams.get("token") !== metadata.token) { + return new Response(JSON.stringify({ error: "invalid_token" }), { status: 403 }); + } + const peer = parsePeer({ + peerID: url.searchParams.get("peerID"), + displayName: url.searchParams.get("displayName"), + color: url.searchParams.get("color"), + }); + if (peer === null) { + return new Response(JSON.stringify({ error: "invalid_peer" }), { status: 400 }); + } + + const pair = new WebSocketPair(); + const client = pair[0]; + const server = pair[1]; + server.accept(); + this.state.addPeer(metadata.sessionID, peer, server, Date.now()); + server.addEventListener("message", (event) => this.state.handleMessage(peer.peerID, event.data, Date.now())); + server.addEventListener("close", () => this.state.dropPeer(peer.peerID, "disconnect")); + server.addEventListener("error", () => this.state.dropPeer(peer.peerID, "disconnect")); + await this.ensureAlarm(); + return new Response(null, { status: 101, webSocket: client }); + } + + override async alarm(): Promise { + this.state.expire(Date.now(), HEARTBEAT_TIMEOUT_MS); + if (this.state.peerCount > 0) await this.ensureAlarm(); + } + + private async ensureAlarm(): Promise { + await this.ctx.storage.setAlarm(Date.now() + HEARTBEAT_TIMEOUT_MS); + } + + private async loadMetadata(): Promise { + if (this.metadata !== null) return this.metadata; + this.metadata = await readSessionMetadata(this.ctx.storage); + return this.metadata; + } +} diff --git a/workers/collaboration/test/index.test.ts b/workers/collaboration/test/index.test.ts new file mode 100644 index 000000000000..2654055568cf --- /dev/null +++ b/workers/collaboration/test/index.test.ts @@ -0,0 +1,68 @@ +import { expect, test } from "bun:test"; +import { collaborationFetch, type CollaborationWorkerEnv } from "../src/handler"; + +class FakeSessionStub { + createdSessionCode: string | null = null; + fetchRequests: Request[] = []; + + async create(sessionCode: string) { + this.createdSessionCode = sessionCode; + return { + sessionID: sessionCode, + sessionCode, + token: `token-for-${sessionCode}`, + }; + } + + async fetch(request: Request) { + this.fetchRequests.push(request); + return new Response("routed-to-session", { status: 299 }); + } +} + +class FakeSessionNamespace { + stubs = new Map(); + + idFromName(name: string) { + return name; + } + + get(id: string) { + let stub = this.stubs.get(id); + if (!stub) { + stub = new FakeSessionStub(); + this.stubs.set(id, stub); + } + return stub; + } +} + +test("join route uses session code to reach the created session object", async () => { + const namespace = new FakeSessionNamespace(); + const env = { + COLLABORATION_SESSIONS: namespace, + } satisfies CollaborationWorkerEnv; + + const createResponse = await collaborationFetch( + new Request("http://relay.test/v1/collaboration/sessions", { method: "POST" }), + env + ); + const created = await createResponse.json() as { sessionCode: string; token: string }; + + const joinResponse = await collaborationFetch( + new Request( + `http://relay.test/v1/collaboration/sessions/${created.sessionCode}/connect?token=${created.token}`, + { method: "GET" } + ), + env + ); + + const stub = namespace.stubs.get(created.sessionCode); + expect(createResponse.status).toBe(201); + expect(joinResponse.status).toBe(299); + expect(stub?.createdSessionCode).toBe(created.sessionCode); + expect(stub?.fetchRequests).toHaveLength(1); + expect(new URL(stub?.fetchRequests[0]?.url ?? "").pathname).toBe( + `/v1/collaboration/sessions/${created.sessionCode}/connect` + ); +}); diff --git a/workers/collaboration/test/protocol.test.ts b/workers/collaboration/test/protocol.test.ts new file mode 100644 index 000000000000..c957f13dc8b6 --- /dev/null +++ b/workers/collaboration/test/protocol.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "bun:test"; +import { parseEnvelope, parsePeer, randomSessionCode, randomToken } from "../src/protocol"; + +test("parsePeer accepts complete peer metadata", () => { + expect(parsePeer({ peerID: "p1", displayName: "Peer", color: "#123456" })).toEqual({ + peerID: "p1", + displayName: "Peer", + color: "#123456", + }); +}); + +test("parseEnvelope rejects malformed or oversized frames", () => { + expect(parseEnvelope("{")).toBeNull(); + expect(parseEnvelope(JSON.stringify({ nope: true }))).toBeNull(); + expect(parseEnvelope("x".repeat(1024 * 1024 + 1))).toBeNull(); + expect(parseEnvelope(JSON.stringify({ type: "document.update", payloadBase64: "abc" }))).toEqual({ + type: "document.update", + payloadBase64: "abc", + }); +}); + +test("invite material has expected shape", () => { + expect(randomSessionCode()).toMatch(/^[A-Z0-9]{4}-[A-Z0-9]{4}$/); + expect(randomToken()).toMatch(/^[0-9a-f]{36}$/); +}); diff --git a/workers/collaboration/test/session-metadata.test.ts b/workers/collaboration/test/session-metadata.test.ts new file mode 100644 index 000000000000..0cdaa5bc35a7 --- /dev/null +++ b/workers/collaboration/test/session-metadata.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "bun:test"; +import { createSessionMetadata, readSessionMetadata, type SessionMetadataStorage } from "../src/session-metadata"; + +class FakeMetadataStorage implements SessionMetadataStorage { + values = new Map(); + + async get(key: string): Promise { + return this.values.get(key) as T | undefined; + } + + async put(key: string, value: T): Promise { + this.values.set(key, value); + } +} + +test("created session metadata is available to a later join instance", async () => { + const storage = new FakeMetadataStorage(); + + const created = await createSessionMetadata(storage, "ABCD-1234"); + const loadedByFreshInstance = await readSessionMetadata(storage); + + expect(loadedByFreshInstance).toEqual(created); + expect(loadedByFreshInstance?.sessionCode).toBe("ABCD-1234"); + expect(loadedByFreshInstance?.token).toHaveLength(36); +}); + +test("creating an existing session reuses its token", async () => { + const storage = new FakeMetadataStorage(); + + const first = await createSessionMetadata(storage, "ABCD-1234"); + const second = await createSessionMetadata(storage, "ABCD-1234"); + + expect(second).toEqual(first); +}); diff --git a/workers/collaboration/test/session-state.test.ts b/workers/collaboration/test/session-state.test.ts new file mode 100644 index 000000000000..ac8f2cbe7a92 --- /dev/null +++ b/workers/collaboration/test/session-state.test.ts @@ -0,0 +1,107 @@ +import { expect, test } from "bun:test"; +import { CollaborationRelaySessionState, type RelaySocket } from "../src/session-state"; + +class FakeSocket implements RelaySocket { + sent: string[] = []; + closed: Array<{ code: number; reason: string }> = []; + + send(data: string): void { + this.sent.push(data); + } + + close(code: number, reason: string): void { + this.closed.push({ code, reason }); + } +} + +const peer = (peerID: string) => ({ + peerID, + displayName: peerID, + color: "#123456", +}); + +test("joined frame includes existing distinct peers", () => { + const state = new CollaborationRelaySessionState(); + const first = new FakeSocket(); + const second = new FakeSocket(); + + state.addPeer("ABCD-1234", peer("p1"), first, 1000); + state.addPeer("ABCD-1234", peer("p2"), second, 1000); + + expect(JSON.parse(second.sent[0] ?? "{}")).toEqual({ + type: "session.joined", + sessionID: "ABCD-1234", + peers: [peer("p1"), peer("p2")], + }); + expect(JSON.parse(first.sent.at(-1) ?? "{}")).toEqual({ + type: "peer.joined", + peer: peer("p2"), + }); +}); + +test("forwards opaque non-heartbeat frames to other peers", () => { + const state = new CollaborationRelaySessionState(); + const first = new FakeSocket(); + const second = new FakeSocket(); + state.addPeer("ABCD-1234", peer("p1"), first, 1000); + state.addPeer("ABCD-1234", peer("p2"), second, 1000); + + state.handleMessage("p1", JSON.stringify({ type: "document.update", documentID: "doc1" }), 1100); + + const forwarded = JSON.parse(second.sent.at(-1) ?? "{}"); + expect(forwarded).toEqual({ + type: "document.update", + documentID: "doc1", + fromPeerID: "p1", + receivedAt: 1100, + }); +}); + +test("rejects malformed frames and broadcasts peer departure", () => { + const state = new CollaborationRelaySessionState(); + const first = new FakeSocket(); + const second = new FakeSocket(); + state.addPeer("ABCD-1234", peer("p1"), first, 1000); + state.addPeer("ABCD-1234", peer("p2"), second, 1000); + + state.handleMessage("p1", "{", 1100); + + expect(first.closed).toEqual([{ code: 1003, reason: "invalid frame" }]); + expect(JSON.parse(second.sent.at(-1) ?? "{}")).toEqual({ + type: "peer.left", + peerID: "p1", + reason: "disconnect", + }); +}); + +test("heartbeat refreshes liveness without forwarding", () => { + const state = new CollaborationRelaySessionState(); + const first = new FakeSocket(); + const second = new FakeSocket(); + state.addPeer("ABCD-1234", peer("p1"), first, 1000); + state.addPeer("ABCD-1234", peer("p2"), second, 1000); + const before = second.sent.length; + + state.handleMessage("p1", JSON.stringify({ type: "peer.heartbeat" }), 31_000); + state.expire(31_001, 30_000); + + expect(second.sent.length).toBe(before); + expect(first.closed).toEqual([]); +}); + +test("heartbeat timeout closes stale peers and notifies survivors", () => { + const state = new CollaborationRelaySessionState(); + const first = new FakeSocket(); + const second = new FakeSocket(); + state.addPeer("ABCD-1234", peer("p1"), first, 1000); + state.addPeer("ABCD-1234", peer("p2"), second, 1000); + + state.expire(31_001, 30_000); + + expect(first.closed).toEqual([{ code: 1001, reason: "heartbeat timeout" }]); + expect(JSON.parse(second.sent.at(-1) ?? "{}")).toEqual({ + type: "peer.left", + peerID: "p1", + reason: "timeout", + }); +}); diff --git a/workers/collaboration/tsconfig.json b/workers/collaboration/tsconfig.json new file mode 100644 index 000000000000..23357143c873 --- /dev/null +++ b/workers/collaboration/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "types": ["@cloudflare/workers-types"], + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/workers/collaboration/tsconfig.test.json b/workers/collaboration/tsconfig.test.json new file mode 100644 index 000000000000..cb94263a20c3 --- /dev/null +++ b/workers/collaboration/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["bun-types", "@cloudflare/workers-types"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/workers/collaboration/wrangler.toml b/workers/collaboration/wrangler.toml new file mode 100644 index 000000000000..8cecc4438652 --- /dev/null +++ b/workers/collaboration/wrangler.toml @@ -0,0 +1,11 @@ +name = "cmux-collaboration-worker" +main = "src/index.ts" +compatibility_date = "2026-06-01" + +[[durable_objects.bindings]] +name = "COLLABORATION_SESSIONS" +class_name = "CollaborationSessionObject" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["CollaborationSessionObject"] From 665ff7aba2401a3000e02bc506c3cbf829a58769 Mon Sep 17 00:00:00 2001 From: DorsaRoh Date: Mon, 29 Jun 2026 22:42:59 -0700 Subject: [PATCH 2/5] working shared terminal --- .../CollaborationRelayFrame.swift | 10 +- .../CollaborationSession.swift | 2 + .../SharedTerminalDescriptor.swift | 29 ++ .../CollaborationRelayStateTests.swift | 22 ++ Resources/Localizable.xcstrings | 85 +++++ Sources/CollaborationRuntime.swift | 351 ++++++++++++++++++ Sources/Mobile/MobileTerminalByteTee.swift | 4 + Sources/Panels/TerminalPanelView.swift | 43 +++ Sources/TerminalController.swift | 8 + ...bf0fc9a641724d672dec2a87767fd020f43.sqlite | Bin 0 -> 4096 bytes ...c9a641724d672dec2a87767fd020f43.sqlite-shm | Bin 0 -> 32768 bytes ...c9a641724d672dec2a87767fd020f43.sqlite-wal | Bin 0 -> 41232 bytes ...c85cac82b296003f0686a9f931a1a87e2c2.sqlite | Bin 0 -> 16384 bytes ...c0e4d9d2702b9c6b7742d1591144870071f.sqlite | Bin 0 -> 16384 bytes ...2d5563786ecc264864a6ddff28587ea.sqlite-wal | Bin 41232 -> 333752 bytes .../metadata.sqlite-wal | Bin 57712 -> 453232 bytes .../collaboration/test/session-state.test.ts | 36 ++ 17 files changed, 589 insertions(+), 1 deletion(-) create mode 100644 Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/SharedTerminalDescriptor.swift create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/2176ee2a3478351a9ec5b654efb98bf0fc9a641724d672dec2a87767fd020f43.sqlite create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/2176ee2a3478351a9ec5b654efb98bf0fc9a641724d672dec2a87767fd020f43.sqlite-shm create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/2176ee2a3478351a9ec5b654efb98bf0fc9a641724d672dec2a87767fd020f43.sqlite-wal create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/5cf9566811a28de3c85872fdc2307c85cac82b296003f0686a9f931a1a87e2c2.sqlite create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/916fdbcbc6cadb6b3d28928907dddc0e4d9d2702b9c6b7742d1591144870071f.sqlite diff --git a/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationRelayFrame.swift b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationRelayFrame.swift index 30060b5a8149..824881957d6a 100644 --- a/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationRelayFrame.swift +++ b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationRelayFrame.swift @@ -1,4 +1,4 @@ -import Foundation +public import Foundation /// Client-side relay frame representation used by tests and transports. public enum CollaborationRelayFrame: Codable, Equatable, Sendable { @@ -12,4 +12,12 @@ public enum CollaborationRelayFrame: Codable, Equatable, Sendable { case presence(PresenceState) /// A peer disconnected or timed out. case peerLeft(peerID: String) + /// A terminal surface was shared by a peer. + case terminalOpen(terminalID: String, descriptor: SharedTerminalDescriptor) + /// Raw PTY output bytes for a shared terminal. + case terminalOutput(terminalID: String, sequence: UInt64, data: Data) + /// Raw input bytes for a shared terminal's authoritative PTY. + case terminalInput(terminalID: String, inputID: String, data: Data) + /// A terminal share was closed. + case terminalClose(terminalID: String) } diff --git a/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationSession.swift b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationSession.swift index e82d2624cb2a..9e1a87f0dfd1 100644 --- a/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationSession.swift +++ b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationSession.swift @@ -136,6 +136,8 @@ public actor CollaborationSession { case let .peerLeft(peerID): presenceByPeer.removeValue(forKey: peerID) continuation?.yield(.presenceCleared(peerID: peerID)) + case .terminalOpen, .terminalOutput, .terminalInput, .terminalClose: + break } } diff --git a/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/SharedTerminalDescriptor.swift b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/SharedTerminalDescriptor.swift new file mode 100644 index 000000000000..8eb5084782b1 --- /dev/null +++ b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/SharedTerminalDescriptor.swift @@ -0,0 +1,29 @@ +public import Foundation + +/// Describes one terminal surface shared in a collaboration session. +public struct SharedTerminalDescriptor: Codable, Equatable, Hashable, Sendable { + /// The workspace that owns the terminal surface on the sharing peer. + public let workspaceID: UUID + /// The stable terminal surface identifier. + public let surfaceID: UUID + /// The user-visible terminal title. + public let title: String + + /// Creates a shared terminal descriptor. + /// - Parameters: + /// - workspaceID: The workspace that owns the terminal surface on the sharing peer. + /// - surfaceID: The stable terminal surface identifier. + /// - title: The user-visible terminal title. + public init(workspaceID: UUID, surfaceID: UUID, title: String) { + self.workspaceID = workspaceID + self.surfaceID = surfaceID + self.title = title + } + + /// The stable collaboration terminal identifier for a session. + /// - Parameter sessionID: The current collaboration session identifier. + /// - Returns: A terminal identifier scoped to the session. + public func terminalID(sessionID: String) -> String { + "\(sessionID):terminal:\(workspaceID.uuidString):\(surfaceID.uuidString)" + } +} diff --git a/Packages/Shared/CmuxCollaboration/Tests/CmuxCollaborationTests/CollaborationRelayStateTests.swift b/Packages/Shared/CmuxCollaboration/Tests/CmuxCollaborationTests/CollaborationRelayStateTests.swift index c1786685535a..36ada9486ffc 100644 --- a/Packages/Shared/CmuxCollaboration/Tests/CmuxCollaborationTests/CollaborationRelayStateTests.swift +++ b/Packages/Shared/CmuxCollaboration/Tests/CmuxCollaborationTests/CollaborationRelayStateTests.swift @@ -32,4 +32,26 @@ struct CollaborationRelayStateTests { #expect(await session.currentConnectionState() == .disconnected) } + + @Test + func terminalRelayFramesRoundTrip() throws { + let workspaceID = try #require(UUID(uuidString: "00000000-0000-0000-0000-000000000001")) + let surfaceID = try #require(UUID(uuidString: "00000000-0000-0000-0000-000000000002")) + let descriptor = SharedTerminalDescriptor( + workspaceID: workspaceID, + surfaceID: surfaceID, + title: "Terminal" + ) + let frames: [CollaborationRelayFrame] = [ + .terminalOpen(terminalID: "s:terminal:w:t", descriptor: descriptor), + .terminalOutput(terminalID: "s:terminal:w:t", sequence: 42, data: Data([0x1B, 0x5B, 0x41])), + .terminalInput(terminalID: "s:terminal:w:t", inputID: "input-1", data: Data("echo ok\r".utf8)), + .terminalClose(terminalID: "s:terminal:w:t"), + ] + + let encoded = try JSONEncoder().encode(frames) + let decoded = try JSONDecoder().decode([CollaborationRelayFrame].self, from: encoded) + + #expect(decoded == frames) + } } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 0e8387056ada..76c923379d7d 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -217323,6 +217323,91 @@ } } } + }, + "collaboration.terminal.error.noWorkspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No workspace is available for the shared terminal." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "共有ターミナルに使用できるワークスペースがありません。" + } + } + } + }, + "collaboration.terminal.error.shareFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not create the shared terminal mirror." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "共有ターミナルのミラーを作成できませんでした。" + } + } + } + }, + "collaboration.terminal.share": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Share Terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルを共有" + } + } + } + }, + "collaboration.terminal.sharedTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shared Terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "共有ターミナル" + } + } + } + }, + "collaboration.terminal.stopSharing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stop Sharing Terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナル共有を停止" + } + } + } } }, "version": "1.0" diff --git a/Sources/CollaborationRuntime.swift b/Sources/CollaborationRuntime.swift index 5b62c2c62dec..85d86f4d80c1 100644 --- a/Sources/CollaborationRuntime.swift +++ b/Sources/CollaborationRuntime.swift @@ -65,6 +65,31 @@ private struct CollaborationDocumentSnapshotRequestWire: Codable { let requestID: String } +private struct CollaborationTerminalOpenWire: Codable { + let type: String + let terminalID: String + let descriptor: SharedTerminalDescriptor +} + +private struct CollaborationTerminalOutputWire: Codable { + let type: String + let terminalID: String + let sequence: UInt64 + let dataBase64: String +} + +private struct CollaborationTerminalInputWire: Codable { + let type: String + let terminalID: String + let inputID: String + let dataBase64: String +} + +private struct CollaborationTerminalCloseWire: Codable { + let type: String + let terminalID: String +} + private struct CollaborationPresenceWire: Codable { let type: String let peerID: String @@ -119,6 +144,20 @@ private final class WeakCollaborationPanel { } } +private final class WeakCollaborationTerminalPanel { + weak var panel: TerminalPanel? + + init(_ panel: TerminalPanel) { + self.panel = panel + } +} + +struct CollaborationTerminalHeaderState: Equatable { + var isShared = false + var statusText = "" + var peerSummary = "" +} + @MainActor @Observable final class CollaborationRuntime { @@ -140,6 +179,11 @@ final class CollaborationRuntime { private var panelsByDocumentID: [String: WeakCollaborationPanel] = [:] private var descriptorsByDocumentID: [String: SharedFileDescriptor] = [:] private var statesByDocumentID: [String: CollaborationDocumentHeaderState] = [:] + private var hostedTerminalsByID: [String: WeakCollaborationTerminalPanel] = [:] + private var hostedTerminalIDsBySurfaceID: [UUID: String] = [:] + private var hostedTerminalOutputSequencesByID: [String: UInt64] = [:] + private var mirroredTerminalsByID: [String: WeakCollaborationTerminalPanel] = [:] + private var terminalStatesByID: [String: CollaborationTerminalHeaderState] = [:] private var peersByID: [String: CollaborationPeerWire] = [:] private var snapshotFallbackTasks: [String: Task] = [:] @@ -166,6 +210,58 @@ final class CollaborationRuntime { share(panel: panel) } + func state(for terminal: TerminalPanel) -> CollaborationTerminalHeaderState { + let terminalID = terminalID(for: terminal) + return terminalStatesByID[terminalID] ?? CollaborationTerminalHeaderState( + isShared: false, + statusText: connectionLabel, + peerSummary: peerSummary + ) + } + + func configureOrShare(terminal: TerminalPanel) { + if session == nil { + presentStartDialog() + return + } + if state(for: terminal).isShared { + leave(terminal: terminal) + return + } + share(terminal: terminal) + } + + func leave(terminal: TerminalPanel) { + let terminalID = terminalID(for: terminal) + hostedTerminalsByID.removeValue(forKey: terminalID) + hostedTerminalIDsBySurfaceID.removeValue(forKey: terminal.id) + hostedTerminalOutputSequencesByID.removeValue(forKey: terminalID) + mirroredTerminalsByID.removeValue(forKey: terminalID) + terminalStatesByID.removeValue(forKey: terminalID) + Task { + try? await send(.terminalClose(terminalID: terminalID)) + } + } + + func noteTerminalOutput(surfaceID: UUID, data: Data) { + guard let terminalID = hostedTerminalIDsBySurfaceID[surfaceID] else { return } + let sequence = hostedTerminalOutputSequencesByID[terminalID] ?? 0 + hostedTerminalOutputSequencesByID[terminalID] = sequence &+ UInt64(data.count) + Task { + try? await send(.terminalOutput(terminalID: terminalID, sequence: sequence, data: data)) + } + } + + func noteTerminalInput(terminalID: String, data: Data) { + Task { + try? await send(.terminalInput( + terminalID: terminalID, + inputID: "\(peerIdentity.peerID)-\(UUID().uuidString)", + data: data + )) + } + } + func leave(panel: any CollaborationEditablePanel) { guard let session else { return } let descriptor = descriptor(for: panel) @@ -235,6 +331,7 @@ final class CollaborationRuntime { "invite_token": inviteToken ?? NSNull(), "status": connectionLabel, "shared_documents": statesByDocumentID.values.filter(\.isShared).count, + "shared_terminals": terminalStatesByID.values.filter(\.isShared).count, "peers": peersByID.values.map { peer in [ "peer_id": peer.peerID, @@ -296,6 +393,25 @@ final class CollaborationRuntime { ] } + func shareSelectedTerminalForAutomation() -> [String: Any] { + guard let workspace = TerminalController.shared.tabManager?.selectedWorkspace + ?? TerminalController.shared.tabManager?.tabs.first else { + return [ + "shared": false, + "error": CollaborationStrings.noWorkspaceForTerminalShare, + ] + } + guard let terminal = workspace.focusedTerminalPanel + ?? workspace.panels.values.compactMap({ $0 as? TerminalPanel }).first else { + return [ + "shared": false, + "error": CollaborationStrings.terminalShareFailed, + ] + } + configureOrShare(terminal: terminal) + return statusPayload() + } + func leaveSessionForAutomation() -> [String: Any] { disconnectWebSocket() session = nil @@ -304,11 +420,66 @@ final class CollaborationRuntime { panelsByDocumentID.removeAll() descriptorsByDocumentID.removeAll() statesByDocumentID.removeAll() + hostedTerminalsByID.removeAll() + hostedTerminalIDsBySurfaceID.removeAll() + hostedTerminalOutputSequencesByID.removeAll() + mirroredTerminalsByID.removeAll() + terminalStatesByID.removeAll() peersByID.removeAll() connectionLabel = CollaborationStrings.disconnected return statusPayload() } + private func presentStartDialog() { + let alert = NSAlert() + alert.messageText = CollaborationStrings.startTitle + alert.informativeText = CollaborationStrings.startMessage + alert.addButton(withTitle: CollaborationStrings.createSession) + alert.addButton(withTitle: CollaborationStrings.joinSession) + alert.addButton(withTitle: CollaborationStrings.cancel) + + let relayField = NSTextField(string: relayURLString) + relayField.placeholderString = "http://localhost:8787" + relayField.frame = NSRect(x: 0, y: 0, width: 360, height: 24) + alert.accessoryView = relayField + + let response = alert.runModal() + relayURLString = relayField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + switch response { + case .alertFirstButtonReturn: + Task { _ = await createSessionForAutomation(relayURL: relayURLString) } + case .alertSecondButtonReturn: + presentJoinDialog() + default: + break + } + } + + private func presentJoinDialog() { + let alert = NSAlert() + alert.messageText = CollaborationStrings.joinSession + alert.informativeText = CollaborationStrings.joinMessage + alert.addButton(withTitle: CollaborationStrings.joinSession) + alert.addButton(withTitle: CollaborationStrings.cancel) + + let stack = NSStackView() + stack.orientation = .vertical + stack.spacing = 8 + stack.frame = NSRect(x: 0, y: 0, width: 360, height: 64) + let codeField = NSTextField(string: "") + codeField.placeholderString = CollaborationStrings.sessionCodePlaceholder + let tokenField = NSTextField(string: "") + tokenField.placeholderString = CollaborationStrings.inviteTokenPlaceholder + stack.addArrangedSubview(codeField) + stack.addArrangedSubview(tokenField) + alert.accessoryView = stack + + guard alert.runModal() == .alertFirstButtonReturn else { return } + let code = codeField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + let token = tokenField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + Task { await joinSession(code: code, token: token) } + } + private func presentStartDialog(thenShare panel: any CollaborationEditablePanel) { let alert = NSAlert() alert.messageText = CollaborationStrings.startTitle @@ -420,6 +591,7 @@ final class CollaborationRuntime { await nextSession.markConnected() connectionLabel = CollaborationStrings.connected reopenSharedDocumentsForCurrentSession() + reopenSharedTerminalsForCurrentSession() } private func connectURL(code: String, token: String) -> URL? { @@ -472,6 +644,30 @@ final class CollaborationRuntime { } } + private func share(terminal: TerminalPanel) { + guard session != nil, let sessionCode else { return } + let descriptor = terminalDescriptor(for: terminal) + let terminalID = descriptor.terminalID(sessionID: sessionCode) + hostedTerminalsByID[terminalID] = WeakCollaborationTerminalPanel(terminal) + hostedTerminalIDsBySurfaceID[terminal.id] = terminalID + terminalStatesByID[terminalID] = CollaborationTerminalHeaderState( + isShared: true, + statusText: CollaborationStrings.shared, + peerSummary: peerSummary + ) + Task { + do { + try await send(.terminalOpen(terminalID: terminalID, descriptor: descriptor)) + if let replay = MobileTerminalByteTee.shared.replayState(surfaceID: terminal.id), + !replay.data.isEmpty { + try await send(.terminalOutput(terminalID: terminalID, sequence: replay.seq, data: replay.data)) + } + } catch { + lastErrorMessage = error.localizedDescription + } + } + } + private func reopenSharedDocumentsForCurrentSession() { guard let session, let sessionCode else { return } let openPanels = panelsByDocumentID.values.compactMap(\.panel) @@ -505,6 +701,32 @@ final class CollaborationRuntime { } } + private func reopenSharedTerminalsForCurrentSession() { + guard let sessionCode else { return } + let openTerminals = hostedTerminalsByID.values.compactMap(\.panel) + guard !openTerminals.isEmpty else { return } + + hostedTerminalsByID.removeAll() + hostedTerminalIDsBySurfaceID.removeAll() + hostedTerminalOutputSequencesByID.removeAll() + terminalStatesByID.removeAll() + + for terminal in openTerminals { + let descriptor = terminalDescriptor(for: terminal) + let terminalID = descriptor.terminalID(sessionID: sessionCode) + hostedTerminalsByID[terminalID] = WeakCollaborationTerminalPanel(terminal) + hostedTerminalIDsBySurfaceID[terminal.id] = terminalID + terminalStatesByID[terminalID] = CollaborationTerminalHeaderState( + isShared: true, + statusText: CollaborationStrings.shared, + peerSummary: peerSummary + ) + Task { + try? await send(.terminalOpen(terminalID: terminalID, descriptor: descriptor)) + } + } + } + private func scheduleSnapshotFallback(descriptor: SharedFileDescriptor, documentID: String) { snapshotFallbackTasks[documentID]?.cancel() snapshotFallbackTasks[documentID] = Task { [weak self] in @@ -523,6 +745,58 @@ final class CollaborationRuntime { } } + private func handleRemoteTerminalOpen(terminalID: String, descriptor: SharedTerminalDescriptor) { + if mirroredTerminalsByID[terminalID]?.panel != nil { return } + let title = descriptor.title.trimmingCharacters(in: .whitespacesAndNewlines) + let displayTitle = title.isEmpty ? CollaborationStrings.sharedTerminalTitle : title + guard let workspace = TerminalController.shared.tabManager?.selectedWorkspace + ?? TerminalController.shared.tabManager?.tabs.first else { + lastErrorMessage = CollaborationStrings.noWorkspaceForTerminalShare + return + } + guard let panel = workspace.addRemoteTmuxDisplayPane( + remotePaneId: terminalID.hashValue, + title: displayTitle, + focus: false, + onInput: { [terminalID] data in + Task { @MainActor in + CollaborationRuntime.shared.noteTerminalInput(terminalID: terminalID, data: data) + } + } + ) else { + lastErrorMessage = CollaborationStrings.terminalShareFailed + return + } + mirroredTerminalsByID[terminalID] = WeakCollaborationTerminalPanel(panel) + terminalStatesByID[terminalID] = CollaborationTerminalHeaderState( + isShared: true, + statusText: CollaborationStrings.shared, + peerSummary: peerSummary + ) + } + + private func handleRemoteTerminalOutput(terminalID: String, data: Data) { + mirroredTerminalsByID[terminalID]?.panel?.surface.processRemoteOutput(data) + } + + private func handleRemoteTerminalInput(terminalID: String, data: Data) { + guard let panel = hostedTerminalsByID[terminalID]?.panel else { return } + let text = String(decoding: data, as: UTF8.self) + switch panel.sendInputResult(text) { + case .sent: + panel.surface.forceRefresh(reason: "collaboration.terminalInput") + case .queued, .inputQueueFull, .surfaceUnavailable, .processExited: + break + } + } + + private func handleRemoteTerminalClose(terminalID: String) { + mirroredTerminalsByID.removeValue(forKey: terminalID) + hostedTerminalsByID.removeValue(forKey: terminalID) + hostedTerminalOutputSequencesByID.removeValue(forKey: terminalID) + terminalStatesByID.removeValue(forKey: terminalID) + } + private func handle(event: CollaborationEvent) async { switch event { case .documentChanged(let snapshot): @@ -620,6 +894,22 @@ final class CollaborationRuntime { case "presence.update": let presence = try decoder.decode(CollaborationPresenceWire.self, from: data) try await session?.applyRemoteFrame(.presence(presence.presenceState)) + case "terminal.open": + let open = try decoder.decode(CollaborationTerminalOpenWire.self, from: data) + handleRemoteTerminalOpen(terminalID: open.terminalID, descriptor: open.descriptor) + case "terminal.output": + let output = try decoder.decode(CollaborationTerminalOutputWire.self, from: data) + if let bytes = Data(base64Encoded: output.dataBase64) { + handleRemoteTerminalOutput(terminalID: output.terminalID, data: bytes) + } + case "terminal.input": + let input = try decoder.decode(CollaborationTerminalInputWire.self, from: data) + if let bytes = Data(base64Encoded: input.dataBase64) { + handleRemoteTerminalInput(terminalID: input.terminalID, data: bytes) + } + case "terminal.close": + let close = try decoder.decode(CollaborationTerminalCloseWire.self, from: data) + handleRemoteTerminalClose(terminalID: close.terminalID) default: break } @@ -646,6 +936,28 @@ final class CollaborationRuntime { try await sendSnapshotRequest(documentID: documentID, requestID: requestID) case .presence(let state): try await send(CollaborationPresenceWire(state: state)) + case .terminalOpen(let terminalID, let descriptor): + try await send(CollaborationTerminalOpenWire( + type: "terminal.open", + terminalID: terminalID, + descriptor: descriptor + )) + case .terminalOutput(let terminalID, let sequence, let data): + try await send(CollaborationTerminalOutputWire( + type: "terminal.output", + terminalID: terminalID, + sequence: sequence, + dataBase64: data.base64EncodedString() + )) + case .terminalInput(let terminalID, let inputID, let data): + try await send(CollaborationTerminalInputWire( + type: "terminal.input", + terminalID: terminalID, + inputID: inputID, + dataBase64: data.base64EncodedString() + )) + case .terminalClose(let terminalID): + try await send(CollaborationTerminalCloseWire(type: "terminal.close", terminalID: terminalID)) case .peerLeft: break } @@ -700,6 +1012,13 @@ final class CollaborationRuntime { for documentID in statesByDocumentID.keys { updateState(documentID: documentID, isShared: statesByDocumentID[documentID]?.isShared ?? false) } + for terminalID in terminalStatesByID.keys { + terminalStatesByID[terminalID] = CollaborationTerminalHeaderState( + isShared: terminalStatesByID[terminalID]?.isShared ?? false, + statusText: terminalStatesByID[terminalID]?.isShared == true ? CollaborationStrings.shared : connectionLabel, + peerSummary: peerSummary + ) + } } private var peerSummary: String { @@ -741,6 +1060,18 @@ final class CollaborationRuntime { ) } + private func terminalDescriptor(for panel: TerminalPanel) -> SharedTerminalDescriptor { + SharedTerminalDescriptor( + workspaceID: panel.workspaceId, + surfaceID: panel.id, + title: panel.displayTitle + ) + } + + private func terminalID(for panel: TerminalPanel) -> String { + terminalDescriptor(for: panel).terminalID(sessionID: sessionCode ?? "") + } + private func disconnectWebSocket() { heartbeatTask?.cancel() heartbeatTask = nil @@ -813,6 +1144,26 @@ enum CollaborationStrings { String(localized: "collaboration.toolbar.collaborate", defaultValue: "Collaborate") } + static var shareTerminal: String { + String(localized: "collaboration.terminal.share", defaultValue: "Share Terminal") + } + + static var stopSharingTerminal: String { + String(localized: "collaboration.terminal.stopSharing", defaultValue: "Stop Sharing Terminal") + } + + static var sharedTerminalTitle: String { + String(localized: "collaboration.terminal.sharedTitle", defaultValue: "Shared Terminal") + } + + static var noWorkspaceForTerminalShare: String { + String(localized: "collaboration.terminal.error.noWorkspace", defaultValue: "No workspace is available for the shared terminal.") + } + + static var terminalShareFailed: String { + String(localized: "collaboration.terminal.error.shareFailed", defaultValue: "Could not create the shared terminal mirror.") + } + static var shared: String { String(localized: "collaboration.status.shared", defaultValue: "Shared") } diff --git a/Sources/Mobile/MobileTerminalByteTee.swift b/Sources/Mobile/MobileTerminalByteTee.swift index 01b6675b8d2c..d70a3739f724 100644 --- a/Sources/Mobile/MobileTerminalByteTee.swift +++ b/Sources/Mobile/MobileTerminalByteTee.swift @@ -150,6 +150,10 @@ public let cmuxMobileTerminalByteTeeCallback: @convention(c) ( bytes.withMemoryRebound(to: UInt8.self, capacity: count) { rebound in let buffer = UnsafeBufferPointer(start: rebound, count: count) MobileTerminalByteTee.shared.append(surfaceID: box.surfaceID, bytes: buffer) + let data = Data(buffer: buffer) + Task { @MainActor in + CollaborationRuntime.shared.noteTerminalOutput(surfaceID: box.surfaceID, data: data) + } } } diff --git a/Sources/Panels/TerminalPanelView.swift b/Sources/Panels/TerminalPanelView.swift index 542582165de0..65d025678e01 100644 --- a/Sources/Panels/TerminalPanelView.swift +++ b/Sources/Panels/TerminalPanelView.swift @@ -62,6 +62,9 @@ struct TerminalPanelView: View { private var terminalBody: some View { VStack(spacing: 0) { + terminalHeader + Divider() + // Layering contract: terminal find UI is mounted in GhosttySurfaceScrollView (AppKit portal layer) // via `searchState`. Rendering `SurfaceSearchOverlay` in this SwiftUI container can hide it. GhosttyTerminalView( @@ -132,6 +135,46 @@ struct TerminalPanelView: View { terminalFontSize = GhosttyConfig.load(globalFontMagnificationPercent: GlobalFontMagnification.storedPercent).fontSize } } + + private var terminalHeader: some View { + let state = CollaborationRuntime.shared.state(for: panel) + return HStack(spacing: 8) { + CmuxSystemSymbolImage(systemName: panel.displayIcon ?? "terminal.fill", pointSize: 16) + .foregroundStyle(.secondary) + .frame(width: 16) + Text(panel.displayTitle) + .cmuxFont(size: 11, design: .monospaced) + .foregroundStyle(Color(nsColor: appearance.foregroundColor).opacity(0.68)) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 8) + if state.isShared { + Text(state.peerSummary) + .cmuxFont(size: 10) + .foregroundStyle(.secondary) + .lineLimit(1) + } + terminalCollaborationButton + } + .padding(.horizontal, 12) + .frame(height: 30) + .background(Color.clear) + } + + private var terminalCollaborationButton: some View { + let state = CollaborationRuntime.shared.state(for: panel) + let label = state.isShared ? CollaborationStrings.stopSharingTerminal : CollaborationStrings.shareTerminal + return PanelHeaderIconButton( + systemName: state.isShared ? "person.2.fill" : "person.2", + label: label, + isDisabled: false, + action: { + CollaborationRuntime.shared.configureOrShare(terminal: panel) + } + ) + .foregroundColor(state.isShared ? .accentColor : .secondary) + .accessibilityIdentifier("TerminalCollaborationButton") + } } private struct AgentHibernationPlaceholderView: View { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 01c87f98c149..8fa60518c28a 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1142,6 +1142,11 @@ class TerminalController { let payload = await CollaborationRuntime.shared.leaveSessionForAutomation() return .ok(payload) } + case "collaboration.terminal.share_selected": + return v2AsyncResultCall(id: request.id, timeoutSeconds: 5) { + let payload = await CollaborationRuntime.shared.shareSelectedTerminalForAutomation() + return .ok(payload) + } case "system.top": return v2Result(id: request.id, v2SystemTop(params: request.params)) case "system.memory": @@ -1873,6 +1878,8 @@ class TerminalController { ) case "collaboration.session.leave": return v2Ok(id: id, result: CollaborationRuntime.shared.leaveSessionForAutomation()) + case "collaboration.terminal.share_selected": + return v2Ok(id: id, result: CollaborationRuntime.shared.shareSelectedTerminalForAutomation()) // mobile.host.status/mobile.workspace.list/mobile.terminal.* (+terminal.* // aliases), mobile.terminal.paste/terminal.paste, and chat.sessions.dump // handled by ControlCommandCoordinator (bodies stay; shared with @@ -2018,6 +2025,7 @@ class TerminalController { "collaboration.session.create", "collaboration.session.join", "collaboration.session.leave", + "collaboration.terminal.share_selected", "system.identify", "system.tree", "sidebar.custom.open", diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/2176ee2a3478351a9ec5b654efb98bf0fc9a641724d672dec2a87767fd020f43.sqlite b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/2176ee2a3478351a9ec5b654efb98bf0fc9a641724d672dec2a87767fd020f43.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..0b58f0dca7d6ce95907cb0b322dc4802f04688c0 GIT binary patch literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AVoG{WYDXN;00+HAlr;ljiVtj n8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O6ovo**AfQj literal 0 HcmV?d00001 diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/2176ee2a3478351a9ec5b654efb98bf0fc9a641724d672dec2a87767fd020f43.sqlite-shm b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/2176ee2a3478351a9ec5b654efb98bf0fc9a641724d672dec2a87767fd020f43.sqlite-shm new file mode 100644 index 0000000000000000000000000000000000000000..5e7d93fe122cc9859b0ab4d2e192e6743ac25722 GIT binary patch literal 32768 zcmeI)uZ;ph6bIm0{x9l+q5z~*0u|5#Awir*5IiN&0K6fa%LRuc z@cojvFPThsX1)Sm|MZ!1+EGPHAKmDUsP+7J`n}m6zk0Lf;b$;DuXc;cemLqsxIcb% zOBvtGzf{YAEzVB0sNCyS&-b{mSP}vR2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+0D+egC}P$`^JQM+ zAp{-~$n#c&zY(nO&>0%@wM`pCfsS^$x}1K2n{V{5-v&=B z3-k+kvQ+H6!XNhwxVa!s)ey1X|O`C=e|00IagfB*srAbXRhly7OD)z=qKCBsd=rL%>8U&%}~lrpXC_>y$0$4dIv)z=tiY;&Dy1_H5I z-R7=_8gom`s_ByN*w%(X{Whbv`;46v$mtijwPEk8$rIa;FVHWL-5+3|Bm4VrJzv1H z;(DfEKz=X~KmY**5I_I{1Q0*~0R#|`0z;mnvdT)=$d1{HeXFLcO{%Wib$e%C@Kg2P z+4DmUf4={(|3w*p5%vq%_w)r$zre10r7u3-{^FZ{zQB~?o#KxY0R#|0009ILKmY** z5I_I{1oB9rL_T1kaP_0-iBAJn=jFO`@&fAM@Uds(>J9P&d342D3IYfqfB*srAbBcU=EBK0tg_000IagfB*srAbUp*uf1IFH^1sigj*eiu?G(9-BSmW)Y0W^0 zX;M$S!{_117vj1DxLQ{giVzoeK>sIOc5KVf{@tSF&Ff}XC`$9=o-D{^i_A3Hb0Wr= zscT8stFUxu`q{Xe_(N;5$7lPu^#be8CuX19i@HHT00Izz00bZa0SG_<0uX=z1hl}Z zRj#b87{h0U>~vLpn8rI?_zhq9)BVzR#C^d@`1K~I=^0v`r*Ryq4-~u-`~{C_Hwt$A z=zv0g@Nlx=o$Q_{syAEp@zC0dS*|QC8ACqF9=1ojhiz*nW7z&Bm*D&2Zxhj8AYQiK z3W{2LLF2tquDGr-ynC58?q&Tf?aE^nCwbhLJr&OmnLi(Bb^)zs$#3sZqaA5<+O^TU zxnX}}x*#9`0SG_<0uX=z1Rwwb2tWV=5cqEdZW$K4@A_`^$PH_|^ZH(f{h8^4fB*y_ z009U<00Izz00bZa0SG|g`UJYA6vC)w#)wdv#$vx}7I# zdi+L_AE|!zf%Mily-uP$Pi;B1w6j@rHlA*Dob@M;AE{IGJ lfB*y_009U<00Izz00bZa0SNqafd%~oz|DJKzFN9vKLF}Rm1vIr?K*v1O*~>@5*v zOjk9h>TMXRGyAOFPW++O*~`!S57h$W(N|`EF_%??fB*y_009U<00Izz00bZa0SG98 zb0c3^Thm5wvLNh9|1kEOTsSpHIJ5oSw#Oa8NjTL8r`Z`=U!+MK$un|y1>fNw?Rjp~ z@eZiY51vmKd<=RglB$hXbuzSZqUQ@ME82)pv)4Q0-Rm7=E@Qp(OD@6p#os2Pt}EWP z+Jd}R*R7pu`GRF>qsKRC{eCh?;$Cnp{V4SZL0|gwL;BALnqNTcN%Y(M(|AW3pLS#X zZmyZ%nJNefKmY;|fB*y_009U<00Izz00jOUfd`twp6+dR+ArF>-R7dYmu6ltRS*z> z00bZa0SG_<0uX=z1Rwwb2;7}OFSj~s^ko)AK^9z_X>nmFhr=WtxV7TK^u@inG#TAa zBRM;MFH4W)p!m!ROOIZ=PPcFY1s%1X_=-rv< literal 0 HcmV?d00001 diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/a9e8393d05838637ddaf02a111008625c2d5563786ecc264864a6ddff28587ea.sqlite-wal b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/a9e8393d05838637ddaf02a111008625c2d5563786ecc264864a6ddff28587ea.sqlite-wal index 54048f423390e6dd84203195af9b48cf3a374de7..3cb53d980a738ee4c8f1f62ac0a6549a33cea18c 100644 GIT binary patch delta 2550 zcmZYAc{E&C90%|*7#)X@SUM`>h@kdR>nLgqsYr|?NQpR<68koSQ!#0XZAiv)Y>kja zBdv&J&>#e3ED5!QAeIR_il7ym5VWQ5`Xhe#ocG`7eBZtA_j|v4y%qoXfn=3P?(p&txRBs;HY{UGkApxk{GGbAY1{2TT%F+XK|qzBP9F{W8lxA^|n+sjQlRDs|3i9mBh_HsniUNydk>u z19DW46{7R7g^H24f^qSHM3PAJlQwnP7}?+}Rt02C#`yJ$2|F4i|8BPo0g@elWX9&D zvCIznASTyF*x+3TW-5wkNjT+m0~yhGL``61jr@Fdiz0K8k$kH$3QVkX%a7swvMb2Q zE>n|$VX_4-Cgm`vkWpBO(gDU&gwby896E%I>TKQwFoAtJ1y_=N;*rswZb$_t`{#4! z{FZ86$QVv?^?_jwik-Y(u(g1U*%*Eb7)Qa8Y3igg8=2$7${D~slu*yTb=Y+f8T%J@ z6kwV}IMr?Cm1Ja`dV_huw0;%spqgm9+sC{0a(m8X0>jdiHkfw*X&4!=jv7;7{xPlI zuxoZ}pW*}qq>(5G9uz>sMaiAqi43zQT6MBW0JOD^17IbtDuHI094 zwFJ!Mm+Z2OBPD?-C$ZYE02mr0EU2^C;xaPnRfWmTxP1d9-@751r_i&COjbpcE-*E} z@!EsytQ?UkD4*g2XXMHrYJ55;iO3xe`k*O(27yt%$-)q}$+nj{VliXH3eSePDXKv%=RDTvSY$mS~ zn1z*Czh4h}9Yf|>CeaO;6?3EZ9Q^5<$n>Xa5`fW6Un2_nUWr6z(r51i{^R* zGj>SAC$m|_8kx;V{sX`yre@qVT3eRF3oTo24Wewy^FNUhy_ZRa245CiSL1BUu{ ze9MKrK2K!C{Xz}_^Xm3!Oh{MrS!6!_me~YMk#f{?@`e`?89C3I0AS?#J`o<^caA_t z$^Cs1ZfAYeW6~ns$KTmh>|WQqd)7EBYYWVK8P7Z4_jP+CqkG0|6&UStLBpu5^$KK+ zso_)R``=~bSbBOcZ4&i7$HG201DGuP%-D_&2R)Q?;zS(<82lO`g+!|vK*qs(oCi#V zFE_o`a#|A^7YiH{7^zNiwQE5a(5&Nlm@1kABRum5ZF$7N3g!4vZ0CWI%PcUdU6nUR F{|~hSyWRi* delta 9 Qcmdn7USz@{rVRoM0Tze^F#rGn diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/metadata.sqlite-wal b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/metadata.sqlite-wal index 17d151c2168f2f72b6334a234be335c4fea32a9a..0e80af47506d3d38a7f5a395a2d97b4e18ae0d52 100644 GIT binary patch literal 453232 zcmeI*dz4jG;lS}>RQh2eX#NnTbTmal{B<95A7efS1@VDli1 zs1%ZEYCj(sA(g2801*tyM?S*$D@hVVN-`x&&Fl>IYu&xp{5Pky&a6InA?uvMv)A6A zeg5IQHZz5bdtcJD=jwq?P5qlXKXXfeTHdzZ8-u>{N@dBZbC0|}`ckjPuMeBI^(8m- z9lG#{FOHr#wc1iUeadO2sV&*)>E`C9_Li3D|84%~f1QtYembAubmP@Oe%m-+!+lqm z%}xJv(b0XQKk3zPYE$&fhX4Tr1PBlyK!5-N0t5*B#|TX8)9Vx4?zm&GX{D*9j#H~A zlxrspA3c2RVgI^lK49#~;p0ZOj2piH=#ee|`f|(eo40KIl*&_2pK`*)O3S#BM~`bc zeC)x84Ig_<%b_EW8TQZr;mq>M)yi3?Rx2k=op@Tc<=`)j8#!v^*o}WUq~)lC#~n20 z$Z;)W#~gL=hzNb--}C>!7rg#a$Kb67FP^@6=WhHXCH%+ii-i&(K!5-N0t5&UAV7cs zf&V~(pbH%I`a!on`^nvAbasJW{T4JuzkCP~AV7cs0RjXF5FkK+0D^t)sQrkdQBseXcIhAnuX?0RjXF5FkK+009C72qZ@!t}bx=!XGYO7A-62 z0v{i@?MttG5FkK+009C72oNAZpg|z6E-+>OfaS}gaf2=}`E&ig``81IUDnwJI)6@~pGVLM z;X{A`0RjXF5FkK+009C75+m?$?E-%~dW-QVezM^E3nXT{O@sge0t5&UAV7cs0RjXF z1OgkoK&h=-%(i7Ksa!EzjD8ehrBbS;GNp7nok|t+nOr$j$>pN27xUF}Ig@WK=37ho zN~KoI6m!KwwG{oF!V7vWdafRg9Gn-(pZ?7Dx1O@c?*1J?VD5$h0RjXF5FkK+009C7 z2qZ-ywk|MgiznNoMFm}8?%G@O*DhLjk}i;x?KTer1PBlyK!5-N0t5&UAP@+|)dem& zAay}|G;+`d?#vF{{Ii$u_k=DGn7biBfB*pk1PBlyK!5-N0!a~ws|$Q%zj3>@M~e!& zKx*}`&tAIx-6gs}QnuSX2oNAZfB*pk1PBlyK!89X5LXwt`t#>J-5!k`bb*6sU-i=W zKb*FWE)bZzAwYlt0RjXF5FkK+009C?5s0e`%r0DiaeK6=pbJbr`M(D|HQ?y1E|8S% zHV*;>2oNAZfB*pk1PBly5D3K81#ZkP-J?AkIp_i}eAM#I>;wHj&;Hv|X}AV7cs z0RjXF5FkJxDFShIf!jW}`uX-~Q9&1I{?_UNUo0H;s4kF{?KTer1PBlyK!5-N0t5&U zAP@+|)dl7a>wRf^G;+`dN*Dd_lwUW!`<^Zkn7biBfB*pk1PBlyK!5-N0!a~ws|zgH zz3q&8w5Xs9)LtL-w`Ud)ny(8aWxLIT009C72oNAZfB*pk1PBBIadm-3yBzKw$2M009C72oNAZfB*pk1PCNWAg(U(Kui6RdbFsZ3(PqDy1`#P zYU2L7KvK5bJO~gVK!5-N0t5&UAV7dXAP`ph-V4!XeAuME886F=X3UtJ(D zcSC>x0RjXF5FkK+009C7k|GdS7kF~u!#mZZMFm|Tef3{woOi(sy>)@4Y`1w3AV7cs z0RjXF5FkK+0D(Xtt}gK0Hotqk9*rDyf#xxn%>D3+d#CFHfw>z31PBlyK!5-N0t5&U zAdnP+xVpfvw(56YJz7-I1vU(OX2wa6*WT6zlCs_AL4W`O0t5&UAV7cs0RjX9fw;QB z>wUAk)}xVwF0lEfk8FSC@wcC)3k2qF2oNAZfB*pk1PBlyK!89}1mfxfzw0x8Wj$I{ z&;>r;yE^}H;5s^uV+AXyl*^?7Cw2mzLaq>m#~AVD5$h0RjXF z5FkK+009C72qZ-yrY`Vc@AubCt4E6px2oNAZfB*pk z1PBly5D3K81^T|%XU}>xa?k~yuZ_B7ee+d6&;Hv|X}AV7cs0RjXF5FkJxDFShI zforsrl#xBqJl1P`RhX_ zeD98df6xVzvfbuEfB*pk1PBlyK!5-N0t5nqxVpfQH)j8;9*rDyfzsBGjJ@iDotzg4 z$lVYiK!5-N0t5&UAV7csfy4;J)df}{?^>e^Bxb)&ga82o1PBlyK!5-N0t5&&3dGd~ zMm|6I%6c?#&;?dDy*+pS%|p)B1scg+6Cgl<009C72oNAZfB=ES2*lL|Mn8Q>TRmD; z&;{1scWCb)uGwW1T_7?0Z6X8+5FkK+009C72oNAZpiv;ME^x$?r>&_+0|#AT*ByuL z_1eH`gLQ#Ma@Pb15FkK+009C72oNAZATa`Qb%A4lam_XLXjMTMs7xC;_%myN+)Eco z%zm2)0RjXF5FkK+009C72oPu#h^q^f9=d0rdNgp*1>PG!W$4^V>sxh!Msn8#2oNAZ zfB*pk1PBlyKp-&!adm-{m%s34Jz7=J1yWo7ZgBsbQmb@<#O$|;5FkK+009C72oNAZ zfB=C;fw;QB=}Z4Qs~!y;bb&r^_B;Q}=}EWf0*&OZ2@oJafB*pk1PBlyK!8AE1mfxf zXW!Q{ydJG8=mKr4PMq-gyzHlRfyC^$i4Y(_fB*pk1PBlyK!5;&MuE7xz=i*Jz+3fb z;GhevyydaGCXbnYlP=Im?wSAr0t5&UAV7cs0RjXFBt{^vE^yi1CtX*MRuy!C?Itf8 zUs$-?Il4e%_S-}V5FkK+009C72oNAZfIy=_TwP%LkH7o%WzoPv7dSV4+0e`xw@udt z8p&M~AV7cs0RjXF5FkK+0D;5^#MK36&bvRmELv621yaAfW6S;KjMD`Yv)v{_fB*pk z1PBlyK!5-N0t5nqxVpe~cf7W0Su}Fc1>S%8!PRdLcy_Jx0)e?30t5&UAV7cs0RjXF z5Fn5gfw;QB&9`kfeOa`qpbKpE@hz`D{m!E!b%CU8w|NjCK!5-N0t5&UAV7csfj}Ux zE->f&Lki2Hk%KO9*_3q0?`Mzg&;Hv|X}AV7cs0RjXF5FkJxDFShIf%)G%ZgA(C zf-W%smH}VLkDT+dE|8S%HV*;>2oNAZfB*pk1PBly5D3K81@5`-;-@+X4!XeYTTE`A zSlHqfT_7-bLx2DQ0t5&UAV7cs0RjY)A`n*>xNp|an(NV`f-dl*5m!vu{=?@M>Hv3fB*pk1PBlyK!5-N0*Mibs|&pF zwZreKN2?0Dz!_g3yMFS7JtjIYkeK~85ds7V5FkK+009C72oNC9C=gc{Sar$N3H4~; zpbI>4GN@Z)Qd@)~YtF>j*rF5xSsAkHUflD5I>$p|@ z+W)OxV9`}4AHMy}ckPM)n!aJT zS-L=ve9=sr009C72oNAZfB*pk1QIEb$S$zq>{T80h$}cRa8~aA0lgpEZjdgJ$QQ|^ z2oNAZfB*pk1PBlyK%hqo#MTA=I$}oC#=%N$)nc|STS?`L*H>*;kxYsJ0RjXF5FkK+009C7dZa*1 zU0_A?hUss_bzY$BF0kdHOJ_{o?2Ns2fgbsynKS_c1PBlyK!5-N0t5&oQXr9CpwFa3 zYW0XKI4|(4UZ2gL^YF{fx0{wsg^uDp37wEDJtev!E=Ye0kXtXZS zBVRO=CP07y0RjXF5FkK+0D(jbB(e)^TfXN0dc+l+7Z`Wrgv}>jby`&yNaTxTQUnMP zAV7cs0RjXF5FpSa1>))g+pc|YU2NwCy6gf+E_{03a}%zgp$qiL7tN#z5FkK+009C7 z2oNAZAdv!z>;gNSaL+0Ah$}cR@X-&R8o&PDK{x3FiF}bviU0uu1PBlyK!5-N0t9-b zKwMp5hc(+@8{2t-F1x_WXG^CH`P55wU7$z4XeLd7009C72oNAZfB*pki4;g=7ufYn zf4;s`D>yIk+n+vo=k=$4`iRah(5v5qrhW^e-+TxVAV7cs0RjXF5FkK+0D*1@^qekm z`q{fB*pk1PBlyK!5-N0t5nqM0SB;M-Lj+Im_U@z*&WP4}ZSj zoqkSXuzzj{5FkK+009C72oNAZfIxQyHg{2_SOX&_s=x}0t5&U zAV7cs0RjXF5a^CTBD=s|hoA9Ud$g+Hyuihmw{Euj>>WPP1-i4}o+Utl009C72oNAZ zfB*pkjRJ9Xf&HGod#~8e3v}59&THBKlsPLetmp!b`{$Yf0RjXF5FkK+009C72y{mv zkzHW;=vh~`N2?0X3#?e%yl&y&=AWetbZ5UkOMn0Y0t5&UAV7cs0RjXX1>))g2R`}y z8?l`i=&}nOu+{ndKQQwXd0n7!|6CIwK!5-N0t5&UAV7csf$j(-vI`t^(89L%XjQ>^ zfxmtG(_ibqzV%~Wpga5RSpozI5FkK+009C72oNC9C=gc{IP4c6&W!E6K$l(M+qY)M z?s50jqAt+5f367-AV7cs0RjXF5FkK+Kz9TZ*#*Xocx6p{w5s5|z+ZNH=z~+PKYpPu z(4GDEECB)p2oNAZfB*pk1PBml6o{(}jC**;ePcT>&}A1`ch~Y`2W-1^k}lA=f367- zAV7cs0RjXF5FkK+Kz9TZ*#*WAZ@Q*ET2*jfVCbyF`@FQx(s{Z-clO(}1PBlyK!5-N z0t5&UAV8o|Ag(U(r3d$47u$J(F1tYf%9~q{`_25X>H>}X=b8Wk0t5&UAV7cs0RjXF zbVneOUEuhAcG;&rT2*jfVDe`-yXo2+COn}FbZ5UkOMn0Y0t5&UAV7cs0RjXX1>))g z9m{Ih#&%wy%Pugd{{C~X{$#@+bb-eGb4`E%0RjXF5FkK+009C7x+9RtE>LM3^=5mt zs^Gl9q4VGEf6nt)Y~I-gdi7h-)Neubn-2j31PBlyK!5-N0t5&UAkYngp3?;m*&(xT z>+AM8Oc&_Jc6*cn0RjXF5FkK+009C72m}I&>;jW=lV-I?(+tiF9QDQrD~C1boBTV1 zU<2I{AV7cs0RjXF5FkK+0D*1@Z0rJ=bRl1@W=h%CLNS|5m)ff3Tt`0FTCH`o6+3FF zTDh&1Z%r36t(ANsQ>m6SrDCCwFVrfjOsdwJ9k^u0sc)Y>B)0D_&}A2xwETc~MjpG# zJ-R?Qw%elw2oNAZfB*pk1PBlyKp+r^s|!qCI&7Erje|v1p_pyUR#LfQwwTWrDwR?# zl_{mu=~Swi&*aLPN-h_Dy_m0-%b9#@G2dFsS1PqyrkE=hs--A0blC+`y%znp_YFt> zOcw|?&^uD9BwMFr;tDtrI&H@gklcfBsqjqUa*0RjXF5FkK+009C7 z2oMMa;_3ol`RSQoiS4{VmtA0=o$f#3@~0NPqzeQa=!O6R0t5&UAV7cs0RjXFbVDGK zU7$W>&CB&@QNekE7uO$r@in6_o~sLVW4k>{fB*pk1PBlyK!5-N0t5nqxVpfl3uf;g K+j)VFUEuEkTAMHc delta 9 QcmexxMf$@b<_!f80UaF$mH+?% diff --git a/workers/collaboration/test/session-state.test.ts b/workers/collaboration/test/session-state.test.ts index ac8f2cbe7a92..88ccb3fbae63 100644 --- a/workers/collaboration/test/session-state.test.ts +++ b/workers/collaboration/test/session-state.test.ts @@ -57,6 +57,42 @@ test("forwards opaque non-heartbeat frames to other peers", () => { }); }); +test("forwards terminal collaboration frames to other peers", () => { + const state = new CollaborationRelaySessionState(); + const first = new FakeSocket(); + const second = new FakeSocket(); + state.addPeer("ABCD-1234", peer("p1"), first, 1000); + state.addPeer("ABCD-1234", peer("p2"), second, 1000); + + state.handleMessage( + "p1", + JSON.stringify({ type: "terminal.output", terminalID: "term1", sequence: 7, dataBase64: "b2s=" }), + 1100 + ); + state.handleMessage( + "p2", + JSON.stringify({ type: "terminal.input", terminalID: "term1", inputID: "i1", dataBase64: "ZWNobyBvaw0=" }), + 1200 + ); + + expect(JSON.parse(second.sent.at(-1) ?? "{}")).toEqual({ + type: "terminal.output", + terminalID: "term1", + sequence: 7, + dataBase64: "b2s=", + fromPeerID: "p1", + receivedAt: 1100, + }); + expect(JSON.parse(first.sent.at(-1) ?? "{}")).toEqual({ + type: "terminal.input", + terminalID: "term1", + inputID: "i1", + dataBase64: "ZWNobyBvaw0=", + fromPeerID: "p2", + receivedAt: 1200, + }); +}); + test("rejects malformed frames and broadcasts peer departure", () => { const state = new CollaborationRelaySessionState(); const first = new FakeSocket(); From dca6d8c9748442a18bcc2b443034245f584a3d83 Mon Sep 17 00:00:00 2001 From: DorsaRoh Date: Tue, 30 Jun 2026 03:10:49 -0700 Subject: [PATCH 3/5] cursors --- .../TerminalMouseReportFilter.swift | 86 +++ .../TerminalMouseReportFilterTests.swift | 44 ++ Sources/CollaborationRuntime.swift | 260 +++++++- Sources/GhosttyTerminalView.swift | 600 +++++++++++++++++- cmuxTests/TerminalAndGhosttyTests.swift | 43 ++ ...a60430cf3b0d1fb1d6b52abbbeed3662534.sqlite | Bin 0 -> 4096 bytes ...0cf3b0d1fb1d6b52abbbeed3662534.sqlite-shm} | Bin 32768 -> 32768 bytes ...30cf3b0d1fb1d6b52abbbeed3662534.sqlite-wal | Bin 0 -> 90672 bytes ...bf0fc9a641724d672dec2a87767fd020f43.sqlite | Bin 4096 -> 16384 bytes ...c9a641724d672dec2a87767fd020f43.sqlite-wal | Bin 41232 -> 0 bytes ...8dba58df4e4e4d0d123c5aba165898e40d7.sqlite | Bin 0 -> 16384 bytes ...e3640a5a436f7a8802ab11e31068e79e205.sqlite | Bin 0 -> 16384 bytes ...8a63ed3890a890aed9ca101af1ac429312a.sqlite | Bin 0 -> 16384 bytes ...9ff492d0f4fa75b748c61ac002cef767e67.sqlite | Bin 0 -> 16384 bytes ...4255ffb66998e63abf461346e712a82b17a.sqlite | Bin 0 -> 16384 bytes ...838a53329c465f20cb26bd92c581dbc6c84.sqlite | Bin 0 -> 16384 bytes ...c886f3d34eaebd01c6994bf88c6e014c97b.sqlite | Bin 0 -> 16384 bytes ...2038bc30ec767d07f3bbb69df1525f4ff5b.sqlite | Bin 0 -> 8192 bytes ...ea4c8d6a631bd96af46012b1e77875d7b4b.sqlite | Bin 0 -> 16384 bytes ...2a5a59dcf5cb549c6b8ca293916a0045e01.sqlite | Bin 0 -> 16384 bytes ...625c2d5563786ecc264864a6ddff28587ea.sqlite | Bin 4096 -> 16384 bytes ...2d5563786ecc264864a6ddff28587ea.sqlite-shm | Bin 32768 -> 0 bytes ...2d5563786ecc264864a6ddff28587ea.sqlite-wal | Bin 333752 -> 0 bytes ...b7f02af105c7610872008f024096afac8d7.sqlite | Bin 0 -> 16384 bytes ...0a96ae46b4ed46279578a09474dc39cc1b8.sqlite | Bin 0 -> 16384 bytes ...ea5799f71e1ddd4cb5af2330b36927da00a.sqlite | Bin 0 -> 16384 bytes ...69c4c90288e408d715546a56c1714a4816f.sqlite | Bin 0 -> 16384 bytes ...7193e7ef291afcc3810749c1ecfff805af0.sqlite | Bin 0 -> 16384 bytes ...590bf5eca1eec1dbf73744c99a2734297b9.sqlite | Bin 0 -> 16384 bytes .../metadata.sqlite-shm | Bin 32768 -> 32768 bytes .../metadata.sqlite-wal | Bin 453232 -> 1676872 bytes .../collaboration/test/session-state.test.ts | 30 + 32 files changed, 1039 insertions(+), 24 deletions(-) create mode 100644 Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/TerminalMouseReportFilter.swift create mode 100644 Packages/Shared/CmuxCollaboration/Tests/CmuxCollaborationTests/TerminalMouseReportFilterTests.swift create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/182a53b53cc95c92b7d921e8c6de3a60430cf3b0d1fb1d6b52abbbeed3662534.sqlite rename workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/{2176ee2a3478351a9ec5b654efb98bf0fc9a641724d672dec2a87767fd020f43.sqlite-shm => 182a53b53cc95c92b7d921e8c6de3a60430cf3b0d1fb1d6b52abbbeed3662534.sqlite-shm} (98%) create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/182a53b53cc95c92b7d921e8c6de3a60430cf3b0d1fb1d6b52abbbeed3662534.sqlite-wal delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/2176ee2a3478351a9ec5b654efb98bf0fc9a641724d672dec2a87767fd020f43.sqlite-wal create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/255f587fd05658b4177459e7215348dba58df4e4e4d0d123c5aba165898e40d7.sqlite create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/29fcbd2ad44f175c7eb1bbb784f10e3640a5a436f7a8802ab11e31068e79e205.sqlite create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/3f0f95a5f37dea778e45b46506f858a63ed3890a890aed9ca101af1ac429312a.sqlite create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/4424c75383480fc20ada4e4dcd84a9ff492d0f4fa75b748c61ac002cef767e67.sqlite create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/49f82606dd56e5349b850d37cbc274255ffb66998e63abf461346e712a82b17a.sqlite create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/58577c2c6c0e91055de31dad3b883838a53329c465f20cb26bd92c581dbc6c84.sqlite create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/66a5a07644ee1e9d3514083f4b227c886f3d34eaebd01c6994bf88c6e014c97b.sqlite create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/85a8afb99f3b6096356cfc01c57152038bc30ec767d07f3bbb69df1525f4ff5b.sqlite create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/8e7af33eae36ae7d9a08cf0bafd7aea4c8d6a631bd96af46012b1e77875d7b4b.sqlite create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/966f8008da9afa6bf1f283470e9a22a5a59dcf5cb549c6b8ca293916a0045e01.sqlite delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/a9e8393d05838637ddaf02a111008625c2d5563786ecc264864a6ddff28587ea.sqlite-shm delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/a9e8393d05838637ddaf02a111008625c2d5563786ecc264864a6ddff28587ea.sqlite-wal create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/c1000d36cb8b0052429c6b47892acb7f02af105c7610872008f024096afac8d7.sqlite create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/df9c171be52cb4e34c2a23333b13a0a96ae46b4ed46279578a09474dc39cc1b8.sqlite create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/e4d786a9d3f442592c6dee4715e15ea5799f71e1ddd4cb5af2330b36927da00a.sqlite create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/e6ea82d3126efcf151316cd7c7e9369c4c90288e408d715546a56c1714a4816f.sqlite create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/ef72c880cc9ffbbd112dda9c873527193e7ef291afcc3810749c1ecfff805af0.sqlite create mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/f7f9bcbdd88d6aef504e3c4007c80590bf5eca1eec1dbf73744c99a2734297b9.sqlite diff --git a/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/TerminalMouseReportFilter.swift b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/TerminalMouseReportFilter.swift new file mode 100644 index 000000000000..161a0eda9398 --- /dev/null +++ b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/TerminalMouseReportFilter.swift @@ -0,0 +1,86 @@ +public import Foundation + +/// Removes terminal mouse-report escape sequences from shared terminal input. +/// +/// Shared terminal mirrors render remote output in a local terminal surface. If +/// that mirror surface enters xterm mouse-reporting mode, ordinary pointer +/// movement can generate escape sequences such as `ESC[<35;12;4M`. Those bytes +/// are terminal protocol reports, not intentional keyboard input, so they must +/// not be forwarded into the authoritative PTY. +public struct TerminalMouseReportFilter: Sendable { + /// Creates a terminal mouse report filter. + public init() {} + + /// Returns `data` with complete xterm mouse-report escape sequences removed. + /// - Parameter data: Input bytes produced by a shared terminal mirror. + /// - Returns: The input bytes with mouse reports stripped. + public func filtering(_ data: Data) -> Data { + guard !data.isEmpty else { return data } + let bytes = Array(data) + var output: [UInt8] = [] + output.reserveCapacity(bytes.count) + + var index = 0 + while index < bytes.count { + if let end = mouseReportEnd(in: bytes, startingAt: index) { + index = end + continue + } + + output.append(bytes[index]) + index += 1 + } + + return output.count == bytes.count ? data : Data(output) + } + + private func mouseReportEnd(in bytes: [UInt8], startingAt index: Int) -> Int? { + guard index + 2 < bytes.count else { return nil } + guard bytes[index] == 0x1B, bytes[index + 1] == 0x5B else { return nil } + + if bytes[index + 2] == 0x4D { + // Legacy X10/UTF-8 format: ESC [ M Cb Cx Cy. UTF-8 coordinates can + // make this longer, but Ghostty's normal X10 writes are six bytes. + let end = index + 6 + return end <= bytes.count ? end : nil + } + + if bytes[index + 2] == 0x3C { + // SGR / SGR-pixels format: ESC [ < Cb ; Cx ; Cy M|m. + return csiMouseReportEnd(in: bytes, startingAt: index + 3) + } + + // URXVT format: ESC [ Cb ; Cx ; Cy M. + return csiMouseReportEnd(in: bytes, startingAt: index + 2) + } + + private func csiMouseReportEnd(in bytes: [UInt8], startingAt index: Int) -> Int? { + var cursor = index + var parameterCount = 0 + + while cursor < bytes.count { + let byte = bytes[cursor] + if isDigit(byte) { + cursor += 1 + continue + } + + if byte == 0x3B { + parameterCount += 1 + cursor += 1 + continue + } + + guard parameterCount == 2, byte == 0x4D || byte == 0x6D else { + return nil + } + return cursor + 1 + } + + return nil + } + + private func isDigit(_ byte: UInt8) -> Bool { + byte >= 0x30 && byte <= 0x39 + } +} diff --git a/Packages/Shared/CmuxCollaboration/Tests/CmuxCollaborationTests/TerminalMouseReportFilterTests.swift b/Packages/Shared/CmuxCollaboration/Tests/CmuxCollaborationTests/TerminalMouseReportFilterTests.swift new file mode 100644 index 000000000000..a40ff97ad1ac --- /dev/null +++ b/Packages/Shared/CmuxCollaboration/Tests/CmuxCollaborationTests/TerminalMouseReportFilterTests.swift @@ -0,0 +1,44 @@ +import CmuxCollaboration +import Foundation +import Testing + +@Suite +struct TerminalMouseReportFilterTests { + private let filter = TerminalMouseReportFilter() + + @Test + func removesSGRMouseMotionReport() { + let input = Data("a\u{1B}[<35;12;4Mb".utf8) + + let filtered = filter.filtering(input) + + #expect(String(decoding: filtered, as: UTF8.self) == "ab") + } + + @Test + func removesSGRMouseReleaseReport() { + let input = Data("a\u{1B}[<0;12;4mb".utf8) + + let filtered = filter.filtering(input) + + #expect(String(decoding: filtered, as: UTF8.self) == "ab") + } + + @Test + func removesLegacyX10MouseReport() { + let input = Data([0x61, 0x1B, 0x5B, 0x4D, 0x43, 0x21, 0x22, 0x62]) + + let filtered = filter.filtering(input) + + #expect(String(decoding: filtered, as: UTF8.self) == "ab") + } + + @Test + func keepsOrdinaryKeyboardInputAndIncompleteEscapes() { + let input = Data("echo ok\r\u{1B}[<35;12".utf8) + + let filtered = filter.filtering(input) + + #expect(filtered == input) + } +} diff --git a/Sources/CollaborationRuntime.swift b/Sources/CollaborationRuntime.swift index 85d86f4d80c1..1b468b303406 100644 --- a/Sources/CollaborationRuntime.swift +++ b/Sources/CollaborationRuntime.swift @@ -76,6 +76,24 @@ private struct CollaborationTerminalOutputWire: Codable { let terminalID: String let sequence: UInt64 let dataBase64: String + let fromPeerID: String? + let caretPeerID: String? + + init( + type: String, + terminalID: String, + sequence: UInt64, + dataBase64: String, + fromPeerID: String? = nil, + caretPeerID: String? = nil + ) { + self.type = type + self.terminalID = terminalID + self.sequence = sequence + self.dataBase64 = dataBase64 + self.fromPeerID = fromPeerID + self.caretPeerID = caretPeerID + } } private struct CollaborationTerminalInputWire: Codable { @@ -83,6 +101,39 @@ private struct CollaborationTerminalInputWire: Codable { let terminalID: String let inputID: String let dataBase64: String + let fromPeerID: String? + + init(type: String, terminalID: String, inputID: String, dataBase64: String, fromPeerID: String? = nil) { + self.type = type + self.terminalID = terminalID + self.inputID = inputID + self.dataBase64 = dataBase64 + self.fromPeerID = fromPeerID + } +} + +private struct CollaborationTerminalPointerWire: Codable { + let type: String + let terminalID: String + let fromPeerID: String + let x: Double + let y: Double + let visible: Bool +} + +private struct CollaborationTerminalSelectionRectWire: Codable { + let x: Double + let y: Double + let width: Double + let height: Double +} + +private struct CollaborationTerminalSelectionWire: Codable { + let type: String + let terminalID: String + let fromPeerID: String + let rects: [CollaborationTerminalSelectionRectWire] + let visible: Bool } private struct CollaborationTerminalCloseWire: Codable { @@ -152,6 +203,10 @@ private final class WeakCollaborationTerminalPanel { } } +private struct TerminalOutputCaretSuppression { + let expiresAt: Date +} + struct CollaborationTerminalHeaderState: Equatable { var isShared = false var statusText = "" @@ -182,9 +237,13 @@ final class CollaborationRuntime { private var hostedTerminalsByID: [String: WeakCollaborationTerminalPanel] = [:] private var hostedTerminalIDsBySurfaceID: [UUID: String] = [:] private var hostedTerminalOutputSequencesByID: [String: UInt64] = [:] + private var hostedTerminalOutputCaretSuppressionsByID: [String: TerminalOutputCaretSuppression] = [:] private var mirroredTerminalsByID: [String: WeakCollaborationTerminalPanel] = [:] + private var mirroredTerminalIDsBySurfaceID: [UUID: String] = [:] private var terminalStatesByID: [String: CollaborationTerminalHeaderState] = [:] private var peersByID: [String: CollaborationPeerWire] = [:] + private var terminalPointerLastSentAtBySurfaceID: [UUID: TimeInterval] = [:] + private var terminalSelectionLastSentAtBySurfaceID: [UUID: TimeInterval] = [:] private var snapshotFallbackTasks: [String: Task] = [:] private init() { @@ -234,8 +293,9 @@ final class CollaborationRuntime { func leave(terminal: TerminalPanel) { let terminalID = terminalID(for: terminal) hostedTerminalsByID.removeValue(forKey: terminalID) - hostedTerminalIDsBySurfaceID.removeValue(forKey: terminal.id) + removeTerminalSurfaceMappings(for: terminalID) hostedTerminalOutputSequencesByID.removeValue(forKey: terminalID) + hostedTerminalOutputCaretSuppressionsByID.removeValue(forKey: terminalID) mirroredTerminalsByID.removeValue(forKey: terminalID) terminalStatesByID.removeValue(forKey: terminalID) Task { @@ -262,6 +322,116 @@ final class CollaborationRuntime { } } + func noteTerminalPointer(surfaceID: UUID, point: CGPoint?, bounds: CGRect, visible: Bool) { + let terminalID = hostedTerminalIDsBySurfaceID[surfaceID] ?? mirroredTerminalIDsBySurfaceID[surfaceID] + guard let terminalID else { return } + + let now = ProcessInfo.processInfo.systemUptime + if visible { + let lastSentAt = terminalPointerLastSentAtBySurfaceID[surfaceID] ?? 0 + guard now - lastSentAt >= (1.0 / 60.0) else { return } + terminalPointerLastSentAtBySurfaceID[surfaceID] = now + } else { + terminalPointerLastSentAtBySurfaceID.removeValue(forKey: surfaceID) + } + + let normalizedX: Double + let normalizedY: Double + if visible, let point, bounds.width > 0, bounds.height > 0 { + normalizedX = Double(min(max(point.x / bounds.width, 0), 1)) + normalizedY = Double(min(max(point.y / bounds.height, 0), 1)) + } else { + normalizedX = 0 + normalizedY = 0 + } + + Task { + try? await send(CollaborationTerminalPointerWire( + type: "terminal.pointer", + terminalID: terminalID, + fromPeerID: peerIdentity.peerID, + x: normalizedX, + y: normalizedY, + visible: visible + )) + } + } + + func noteTerminalSelection(surfaceID: UUID, rects: [CGRect], bounds: CGRect, visible: Bool) { + let terminalID = hostedTerminalIDsBySurfaceID[surfaceID] ?? mirroredTerminalIDsBySurfaceID[surfaceID] + guard let terminalID else { return } + + let now = ProcessInfo.processInfo.systemUptime + if visible { + let lastSentAt = terminalSelectionLastSentAtBySurfaceID[surfaceID] ?? 0 + guard now - lastSentAt >= (1.0 / 12.0) else { return } + terminalSelectionLastSentAtBySurfaceID[surfaceID] = now + } else { + terminalSelectionLastSentAtBySurfaceID.removeValue(forKey: surfaceID) + } + + let normalizedRects: [CollaborationTerminalSelectionRectWire] + if visible, bounds.width > 0, bounds.height > 0 { + normalizedRects = rects.compactMap { rect in + let clipped = rect.intersection(bounds) + guard !clipped.isNull, clipped.width > 0, clipped.height > 0 else { return nil } + return CollaborationTerminalSelectionRectWire( + x: Double(clipped.minX / bounds.width), + y: Double(clipped.minY / bounds.height), + width: Double(clipped.width / bounds.width), + height: Double(clipped.height / bounds.height) + ) + } + } else { + normalizedRects = [] + } + + Task { + try? await send(CollaborationTerminalSelectionWire( + type: "terminal.selection", + terminalID: terminalID, + fromPeerID: peerIdentity.peerID, + rects: normalizedRects, + visible: visible && !normalizedRects.isEmpty + )) + } + } + + private func peerVisibleToThisClient(_ peerID: String?) -> CollaborationPeerWire? { + guard let peerID, peerID != peerIdentity.peerID else { return nil } + return peersByID[peerID] ?? CollaborationPeerWire( + peerID: peerID, + displayName: peerID, + color: peerIdentity.color + ) + } + + private func terminalOutputPeerID(for terminalID: String) -> String? { + if let suppression = hostedTerminalOutputCaretSuppressionsByID[terminalID] { + if suppression.expiresAt > Date() { + return nil + } + hostedTerminalOutputCaretSuppressionsByID.removeValue(forKey: terminalID) + } + return peerIdentity.peerID + } + + private func removeTerminalSurfaceMappings(for terminalID: String) { + let hostedSurfaceIDs = hostedTerminalIDsBySurfaceID + .filter { $0.value == terminalID } + .map(\.key) + let mirroredSurfaceIDs = mirroredTerminalIDsBySurfaceID + .filter { $0.value == terminalID } + .map(\.key) + + hostedTerminalIDsBySurfaceID = hostedTerminalIDsBySurfaceID.filter { $0.value != terminalID } + mirroredTerminalIDsBySurfaceID = mirroredTerminalIDsBySurfaceID.filter { $0.value != terminalID } + for surfaceID in hostedSurfaceIDs + mirroredSurfaceIDs { + terminalPointerLastSentAtBySurfaceID.removeValue(forKey: surfaceID) + terminalSelectionLastSentAtBySurfaceID.removeValue(forKey: surfaceID) + } + } + func leave(panel: any CollaborationEditablePanel) { guard let session else { return } let descriptor = descriptor(for: panel) @@ -423,9 +593,13 @@ final class CollaborationRuntime { hostedTerminalsByID.removeAll() hostedTerminalIDsBySurfaceID.removeAll() hostedTerminalOutputSequencesByID.removeAll() + hostedTerminalOutputCaretSuppressionsByID.removeAll() mirroredTerminalsByID.removeAll() + mirroredTerminalIDsBySurfaceID.removeAll() terminalStatesByID.removeAll() peersByID.removeAll() + terminalPointerLastSentAtBySurfaceID.removeAll() + terminalSelectionLastSentAtBySurfaceID.removeAll() connectionLabel = CollaborationStrings.disconnected return statusPayload() } @@ -709,6 +883,7 @@ final class CollaborationRuntime { hostedTerminalsByID.removeAll() hostedTerminalIDsBySurfaceID.removeAll() hostedTerminalOutputSequencesByID.removeAll() + hostedTerminalOutputCaretSuppressionsByID.removeAll() terminalStatesByID.removeAll() for terminal in openTerminals { @@ -768,6 +943,7 @@ final class CollaborationRuntime { return } mirroredTerminalsByID[terminalID] = WeakCollaborationTerminalPanel(panel) + mirroredTerminalIDsBySurfaceID[panel.id] = terminalID terminalStatesByID[terminalID] = CollaborationTerminalHeaderState( isShared: true, statusText: CollaborationStrings.shared, @@ -775,12 +951,30 @@ final class CollaborationRuntime { ) } - private func handleRemoteTerminalOutput(terminalID: String, data: Data) { - mirroredTerminalsByID[terminalID]?.panel?.surface.processRemoteOutput(data) + private func handleRemoteTerminalOutput(terminalID: String, data: Data, caretPeerID: String?) { + guard let panel = mirroredTerminalsByID[terminalID]?.panel else { return } + panel.surface.processRemoteOutput(data) + if let peer = peerVisibleToThisClient(caretPeerID) { + panel.surface.hostedView.showTerminalCollaboratorCaret( + peerID: peer.peerID, + displayName: peer.displayName, + colorHex: peer.color + ) + } } - private func handleRemoteTerminalInput(terminalID: String, data: Data) { + private func handleRemoteTerminalInput(terminalID: String, data: Data, fromPeerID: String?) { guard let panel = hostedTerminalsByID[terminalID]?.panel else { return } + if let peer = peerVisibleToThisClient(fromPeerID) { + hostedTerminalOutputCaretSuppressionsByID[terminalID] = TerminalOutputCaretSuppression( + expiresAt: Date().addingTimeInterval(1.5) + ) + panel.surface.hostedView.showTerminalCollaboratorCaret( + peerID: peer.peerID, + displayName: peer.displayName, + colorHex: peer.color + ) + } let text = String(decoding: data, as: UTF8.self) switch panel.sendInputResult(text) { case .sent: @@ -790,10 +984,51 @@ final class CollaborationRuntime { } } + private func handleRemoteTerminalPointer(_ pointer: CollaborationTerminalPointerWire) { + guard let peer = peerVisibleToThisClient(pointer.fromPeerID) else { return } + let panels = [ + hostedTerminalsByID[pointer.terminalID]?.panel, + mirroredTerminalsByID[pointer.terminalID]?.panel + ].compactMap { $0 } + + for panel in panels { + panel.surface.hostedView.showTerminalCollaboratorPointer( + peerID: peer.peerID, + displayName: peer.displayName, + colorHex: peer.color, + normalizedX: pointer.x, + normalizedY: pointer.y, + visible: pointer.visible + ) + } + } + + private func handleRemoteTerminalSelection(_ selection: CollaborationTerminalSelectionWire) { + guard let peer = peerVisibleToThisClient(selection.fromPeerID) else { return } + let panels = [ + hostedTerminalsByID[selection.terminalID]?.panel, + mirroredTerminalsByID[selection.terminalID]?.panel + ].compactMap { $0 } + + let rects = selection.rects.map { + CGRect(x: $0.x, y: $0.y, width: $0.width, height: $0.height) + } + for panel in panels { + panel.surface.hostedView.showTerminalCollaboratorSelection( + peerID: peer.peerID, + colorHex: peer.color, + normalizedRects: rects, + visible: selection.visible + ) + } + } + private func handleRemoteTerminalClose(terminalID: String) { mirroredTerminalsByID.removeValue(forKey: terminalID) hostedTerminalsByID.removeValue(forKey: terminalID) + removeTerminalSurfaceMappings(for: terminalID) hostedTerminalOutputSequencesByID.removeValue(forKey: terminalID) + hostedTerminalOutputCaretSuppressionsByID.removeValue(forKey: terminalID) terminalStatesByID.removeValue(forKey: terminalID) } @@ -900,13 +1135,19 @@ final class CollaborationRuntime { case "terminal.output": let output = try decoder.decode(CollaborationTerminalOutputWire.self, from: data) if let bytes = Data(base64Encoded: output.dataBase64) { - handleRemoteTerminalOutput(terminalID: output.terminalID, data: bytes) + handleRemoteTerminalOutput(terminalID: output.terminalID, data: bytes, caretPeerID: output.caretPeerID) } case "terminal.input": let input = try decoder.decode(CollaborationTerminalInputWire.self, from: data) if let bytes = Data(base64Encoded: input.dataBase64) { - handleRemoteTerminalInput(terminalID: input.terminalID, data: bytes) + handleRemoteTerminalInput(terminalID: input.terminalID, data: bytes, fromPeerID: input.fromPeerID) } + case "terminal.pointer": + let pointer = try decoder.decode(CollaborationTerminalPointerWire.self, from: data) + handleRemoteTerminalPointer(pointer) + case "terminal.selection": + let selection = try decoder.decode(CollaborationTerminalSelectionWire.self, from: data) + handleRemoteTerminalSelection(selection) case "terminal.close": let close = try decoder.decode(CollaborationTerminalCloseWire.self, from: data) handleRemoteTerminalClose(terminalID: close.terminalID) @@ -943,18 +1184,21 @@ final class CollaborationRuntime { descriptor: descriptor )) case .terminalOutput(let terminalID, let sequence, let data): + let caretPeerID = terminalOutputPeerID(for: terminalID) try await send(CollaborationTerminalOutputWire( type: "terminal.output", terminalID: terminalID, sequence: sequence, - dataBase64: data.base64EncodedString() + dataBase64: data.base64EncodedString(), + caretPeerID: caretPeerID )) case .terminalInput(let terminalID, let inputID, let data): try await send(CollaborationTerminalInputWire( type: "terminal.input", terminalID: terminalID, inputID: inputID, - dataBase64: data.base64EncodedString() + dataBase64: data.base64EncodedString(), + fromPeerID: peerIdentity.peerID )) case .terminalClose(let terminalID): try await send(CollaborationTerminalCloseWire(type: "terminal.close", terminalID: terminalID)) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 2df7cca50f8c..262aff824e84 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3417,6 +3417,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private let _renderedFrameLock = NSLock() var cellSize: CGSize = .zero private var lastKnownMousePointInView: NSPoint? + private var terminalCollaboratorCaretViews: [String: TerminalCollaboratorCaretView] = [:] + private var terminalCollaboratorCaretHideTasks: [String: Task] = [:] + private var terminalCollaboratorPointerViews: [String: TerminalCollaboratorPointerView] = [:] + private var terminalCollaboratorPointerHideTasks: [String: Task] = [:] + private var terminalCollaboratorSelectionViews: [String: TerminalCollaboratorSelectionView] = [:] static func retainRenderedFrameNotifications() -> () -> Void { // See GhosttyApp.retainTickNotifications() on the idempotent release. @@ -3603,6 +3608,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return true } + static func shouldForwardPassiveMousePositionToGhostty( + mouseCaptured: Bool, + pressedMouseButtons: Int, + modifierFlags: NSEvent.ModifierFlags + ) -> Bool { + guard mouseCaptured else { return true } + guard pressedMouseButtons == 0 else { return true } + return !modifierFlags.intersection([.command, .option, .control, .shift]).isEmpty + } + // Visibility is used for focus gating. Explicit portal visibility transitions // also drive Ghostty occlusion so hidden workspace/split surfaces pause and // queue a redraw when they become visible again. @@ -3660,6 +3675,181 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { addSubview(keyboardCopyModeCursorOverlayView, positioned: .above, relativeTo: nil) } + func showTerminalCollaboratorCaret(peerID: String, displayName: String, colorHex: String?) { + guard let surface, + let caretFrame = terminalCollaboratorCaretFrame(surface: surface) else { return } + + let view = terminalCollaboratorCaretViews[peerID] ?? TerminalCollaboratorCaretView(frame: .zero) + if view.superview == nil { + addSubview(view, positioned: .above, relativeTo: nil) + } + terminalCollaboratorCaretViews[peerID] = view + + let color = colorHex.flatMap(NSColor.init(hex:)) ?? .controlAccentColor + view.update(displayName: displayName, color: color) + view.frame = terminalCollaboratorOverlayFrame(for: caretFrame, labelWidth: view.preferredLabelWidth) + view.caretFrame = convert(caretFrame, to: view) + view.isHidden = false + + terminalCollaboratorCaretHideTasks[peerID]?.cancel() + terminalCollaboratorCaretHideTasks[peerID] = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(1.6)) + guard !Task.isCancelled else { return } + self?.terminalCollaboratorCaretViews[peerID]?.fadeOut() + self?.terminalCollaboratorCaretHideTasks[peerID] = nil + } + } + + private func syncTerminalCollaboratorCaretOverlays() { + guard let surface, + let caretFrame = terminalCollaboratorCaretFrame(surface: surface) else { + terminalCollaboratorCaretViews.values.forEach { $0.isHidden = true } + return + } + for view in terminalCollaboratorCaretViews.values where !view.isHidden { + view.frame = terminalCollaboratorOverlayFrame(for: caretFrame, labelWidth: view.preferredLabelWidth) + view.caretFrame = convert(caretFrame, to: view) + } + } + + func showTerminalCollaboratorPointer( + peerID: String, + displayName: String, + colorHex: String?, + normalizedX: Double, + normalizedY: Double, + visible: Bool + ) { + let view = terminalCollaboratorPointerViews[peerID] ?? TerminalCollaboratorPointerView(frame: .zero) + if view.superview == nil { + addSubview(view, positioned: .above, relativeTo: nil) + } + terminalCollaboratorPointerViews[peerID] = view + + terminalCollaboratorPointerHideTasks[peerID]?.cancel() + let color = colorHex.flatMap(NSColor.init(hex:)) ?? .controlAccentColor + if visible { + let anchor = terminalCollaboratorPointerAnchor(normalizedX: normalizedX, normalizedY: normalizedY) + view.update(displayName: displayName, color: color) + view.frame = terminalCollaboratorPointerFrame(for: anchor, labelWidth: view.preferredLabelWidth) + view.anchorPoint = convert(anchor, to: view) + view.isHidden = false + view.alphaValue = 1 + terminalCollaboratorPointerHideTasks[peerID] = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(1.2)) + guard !Task.isCancelled else { return } + self?.terminalCollaboratorPointerViews[peerID]?.fadeOut() + self?.terminalCollaboratorPointerHideTasks[peerID] = nil + } + } else { + view.fadeOut() + terminalCollaboratorPointerHideTasks[peerID] = nil + } + } + + private func terminalCollaboratorPointerAnchor(normalizedX: Double, normalizedY: Double) -> NSPoint { + NSPoint( + x: min(max(CGFloat(normalizedX), 0), 1) * bounds.width, + y: min(max(CGFloat(normalizedY), 0), 1) * bounds.height + ) + } + + private func terminalCollaboratorPointerFrame(for anchor: NSPoint, labelWidth: CGFloat) -> NSRect { + let pointerSize = NSSize(width: 20, height: 20) + let labelHeight: CGFloat = 18 + let labelGap: CGFloat = 5 + let hotspot = TerminalCollaboratorPointerView.hotspot + let labelRect = NSRect( + x: min(max(anchor.x + 14 - hotspot.x, 0), max(bounds.width - labelWidth, 0)), + y: min(max(anchor.y + hotspot.y - 16 - labelHeight, 0), max(bounds.height - labelHeight, 0)), + width: labelWidth, + height: labelHeight + ) + let pointerRect = NSRect( + x: anchor.x - hotspot.x, + y: anchor.y + hotspot.y - pointerSize.height, + width: pointerSize.width, + height: pointerSize.height + ) + return pointerRect.union(labelRect).insetBy(dx: -labelGap, dy: -labelGap) + } + + private func syncTerminalCollaboratorPointerOverlays() { + for view in terminalCollaboratorPointerViews.values where !view.isHidden { + let anchor = convert(view.anchorPoint, from: view) + view.frame = terminalCollaboratorPointerFrame(for: anchor, labelWidth: view.preferredLabelWidth) + view.anchorPoint = convert(anchor, to: view) + } + } + + func showTerminalCollaboratorSelection( + peerID: String, + colorHex: String?, + normalizedRects: [CGRect], + visible: Bool + ) { + let view = terminalCollaboratorSelectionViews[peerID] ?? TerminalCollaboratorSelectionView(frame: bounds) + if view.superview == nil { + addSubview(view, positioned: .above, relativeTo: nil) + } + terminalCollaboratorSelectionViews[peerID] = view + + if visible, !normalizedRects.isEmpty { + let color = colorHex.flatMap(NSColor.init(hex:)) ?? .controlAccentColor + view.frame = bounds + view.update(normalizedRects: normalizedRects, color: color) + view.isHidden = false + view.alphaValue = 1 + } else { + view.isHidden = true + } + } + + private func syncTerminalCollaboratorSelectionOverlays() { + for view in terminalCollaboratorSelectionViews.values where !view.isHidden { + view.frame = bounds + view.needsDisplay = true + } + } + + private func terminalCollaboratorCaretFrame(surface: ghostty_surface_t) -> NSRect? { + var x: Double = 0 + var y: Double = 0 + var width: Double = cellSize.width + var height: Double = cellSize.height + ghostty_surface_ime_point(surface, &x, &y, &width, &height) + + let resolvedWidth = width > 0 ? CGFloat(width) : max(cellSize.width, 1) + let resolvedHeight = height > 0 ? CGFloat(height) : max(cellSize.height, 1) + return NSRect( + x: min(max(CGFloat(x), 0), max(bounds.width - resolvedWidth, 0)), + y: min(max(bounds.height - CGFloat(y), 0), max(bounds.height - resolvedHeight, 0)), + width: resolvedWidth, + height: resolvedHeight + ) + } + + private func terminalCollaboratorOverlayFrame(for caretFrame: NSRect, labelWidth: CGFloat) -> NSRect { + let labelHeight: CGFloat = 18 + let horizontalGap: CGFloat = 4 + let verticalGap: CGFloat = 3 + let desiredX = caretFrame.minX + let labelY: CGFloat + if caretFrame.maxY + verticalGap + labelHeight <= bounds.height { + labelY = caretFrame.maxY + verticalGap + } else { + labelY = max(caretFrame.minY - verticalGap - labelHeight, 0) + } + let maxX = max(bounds.width - labelWidth, 0) + let labelRect = NSRect( + x: min(max(desiredX + horizontalGap, 0), maxX), + y: labelY, + width: labelWidth, + height: labelHeight + ) + return caretFrame.union(labelRect).insetBy(dx: -2, dy: -1) + } + func applySurfaceBackground() { let renderingMode = WindowAppearanceSnapshot.terminalRenderingMode( usesHostLayerBackground: GhosttyApp.shared.usesHostLayerBackground @@ -3910,6 +4100,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { super.layout() updateSurfaceSize() syncKeyboardCopyModeCursorOverlay() + syncTerminalCollaboratorCaretOverlays() + syncTerminalCollaboratorPointerOverlays() + syncTerminalCollaboratorSelectionOverlays() invalidateTextInputCoordinates() terminalSurface?.hostedView.scheduleSuppressedFirstResponderFocusReapplyIfReady( reason: "becomeFirstResponder.hiddenOrTiny.layout" @@ -6446,6 +6639,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let eventPoint = convert(event.locationInWindow, from: nil) trackMousePointIfUsable(eventPoint) ghostty_surface_mouse_pos(surface, eventPoint.x, bounds.height - eventPoint.y, mouseModsFromEvent(event)) + noteTerminalCollaboratorSelection() return true } @@ -6457,6 +6651,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let point = convert(event.locationInWindow, from: nil) let consumed = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mouseModsFromEvent(event)) _ = handleCommandClickRelease(at: point, modifierFlags: event.modifierFlags, ghosttyConsumed: consumed) + noteTerminalCollaboratorSelection() return true } @@ -7275,21 +7470,78 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { _ = performBindingAction("reset") } + private func noteTerminalCollaboratorPointer(at point: NSPoint?, visible: Bool) { + guard let surfaceID = terminalSurface?.id else { return } + CollaborationRuntime.shared.noteTerminalPointer( + surfaceID: surfaceID, + point: point, + bounds: bounds, + visible: visible + ) + } + + private func terminalCollaboratorSelectionRects() -> [CGRect] { + guard let snapshot = readSelectionSnapshot(), + !snapshot.string.isEmpty else { + return [] + } + + let cellWidth = max(cellSize.width, 1) + let cellHeight = max(cellSize.height, 1) + let gridLeft = surface.flatMap { keyboardCopyModeGridMetrics(surface: $0)?.xInset } ?? 0 + let lines = snapshot.string.components(separatedBy: "\n") + var rects: [CGRect] = [] + + for (index, line) in lines.enumerated() { + if line.isEmpty, index == lines.count - 1 { continue } + let characterCount = max(line.utf16.count, line.isEmpty ? 1 : 0) + let width = CGFloat(characterCount) * cellWidth + let topOriginY = snapshot.topLeft.y + (CGFloat(index) * cellHeight) + let rect = CGRect( + x: index == 0 ? snapshot.topLeft.x : gridLeft, + y: bounds.height - topOriginY - cellHeight, + width: max(width, cellWidth), + height: cellHeight + ) + rects.append(rect) + } + + return rects + } + + private func noteTerminalCollaboratorSelection() { + guard let surfaceID = terminalSurface?.id else { return } + let rects = terminalCollaboratorSelectionRects() + CollaborationRuntime.shared.noteTerminalSelection( + surfaceID: surfaceID, + rects: rects, + bounds: bounds, + visible: !rects.isEmpty + ) + } + override func mouseMoved(with event: NSEvent) { maybeRequestFirstResponderForMouseFocus() guard let surface = surface else { return } let suppressCommandPathHover = shouldSuppressCommandPathHover(for: event.modifierFlags) let eventPoint = convert(event.locationInWindow, from: nil) trackMousePointIfUsable(eventPoint) - ghostty_surface_mouse_pos( - surface, - eventPoint.x, - bounds.height - eventPoint.y, - hoverModsFromFlags( - event.modifierFlags, - suppressCommandPathHover: suppressCommandPathHover + noteTerminalCollaboratorPointer(at: eventPoint, visible: true) + if Self.shouldForwardPassiveMousePositionToGhostty( + mouseCaptured: ghostty_surface_mouse_captured(surface), + pressedMouseButtons: NSEvent.pressedMouseButtons, + modifierFlags: event.modifierFlags + ) { + ghostty_surface_mouse_pos( + surface, + eventPoint.x, + bounds.height - eventPoint.y, + hoverModsFromFlags( + event.modifierFlags, + suppressCommandPathHover: suppressCommandPathHover + ) ) - ) + } updateWordPathHover( at: eventPoint, cmdHeld: event.modifierFlags.contains(.command), @@ -7304,15 +7556,22 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let suppressCommandPathHover = shouldSuppressCommandPathHover(for: event.modifierFlags) let eventPoint = convert(event.locationInWindow, from: nil) trackMousePointIfUsable(eventPoint) - ghostty_surface_mouse_pos( - surface, - eventPoint.x, - bounds.height - eventPoint.y, - hoverModsFromFlags( - event.modifierFlags, - suppressCommandPathHover: suppressCommandPathHover + noteTerminalCollaboratorPointer(at: eventPoint, visible: true) + if Self.shouldForwardPassiveMousePositionToGhostty( + mouseCaptured: ghostty_surface_mouse_captured(surface), + pressedMouseButtons: NSEvent.pressedMouseButtons, + modifierFlags: event.modifierFlags + ) { + ghostty_surface_mouse_pos( + surface, + eventPoint.x, + bounds.height - eventPoint.y, + hoverModsFromFlags( + event.modifierFlags, + suppressCommandPathHover: suppressCommandPathHover + ) ) - ) + } updateWordPathHover( at: eventPoint, cmdHeld: event.modifierFlags.contains(.command), @@ -7346,6 +7605,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { if NSEvent.pressedMouseButtons != 0 { return } + noteTerminalCollaboratorPointer(at: nil, visible: false) ghostty_surface_mouse_pos(surface, -1, -1, mouseModsFromEvent(event)) } @@ -7353,10 +7613,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { guard let surface = surface else { return } let eventPoint = convert(event.locationInWindow, from: nil) trackMousePointIfUsable(eventPoint) + noteTerminalCollaboratorPointer(at: eventPoint, visible: true) // Forward the raw drag coordinates, including out-of-bounds positions. // Selection auto-scroll depends on libghostty observing the pointer leave // the viewport rather than a cached in-bounds hover point. ghostty_surface_mouse_pos(surface, eventPoint.x, bounds.height - eventPoint.y, mouseModsFromEvent(event)) + noteTerminalCollaboratorSelection() } #if DEBUG @@ -7840,6 +8102,276 @@ private final class GhosttyFlashOverlayView: NSView { } } +private final class TerminalCollaboratorCaretView: NSView { + private let labelFont = NSFont.systemFont(ofSize: 10, weight: .semibold) + private var displayName = "" + private var color = NSColor.controlAccentColor + private(set) var preferredLabelWidth: CGFloat = 72 + + var caretFrame: NSRect = .zero { + didSet { needsDisplay = true } + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.masksToBounds = false + alphaValue = 1 + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var acceptsFirstResponder: Bool { false } + + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } + + func update(displayName: String, color: NSColor) { + self.displayName = displayName + self.color = color + alphaValue = 1 + let textWidth = (displayName as NSString).size(withAttributes: [.font: labelFont]).width + preferredLabelWidth = min(max(ceil(textWidth) + 12, 44), 128) + needsDisplay = true + } + + func fadeOut() { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.18 + animator().alphaValue = 0 + } completionHandler: { [weak self] in + self?.isHidden = true + self?.alphaValue = 1 + } + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + + let caretX = min(max(caretFrame.minX, 0), max(bounds.width - 2, 0)) + let caretHeight = max(min(caretFrame.height * 0.9, caretFrame.height), min(caretFrame.height, 10)) + let caretY = caretFrame.midY - (caretHeight / 2) + let caretRect = NSRect( + x: caretX, + y: caretY, + width: 2, + height: caretHeight + ) + color.setFill() + NSBezierPath(roundedRect: caretRect, xRadius: 1, yRadius: 1).fill() + + let labelX = min(max(caretX - 1, 0), max(bounds.width - preferredLabelWidth, 0)) + let labelY: CGFloat + if caretFrame.maxY + 18 <= bounds.height { + labelY = caretFrame.maxY + 2 + } else { + labelY = max(caretFrame.minY - 18, 0) + } + let labelRect = NSRect( + x: labelX, + y: labelY, + width: preferredLabelWidth, + height: 16 + ) + color.setFill() + NSBezierPath(roundedRect: labelRect, xRadius: 4, yRadius: 4).fill() + + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = .center + paragraph.lineBreakMode = .byTruncatingTail + let attributes: [NSAttributedString.Key: Any] = [ + .font: labelFont, + .foregroundColor: NSColor.white, + .paragraphStyle: paragraph + ] + let textSize = (displayName as NSString).size(withAttributes: attributes) + let textRect = NSRect( + x: labelRect.minX + 6, + y: labelRect.midY - (textSize.height / 2), + width: labelRect.width - 12, + height: textSize.height + ) + (displayName as NSString).draw(in: textRect, withAttributes: attributes) + } +} + +private final class TerminalCollaboratorPointerView: NSView { + static let hotspot = NSPoint(x: 2.744 * (20.0 / 19.0), y: 1.407 * (20.0 / 19.0)) + + private let labelFont = NSFont.systemFont(ofSize: 10.5, weight: .medium) + private var displayName = "" + private var color = NSColor.controlAccentColor + private(set) var preferredLabelWidth: CGFloat = 72 + + var anchorPoint: NSPoint = .zero { + didSet { needsDisplay = true } + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.masksToBounds = false + alphaValue = 1 + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var acceptsFirstResponder: Bool { false } + + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } + + func update(displayName: String, color: NSColor) { + self.displayName = displayName + self.color = color + alphaValue = 1 + let textWidth = (displayName as NSString).size(withAttributes: [.font: labelFont]).width + preferredLabelWidth = min(max(ceil(textWidth) + 12, 48), 128) + needsDisplay = true + } + + func fadeOut() { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.16 + animator().alphaValue = 0 + } completionHandler: { [weak self] in + self?.isHidden = true + self?.alphaValue = 1 + } + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + + let scale: CGFloat = 20.0 / 19.0 + let hotspot = Self.hotspot + func svgPoint(_ x: CGFloat, _ y: CGFloat) -> NSPoint { + NSPoint( + x: anchorPoint.x + (x * scale) - hotspot.x, + y: anchorPoint.y - ((y * scale) - hotspot.y) + ) + } + + let pointerPath = NSBezierPath() + pointerPath.move(to: svgPoint(2.744, 1.407)) + pointerPath.line(to: svgPoint(16.922, 8.531)) + pointerPath.line(to: svgPoint(10.547, 10.547)) + pointerPath.line(to: svgPoint(8.531, 16.922)) + pointerPath.close() + pointerPath.lineJoinStyle = .round + + NSGraphicsContext.saveGraphicsState() + let shadow = NSShadow() + shadow.shadowOffset = NSSize(width: 0, height: -1) + shadow.shadowBlurRadius = 1.5 + shadow.shadowColor = NSColor.black.withAlphaComponent(0.35) + shadow.set() + color.setFill() + pointerPath.fill() + NSGraphicsContext.restoreGraphicsState() + + color.setFill() + pointerPath.fill() + NSColor.white.withAlphaComponent(0.95).setStroke() + pointerPath.lineWidth = 1.25 + pointerPath.stroke() + + let labelX = min(max(anchorPoint.x + 14 - hotspot.x, 0), max(bounds.width - preferredLabelWidth, 0)) + let labelY = min(max(anchorPoint.y + hotspot.y - 16 - 18, 0), max(bounds.height - 18, 0)) + let labelRect = NSRect( + x: labelX, + y: labelY, + width: preferredLabelWidth, + height: 18 + ) + + NSGraphicsContext.saveGraphicsState() + let labelShadow = NSShadow() + labelShadow.shadowOffset = NSSize(width: 0, height: -1) + labelShadow.shadowBlurRadius = 3 + labelShadow.shadowColor = NSColor.black.withAlphaComponent(0.22) + labelShadow.set() + color.setFill() + NSBezierPath(roundedRect: labelRect, xRadius: 6, yRadius: 6).fill() + NSGraphicsContext.restoreGraphicsState() + + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = .center + paragraph.lineBreakMode = .byTruncatingTail + let attributes: [NSAttributedString.Key: Any] = [ + .font: labelFont, + .foregroundColor: NSColor.white, + .paragraphStyle: paragraph + ] + let textSize = (displayName as NSString).size(withAttributes: attributes) + let textRect = NSRect( + x: labelRect.minX + 6, + y: labelRect.midY - (textSize.height / 2), + width: labelRect.width - 12, + height: textSize.height + ) + (displayName as NSString).draw(in: textRect, withAttributes: attributes) + } +} + +private final class TerminalCollaboratorSelectionView: NSView { + private var normalizedRects: [CGRect] = [] + private var color = NSColor.controlAccentColor + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.masksToBounds = false + alphaValue = 1 + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var acceptsFirstResponder: Bool { false } + + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } + + func update(normalizedRects: [CGRect], color: NSColor) { + self.normalizedRects = normalizedRects + self.color = color + needsDisplay = true + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + guard bounds.width > 0, bounds.height > 0 else { return } + + color.withAlphaComponent(0.28).setFill() + color.withAlphaComponent(0.55).setStroke() + for normalized in normalizedRects { + let rect = CGRect( + x: normalized.minX * bounds.width, + y: normalized.minY * bounds.height, + width: normalized.width * bounds.width, + height: normalized.height * bounds.height + ).intersection(bounds) + guard !rect.isNull, rect.width > 0, rect.height > 0 else { continue } + let path = NSBezierPath(roundedRect: rect, xRadius: 2, yRadius: 2) + path.fill() + path.lineWidth = 1 + path.stroke() + } + } +} + private final class TerminalViewportBorderOverlayView: NSView { var effectiveSize: CGSize? { didSet { needsDisplay = true } @@ -7958,6 +8490,42 @@ final class GhosttySurfaceScrollView: NSView { surfaceView.keyDown(with: event) } + func showTerminalCollaboratorCaret(peerID: String, displayName: String, colorHex: String?) { + surfaceView.showTerminalCollaboratorCaret(peerID: peerID, displayName: displayName, colorHex: colorHex) + } + + func showTerminalCollaboratorPointer( + peerID: String, + displayName: String, + colorHex: String?, + normalizedX: Double, + normalizedY: Double, + visible: Bool + ) { + surfaceView.showTerminalCollaboratorPointer( + peerID: peerID, + displayName: displayName, + colorHex: colorHex, + normalizedX: normalizedX, + normalizedY: normalizedY, + visible: visible + ) + } + + func showTerminalCollaboratorSelection( + peerID: String, + colorHex: String?, + normalizedRects: [CGRect], + visible: Bool + ) { + surfaceView.showTerminalCollaboratorSelection( + peerID: peerID, + colorHex: colorHex, + normalizedRects: normalizedRects, + visible: visible + ) + } + private var lastFlashStyle: FlashStyle = .navigation private let keyboardCopyModeBadgeContainerView: GhosttyFlashOverlayView private let keyboardCopyModeBadgeView: GhosttyPassthroughVisualEffectView diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index 8361174e945c..757fe45f452d 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -974,6 +974,49 @@ final class GhosttyPasteboardHelperTests: XCTestCase { } } +@MainActor +final class GhosttyPassiveMouseForwardingTests: XCTestCase { + func testForwardsPassiveMouseWhenTerminalHasNoMouseCapture() { + XCTAssertTrue( + GhosttyNSView.shouldForwardPassiveMousePositionToGhostty( + mouseCaptured: false, + pressedMouseButtons: 0, + modifierFlags: [] + ) + ) + } + + func testSuppressesPassiveMouseWhenCapturedAndNoButtonsOrModifiers() { + XCTAssertFalse( + GhosttyNSView.shouldForwardPassiveMousePositionToGhostty( + mouseCaptured: true, + pressedMouseButtons: 0, + modifierFlags: [] + ) + ) + } + + func testForwardsCapturedMouseWhenButtonIsPressed() { + XCTAssertTrue( + GhosttyNSView.shouldForwardPassiveMousePositionToGhostty( + mouseCaptured: true, + pressedMouseButtons: 1, + modifierFlags: [] + ) + ) + } + + func testForwardsCapturedMouseWhenModifierIsHeld() { + XCTAssertTrue( + GhosttyNSView.shouldForwardPassiveMousePositionToGhostty( + mouseCaptured: true, + pressedMouseButtons: 0, + modifierFlags: [.command] + ) + ) + } +} + @MainActor final class TerminalOffscreenStartupTests: XCTestCase { #if DEBUG diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/182a53b53cc95c92b7d921e8c6de3a60430cf3b0d1fb1d6b52abbbeed3662534.sqlite b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/182a53b53cc95c92b7d921e8c6de3a60430cf3b0d1fb1d6b52abbbeed3662534.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..0b58f0dca7d6ce95907cb0b322dc4802f04688c0 GIT binary patch literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AVoG{WYDXN;00+HAlr;ljiVtj n8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O6ovo**AfQj literal 0 HcmV?d00001 diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/2176ee2a3478351a9ec5b654efb98bf0fc9a641724d672dec2a87767fd020f43.sqlite-shm b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/182a53b53cc95c92b7d921e8c6de3a60430cf3b0d1fb1d6b52abbbeed3662534.sqlite-shm similarity index 98% rename from workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/2176ee2a3478351a9ec5b654efb98bf0fc9a641724d672dec2a87767fd020f43.sqlite-shm rename to workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/182a53b53cc95c92b7d921e8c6de3a60430cf3b0d1fb1d6b52abbbeed3662534.sqlite-shm index 5e7d93fe122cc9859b0ab4d2e192e6743ac25722..848f44824200ec379f4b56b1772370906973ff7b 100644 GIT binary patch delta 200 zcmZo@U}|V!s+V}A%K!qxK+MR%ARq>$S%5g@{(o(O`S<0W9#-`p{O#Lv*uALxx?l2o zQq=>E0s~PX`5y^Dg(ub~n}LUcmw}IgpFw~@kUqQe= mQq=>E0s~GU`5y^Dg(ubB8$ diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/182a53b53cc95c92b7d921e8c6de3a60430cf3b0d1fb1d6b52abbbeed3662534.sqlite-wal b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/182a53b53cc95c92b7d921e8c6de3a60430cf3b0d1fb1d6b52abbbeed3662534.sqlite-wal new file mode 100644 index 0000000000000000000000000000000000000000..857884e47988b340c8454dbe186fd15f8fa327c2 GIT binary patch literal 90672 zcmeI)e@xVM9LMqd0V0UrX>-6<;~T??QwuolVEV`RfLmRH7#t|BjlOtaHU``|I1AJX zIA_iD4|R?Dqu-k?mYC*j(a@Y$w30%p>4s@7myKyla<=uuKHq~c-}@HJ)gRTqJU=^* z5AWl9+xzk1pYQAQ;k+F)w%e9|oo=(aZQ3aMYV)3xKbP&OQ@*Ksq5JK3MadptzqMs> zaOimZ^~y&|8=|UIA8Bb;qLN2k?Qqy+NfN*7nE28z(}s3^LTonv6nnq8pQoBSZ1-(j zH(R`7cRgtn4+a7VAb@;mseF)itbG31@1lgY#`#i>%<+-3pnFcv|QmIpBHer+~T}|cw!)c00Iag zfB*srAb@x zpM3RkrD>BY6_u73O_UZ%YXiZD%BzA>Mfuu5$$xoXK)c6x>gNTL`*yyxarAJ{P_UwJ>q}UO;?fAbGmLuYXD|V1O}0 z009ILKmY**5I_I{1Q0;LLIQd&@bT~uQCak5$^{l48hC5?>8HLV7qHNZ(+UCzAbGFHARbj9fwWC*>3GpDq^&igmX&5+p{K}ATq&Y$BpNm$ zrb#_<-#!mVzR<25HdmCYTc?Q&9H##xTXt;AkNvwv$%lG1P8GG2rGg?Gx==XO#GuY*~_nokM#oU$v0-7+w;0XKmY;|fB*y_009U<00Izz00gwa z7b}}vSuqB0Q`zdMAZ!PFT)1UdxRd=%$>*-%B-~<^)8q`T&eAB3)G2xI1%JnV+V{OZ z*KbmVH=mCee3G3LMa62ZI2u|zF|)aa1!KU+*(;6V?v;i$m9f(JBbVTZ; z-Q2K$FkKK3fB*y_009U<00Izz00bZa0SNpz0*?%fE!CSF8yDF8DOyy5I$^2=KV8HMYcQRFybxVhfiE<~?FRcPrNzTS4W-pGyinRWgB|BBgH_jf^L mf&c^{009U<00Izz00bZa0SG|g&IRW54*(CJU0hnaWxoI<4w?%9 delta 33 ncmZo@U~EvBAkE6iz`(#bvC*EJkwLF6f)~hR5ZEjzFpVDobMOXB diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/2176ee2a3478351a9ec5b654efb98bf0fc9a641724d672dec2a87767fd020f43.sqlite-wal b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/2176ee2a3478351a9ec5b654efb98bf0fc9a641724d672dec2a87767fd020f43.sqlite-wal deleted file mode 100644 index feae7407b0a7964abbe63b06c281df8694ae3bce..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 41232 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 literal 0 HcmV?d00001 diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/49f82606dd56e5349b850d37cbc274255ffb66998e63abf461346e712a82b17a.sqlite b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/49f82606dd56e5349b850d37cbc274255ffb66998e63abf461346e712a82b17a.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..a60776313e2295b3fb6daf1d3c6a6a10b24c8ad2 GIT binary patch literal 16384 zcmeI&O>fgM7zc1CDO7ub?1IV;h)2~XZ4#i-v|T4Iq^&igb`&zxb*IQ}Tq#=HNNWZ{ zAf%qSZ=Z+nz-QTYhv8~nRp>NvVF&boWXq0i`LTbuDA|8sj}k@Q__!|w#WiJv?RhcY8%Q2nav`0uX=z1Rwwb2tWV=5P*Od z_-dsy>+8noRU+Fx6?D469v5!a74B?5Rq?qiI0?60=QKM*8;dlFBlVfQw}S6-pWgZ2 zp6ee{jUPUnF8C;WCyL7TMtL%{d19tBtE)8KKhXRF+K9p%?@!|$X?)tv z@w>TUpEF$$5P$##AOHafKmY;|fB*y_009X6Hv-Fs#h$i``<3VJ?oN48-^;Mim@Wti zKmY;|fB*y_009U<00Izz00iz%pqF|ws`phQLz&1+JI*c))o>Wa1FxE0n7+7YmnNf? zI8?La_mcQX4YF%nMW+}ROQrnF_I4rMaSFLyeygAyM{ajY?J$?mb(}BO)%X7kW?$Uh o1(68?5P$##AOHafKmY;|fB*y_0D*rlu%v$gxc~UaPfNG#7XtE_TL1t6 literal 0 HcmV?d00001 diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/58577c2c6c0e91055de31dad3b883838a53329c465f20cb26bd92c581dbc6c84.sqlite b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/58577c2c6c0e91055de31dad3b883838a53329c465f20cb26bd92c581dbc6c84.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..31d192f396a52c0232b0548a75fddfea0079fdec GIT binary patch literal 16384 zcmeI&K~LK-6bEo8DXW07aG5#|)1Im!fi$L;Fs;=tOQ|)o6bhLsaEjcy--v>SNa zZg50CKYB4<@KJV76;*2W%4lfy)Xe6VmW%-(XZIV!-Tj6&mC$Dgg;5grWLJgLL*}msnqELFarDRg({M)`o_2Nk zZf@8=m@WtiKmY;|fB*y_009U<00Izz00jOUfrp00o|Q|x{@Qyt*qhV$GVHHR7X$<# z009U<00Izz00bZa0SG_<0{17-$vhp@x+;~COyy5I$HQqgJ3LR4NaI&wpmiiP5fgM7zc1CDOB5l?1IV;h({HZHVM$6EltFQbhRLAN+Fo;*4P(OR*<0P|?yau1l(E(QC70m);%^hto-dwu zUI_|2dw%ncQ7ly|#^m~0+Gv=L(qtfyRTO8@NDfuBJY@cSpydU$mBzolKTUU}>1ns8 z@8*X6jp>4b00bZa0SG_<0uX=z1Rwwb2teS!5x8Pl>{b}m+jkGW2hOU#mtlWqx*#9` z0SG_<0uX=z1Rwwb2tWV=5V$yjLE+{k7^+;xGMC@&th_Q-<8hje{APJ&{^DLUw7Px{kSH3JylEk@2s=$|EJ78y|@b^ m69gat0SG_<0uX=z1Rwwb2tWV=|6E{A{{V3L#+R>_ZrKkM6q#fI literal 0 HcmV?d00001 diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/85a8afb99f3b6096356cfc01c57152038bc30ec767d07f3bbb69df1525f4ff5b.sqlite b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/85a8afb99f3b6096356cfc01c57152038bc30ec767d07f3bbb69df1525f4ff5b.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..f44c9bf1119c93e38a29777ac5201c4e3e5402e5 GIT binary patch literal 8192 zcmeI#JqyAx5C-5&6gr76#q~x7ap@nh)*#|{&0wceX$yr`K?-hylRw$jRy!8l?RzA- z-?GHR%!5-J`F@t1lQ<2oksn_CbCA`Qq~j`EE7taO zrMTt{50a1<8?ENb<(<1&+pfn;<&C(!k+nMN;rDO;qVB87{CGPAAOHafKmY;|fB*y_ S009U<;4cLX(IV5?&eR*Ll`~ra literal 0 HcmV?d00001 diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/8e7af33eae36ae7d9a08cf0bafd7aea4c8d6a631bd96af46012b1e77875d7b4b.sqlite b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/8e7af33eae36ae7d9a08cf0bafd7aea4c8d6a631bd96af46012b1e77875d7b4b.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..baa620a9afc72f36e13461ff4c025b8a10e2ee90 GIT binary patch literal 16384 zcmeI&O>fgM7zc1CDO7ub?1H)-5Ra-&+N6OtvaVFSkggVs+R-hTZsig=#F3)4jkIP= z2!zxVclbPf2R;ke9l+JPs!)WuY=`Op$d(=3@?-yQQS$1rkz|VE^t3B8a@YbhP4=9K zF=pzT)AcGW-I;tgt|tD`n(WD^<9m96b^i;qKidnsK|lZk5P$##AOHafKmY;|fB*!v zz(*@zSXnU!JDChSDtH?Q`&_s+SGbe?+^)}E!AZE)2B*mxTAig)oT&HYy%hWf_vyg* z_FcbCb>3bdFL)Y}iR+H$j_ov~GG(7Fv z@ZH?7e=uDT5P$##AOHafKmY;|fB*y_009Wx8i9GkVvpK~+ixDbwY}=BzL#NtWx60B z009U<00Izz00bZa0SG_<0uZ=4fllt>pwU&CjASN%+G%m7uloHY?RmB0%=pE@t-d5Kk48HVM#{hOQGA($<2g-3q~U-6?V#M~c=q(wc2T zAf%qS!{^~U@L9O-0It?mg(Ad-9nk;DmL1#jvwycJdHt%HWQyYSWFRvtu~lZ8>=_Yb z%+xif>s45~GyiN{P5hxX+2aqL+j@a@=M%F(+N-)jKmY;|fB*y_009U<00Izz00gwa zdn;eq*f7RVGuiE{uos8>TzGX)c=P>SE#RKuB)n>q)BFr=F4HuQ)m!op1b@y0dKvip zUT{PWe)Mp*;7s;U6;+$9>U3!9)XW#w*NrisWpA`6yEod_LdHh>mt2B(#NQ^OLtngT zy%7|&4*mMMkuQ`=#(3>AZ8%7VN!*twDvZ)_CJ zq~^zOWa+UQ79Vt-y?A%m>AFr_*>TF-m9pE5N^vFHc3oMn^jv3W@11q|{r`g57uR<| nWP$($AOHafKmY;|fB*y_009U<;GYYu=pO)X-u?2`(k=S|Z`GKd literal 0 HcmV?d00001 diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/a9e8393d05838637ddaf02a111008625c2d5563786ecc264864a6ddff28587ea.sqlite b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/a9e8393d05838637ddaf02a111008625c2d5563786ecc264864a6ddff28587ea.sqlite index 0b58f0dca7d6ce95907cb0b322dc4802f04688c0..dc6fb8cb18aef86ee764ff29684aef934b1d9fa2 100644 GIT binary patch literal 16384 zcmeI&O>fgM7zc1CDO7ub?1IV;h({HZHVIJGpwfs7>1sh}N4Ly$?JkkKxKgyXk=hi5 zKuA4thtI>2FT`~RaJ8=LC_-G=VfsI^WyiMs*uPtp93Ir;R8cn>_GL;A+hC^2o)a;~ zOkFd&UWBDPi_gZz#9vyIJ^s|Xr59MYzcBl=y`dWf1Rwwb2tWV=5P$##AOHafKtKz8 zw6eLaEo1U5m7SgnPrBhA7hcs9-eNzq8*oo>5?-awX>o?OS7{cf>OJ|d1%JT<+7J9a zFKAPZw;#?Iyp_GNqDs9{nGNlX&1`OS)0psi_F8kgd#!0LWvn%S%O!YA{A(gQ^2N)> z8$m(i$gjRLvN^{wCU?%$hW&UDcYAWE!YBy`vaiDBA@lNqmKV@=9R2bBG~JP=r`?&p zn;Z5IrV9cB5P$##AOHafKmY;|fB*y_0D)^Gux?oFe)ZMi{-gGBxwNY9W!PVtE(i!f z00Izz00bZa0SG_<0uX=z1g=h?m$^5o_f;w*naZDbl3y9A(I`#^el@=`e{s*R%|>^V zNG*=vNRv}F$Ujg|qNprYr{Hvz>$*?NCmmHRxluv7vLs98qEjqMn>( m5P$##AOHafKmY;|fB*y_009X6cY!ti1HjF@-@aSAWxoLQ>zV)n delta 33 ncmZo@U~EvBAkE6iz`(#bvC*EJkwLF6f)~hR5ZEjzFpVDobMOXB diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/a9e8393d05838637ddaf02a111008625c2d5563786ecc264864a6ddff28587ea.sqlite-shm b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/a9e8393d05838637ddaf02a111008625c2d5563786ecc264864a6ddff28587ea.sqlite-shm deleted file mode 100644 index cf0f9b4326a5655f43366ef24fd102e1419e52cd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 zcmeI)I}U+C6b8`oel(O8pjB9iM5V9+l~Qa)t=7_M?Saw?XtdlB5z*;{^CkDc$z<}+ z%vr$g9D6CJ7F9^8-Hcw2TAcUO)_S>`j5eo1e>R+7y4T(Ku@Gi&dhw2u(DRCeI-E4!89*r~RYtxqjY*`0&G- zbAF$Pf9CsMhhDQ=Twl<2XS;%ewgru^2_shj>jkGzt9;^~(KEOFe*EMxDm-+)V*l1x zI-T(N{9!`}Pi&~oU0Qe5l009C72oNAZfB*pk1U}CKR~NQCwzRZx&e09k<0sdS z8#iU*)QOi)uD+^vTut4$snt_HYgE+lqKfjWidw123$q7*KIh zuJWRR7nEN#I(L4>=u>lzpD|ZeF{-LdKtJ$xe%uQxc26HvpKIOi)34b-EGYT#^L#CK zCqRGz0RjXF5FkK+009D76KGRdT<}-@2Mw$2I(kg+o?*Mx^8&j@Ju&~0JNNDRGtUbY zAEMIO75>N13lx>K4bKaNpS%bVAV7cs0RjXF5FkK+!2bh*X+B|m)yvKRp$*E zUX{CO$jE{H|1X~xX#5(#IDTH>ma>IyFRy#??ABpeSaN$o_~Au>009C72oNAZfB*pk z1PJ`^2~2L*Vb0(wwGGuZ)eY5uEUD|*s=l_qeq!C!f&DwSO5W)2c-X;dzq*>*L+8yK z>L%4r?Ra8s_nMmC)wSchm0ns~R#w)l_a)_5GGAOFsOcn|-x{{Q;_;%)rT;qwBG zU(+{>pBI=rZC%a2szviZ{r&=b3rhC-LrH)D0RjXF5FkK+009C72xLg$u<#8ATC{s~ z)3t?bUyj0FsV*?Na8t?7;XVFZ7s$|!TZI4t0t5&UAV7cs0RjXFGzlbKU_hH^^I@Fo z0y7VL@drP?Z_g}Upb5;0009C72oNAZfB*pk1PEkEAnpR!_v?0TKJ1n10_}V4T=UlY zy$|RD8M<++5FkK+009C72oNAZfB=Cefw&9I`&!jk@?o6n0#jbybLX-LR+Z=iO<+z0 z2oNAZfB*pk1PBlyKp;Z`aToZ`Sy#W14|}D$z>u|P5Bv373+L(r8M<++5FkK+009C7 z2oNAZfB=Cefw&9Y(d+Kn`7lm(ffFBnf9;Xgi_3L^CNL)g1PBlyK!5-N0t5&UAdn$} zxC`7{`twurVXssd`2A0&j2+vfZ3|r>LpN>}0t5&UAV7cs0RjXF5FpSb5O;wGzq<34 zd>E&?z|E)KxnS2_9gorln!ub05FkK+009C72oNAZfIx->;x4f4)D}17!(OQ_uzlHG zf4Akm-S_GO8M<++5FkK+009C72oNAZfB=Cefw&8-IH~N)D2!8G;QkZW9P`ZTGgs;Y zO<+z02oNAZfB*pk1PBlyKp;Z`aTi#7;@A#R*elfqhV}T{p{p}0t5&U zAV7cs0RjXF5FpSb5O;x%x#;OAj8k3UN6{zUuBn{v??-naGWc?)%cEM2)>2oNAZ zfB*pk1PBlyK!89Z5O;y!v>NeT6sD;za6)0t5&UAV7cs0RjXF5J&{#F7R>T zy^`1~$dUyR2T_8(WZWjUs z2oNAZfB*pk1PBlykO;(G;HVEzeKiWxR2P^reB<9-S$y0cT_EvtAV7cs0RjXF5FkK+ z009D75{SFNaUWFP7=^`BU0~6sW>??1c+n1BAWK(n7Xkzb5FkK+009C72oNBU2*h2W z*hsan(6|#|M=_q;=U(8 ztqUYx4g?4gAV7cs0RjXF5FkJxO9F8h==J9I9#L2<)dd#3SaSY%mVUBZ7s%3;+l2rD z0t5&UAV7cs0RjXFBm!|4=<{09jwno1U0`VQZ96O9JadySka#%|AV7cs0RjXF5FkK+ z0D&wC#9g4`mCg&Juvn@KJbdISOCI>r$QN~iEM2)>2oNAZfB*pk1PBlyK!89Z5O;yW zFAOS+!Zg(d*0*_L<>L=6@1P4LUJe8Z5FkK+009C72oNAZAWH&q7Z~>JlwDC+EY$@b z``Yv&UFM#1ye^QXE4K>)0t5&UAV7cs0RjXF5J&{#E->boixx#;n(6{8x*yYX=fyvG zMHfiC90(8~K!5-N0t5&UAV7dXmIUH1Q2o@ZbE2?Vstf%4IV(Tz|LCO6xkU zKawkt!Zg(dt{buafyE_Lcjy9%mjeL;1PBlyK!5-N0t5&U$dW+Z1!nz2zxSiCSgH%W zdDf-}uike5BwZj&S8f*q1PBlyK!5-N0t5&UAdm>eU109Y2}`3eO?8233$B>F`QGw5 zxH@3&ao3Ym*7O^y3nX3+1PBlyK!5-N0t5&U zAV45X0&y2u`p}N;8^U6#E-+jPzB8;f*-EM2)>2oNAZfB*pk1PBlyK!89Z5O;y)_k6u`<1p0)HlMv>ZKq!h zc~lojyc`G+AV7cs0RjXF5FkK+K$ZmJF0g9p?B^TzN_ByPpH%N3v_IcY7s%3;+l2rD z0t5&UAV7cs0RjXFBm!|4cx1_AMNychy1;3FIp+0;Dlc213nX3+1PBlyK!5-N0t5&U zAV45X0&y4Ev}o7zC@hxh0=xIjnDF;ssQ#)hkfkfP3jqQI2oNAZfB*pk1PBmF1mZ66 zi(6ZbiNZA11@`27Ke@Q*!uxcA#LIyI0RjXF5FkK+009C72xLhh?gG!<(zQht7E5)3 zuJiY`YFT?jy)KZYE4K>)0t5&UAV7cs0RjXF5J&{#F0k!el|PHZG}Q&Vk3Az=S@Yh- zxYV|)K$foDE(8b= zAV7cs0RjXF5FkJx5s16M+t=OMIttTN7wEBj%PrgI|6zhIka#%|AV7cs0RjXF5FkK+ z0D&wC#9iS1nd??ZVX;&f*uVFP{Ug4z<`i8ZOIL0e0t5&UAV7cs0RjXF5Fn5U#9d(D z^zGxLFimxV*S>gU&8-_w>aPnVUJe8Z5FkK+009C72oNAZAWH&q7x+Vc@exs2EY$_( z&Rh7y-upf{S{KODmD_~?0RjXF5FkK+009C72qXdryTB7gb)DBnVVdd!KPsMf%&+QC zyH^)Tyc`G+AV7cs0RjXF5FkK+K$ZmJF3@Vypi85$SgH$D{@bjL6YtnMP8Z11mD_~? z0RjXF5FkK+009C72qXe=7ic?S>KCIhO?81^eecz4rarx4lrE5XIS?Q~fB*pk1PBly zK!5;&ED6M2;MhwRt&hTDsV;DL=Z0T)__(^2E|8@ww+jIR1PBlyK!5-N0t5&UNCe_8 zaQwJcmq%fm>H@jKzi9n@@$jp3fyB#!009C72oNAZfB*pk1PEkFAnpR4F8jR9d+D*#$l=p{I31D?|*aa zGvjrE#LIyI0RjXF5FkK+009C72xLhh?gFJ%efBo)mFfcBUT@y3;kJ*K=>l20a=Q>9 zK!5-N0t5&UAV7csfkYtg0%ulUxg#H@sV;Et(C=RO&Mj{i=>mzD0|5dA2oNAZfB*pk z1PBnwl0e)A$_Fo5m=BAky1;FNZ=LbfQ$xDz0$I9pyAU8ifB*pk1PBlyK!5;&L?G@0 z=bg8vEFY$+E>N_u#m()et$JP;NW2^f5FkK+009C72oNAZfIyZ6;w~_x|LeQ*VX;&f zxT<#h{MCJTcGU&4bmev-K!5-N0t5&UAV7cs0Ro9Y+yzFI7c9z$X{rkxy}9kl-<~{i zvo4T$IS?Q~fB*pk1PBlyK!5;&ED6M2VC*?3o|6xYrMf`txBhwB2itn~(*?40<#r)J zfB*pk1PBlyK!5-N0*OG}1!~F$yqgcxR2Mk@)}?1JFPzdx7f8Gu2oNAZfB*pk1PBly zK!8A&1mZ3*smG)x`LI~33!HTR_ev)BnLSz;$kLVDg#ZBp1PBlyK!5-N0t5&o0&y3( z>hy1y=fgDB1$GwJ_Brl^_5Pm1gv)^d0RjXF5FkK+009C72xLki?gBHq{_OpH*eulr sDrdEP_1XHxb)FZ<)SX*~009C72oNAZfB*pk1PH_eaTkcX?0PNw6Hg3SxBvhE diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/c1000d36cb8b0052429c6b47892acb7f02af105c7610872008f024096afac8d7.sqlite b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/c1000d36cb8b0052429c6b47892acb7f02af105c7610872008f024096afac8d7.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..8a4f6551f0c997f649a46d4052e8b5716e5c25b0 GIT binary patch literal 16384 zcmeI&L2uJA6bEo8DOB5l?1IV;h^LB4n*?YyU8{);>1sjLjzTc)$|W+fBSmW)Y0W?g zgwzvv_&j_EJ`2|!z}32{P=vU!1NuMNvSV9*_U{%Y&!4r^TvL)A4`ogsTW7Y-9uhIe zY*P!So`+*Pi_g~i#2;Fl-TQEO)huwXePZrMcil7y2tWV=5P$##AOHafKmY;|fPfKr z?-Wa$o7Ut(E?*6F6erOE7k<+h{$jtdA97!C5`Lr2X>o?OR%sST`Yi=d1b@UsdKv}? ze%PlL@86j(cr6Dfni}m+V>YyXVi!vr8`gx+v$wj_-CJE}DPybqOD@3=#os2PULYQK zUI+?1y`cHVDwaIYnp{6i8x7M@nhfNzj#L(nC<(07>kJD@vG|MaV7x(hoY;-?U zdU5__5;g4nk@hT literal 0 HcmV?d00001 diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/df9c171be52cb4e34c2a23333b13a0a96ae46b4ed46279578a09474dc39cc1b8.sqlite b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/df9c171be52cb4e34c2a23333b13a0a96ae46b4ed46279578a09474dc39cc1b8.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..bfb58b21429032f7551c603e5b82b5c491bb7f0a GIT binary patch literal 16384 zcmeI&O>fgM7zc1CDO7ub?1IV;h)2~XZ4#g@3F(0g>1u6JGYY|UFPF$`T`5}INNWZ{ zAf%qS!{^~U@L6`L@~7*a7_?*|K9>e(c{ZO5VPS(p*s|JL$=sT(-nalf5Kj zjG4L?biEEscc!0>>xn1Rwwb2tWV=5P$##AOHa^ z@YyPsR#%PTi(DRcRnqDtyIcgdKm^nM!dA=!!AS(wh|}~8tX%m>z{}fB*y_009U<00Izz00bcL&jl9r4*+)`{`hI>mR$muMVQzC literal 0 HcmV?d00001 diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/e4d786a9d3f442592c6dee4715e15ea5799f71e1ddd4cb5af2330b36927da00a.sqlite b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/e4d786a9d3f442592c6dee4715e15ea5799f71e1ddd4cb5af2330b36927da00a.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..0ffd69a80e6e0b56df774a39a52b4db88e74fb74 GIT binary patch literal 16384 zcmeI&L2uJA6bEo8DOB5l?1IV;6HgVBHVHT+rB%g+bhRL~qb!(i-6e7sM~c=q(wcz~ zXi`tRkI%z*;InYu0bH%C3Pp$uJD~rQEjza5Xa8c39r%SG(SU|%QTH6^@03Xf z-U*Ka@wuQ zySZV1W4a(9009U<00Izz00bZa0SG_<0ucCb1a27?yYF;&-A7Me9`u&=y$t&^(**$m z2tWV=5P$##AOHafKmY;|fWY+$^b70b_CVz_mbv_HXXT}l8jaFy=r_wtvlsXB%5-!) zi`D%2jXXP2!}5c;Qb`UIRjWJoYQ3sX4WJRINlxMb#+&XkC8)zhL&o^<5B| mAOHafKmY;|fB*y_009U<00I#B=K?GG2Y{RRzI?TG%YFcBu9^q{ literal 0 HcmV?d00001 diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/e6ea82d3126efcf151316cd7c7e9369c4c90288e408d715546a56c1714a4816f.sqlite b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/e6ea82d3126efcf151316cd7c7e9369c4c90288e408d715546a56c1714a4816f.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..9b5de96ca7c979b1b7e294610224fb42939bbd55 GIT binary patch literal 16384 zcmeI&K~LK-6bEo8DXV}%xJ-ou+EXH4ahU#3w(QuJpZ&WW-&g8RkKkz`1CpKOj7T*%J3qEfY18Vzlno7vpTiZS5h?3KoF_e#T>%2;Xql1uQW_}fHu?1_Wg zks!Zz?3F(l*<7(`44&Mj4Z3kJZg=FF3Zf+F$*u~fhs>W3G`)b<s$40(VWxwGVCu*7X$<# z009U<00Izz00bZa0SG_<0uLw9$*c~lU6sm6rt*iKzK1`Dfuv8LC#n33anu*lC5Ylyr8(C@Q=tw6{a2wc|LSth?|3H_X0yxC&Twq@R0Ptw}>o-fc>=sF$n(P1o literal 0 HcmV?d00001 diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/ef72c880cc9ffbbd112dda9c873527193e7ef291afcc3810749c1ecfff805af0.sqlite b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/ef72c880cc9ffbbd112dda9c873527193e7ef291afcc3810749c1ecfff805af0.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..9c3fbe559e47e52dcb091314f4b66a855ec6247e GIT binary patch literal 16384 zcmeI&O>fgM7zc1CDOB5l?1IV;h({HZHVKfnw%ejzNLP!6b`&zxms8{tU!?AVqc`*(|y<5!I&Q`AdO2Qs4)TV4n(RoE({4`Q z%?6QRIa3opt&B|AN^U*LOi= mf&c^{009U<00Izz00bZa0SG|gp9`$$9{_IN`|{P&E&BmFdzw!G literal 0 HcmV?d00001 diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/f7f9bcbdd88d6aef504e3c4007c80590bf5eca1eec1dbf73744c99a2734297b9.sqlite b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/f7f9bcbdd88d6aef504e3c4007c80590bf5eca1eec1dbf73744c99a2734297b9.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..0b4e610d974242eea6570b381fa28dc0795805fe GIT binary patch literal 16384 zcmeI&O>fgM7zc1CDXaDZ*#(sy5LU&cO@e7{+N~28($#_}DTQF#?i9I+BSmW)Y0ZWZ z2&pIT@Ok(Ud=;)cfU9*?w@!!)JD~p~TXt;AkNvwv$=f$xl1Un;Cj*gDi>wn}C(kJ* zgy^c~RJ{yCb>^S7%ZWdtHa4^|n`L*~lil66v5?ViU(3Z=m;Y@d+Hv^H z);mu9R>x_)*YX9+(#H2M(guTMn8bZ?B7-OmhGHOt#UcIbffg6gtt7hf{&cb=Adi}!U3uC$sJJWYUYhxhsDgk1 z1Rwwb2tWV=5P$##AOHafK;ZTS`niW=Zy+-fiA?-5)8g_-jz&p3bQ;Cw*^7H|bvk;O zMsj}qPL>|aVezr3?8^NxEX$HrE7wXrS&gk~Sn9=lrKnu%mG}0;>PO?^`~NvH&u{O7 n$OHihKmY;|fB*y_009U<00Izzz&{sQRX+eMJ^22^P%ZflDF~S- literal 0 HcmV?d00001 diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/metadata.sqlite-shm b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/metadata.sqlite-shm index e43b9f8570715c9401131950385266ba96fccf11..cbf7ee4c8e4e3f2f1444d87b9936d72c7a9f4bd7 100644 GIT binary patch literal 32768 zcmeI)b!=Bx7{>AMQ{0PJa4%5Yic{Q+Q@psfI4$m4T*}6f8G|u=1LnpY#x`ueF}5*- z4H<(e{7N9fSzW;0!ne&VVy; zFEViZU*oFmq$M5c z$v{Rjk(n%HB^%kvK`=SVMQ-wtmwehL}7|hlwuU81SKg&Y03~mS;|qK zNGec~N>ru_RjEdGYEY9}L{XbM)TJKvX+T375lv&7(3EC0rv)u(MQhs7mUgr!h7NS3 z6P@WoSGv)iSbETtUi799ed$LW{TaYO1~Hf+3}qO@8No>6NnjMC8N*n{F`fxbB#|U0 zF_|e$Wg63&!Axc`n>oy79`jkiLKd-@WR|d$Wh`d}D_O;A*07co)^Q*Avz`aoz(zK) znFo1@hk1lY*}`LNWgCyPohR7APIj@IJ?v#4Px2J|Ilw`l<{6&lIiBYQUgRZS<`rJ$ z5U=q%Z}28>@iy;pm?ON)QQqTyKHwO~Il)Oz@gX1aF`w`$pYb_g@Fl1DiZgu8H+;)k zzTPxfAbI51I>PZ88ur#SW301eb24b~71)i4d$2#r*{5;RJqHAZ7K zPUAH}6P2hWP10md(Ns;-bj{FA&C+bm(Ok{bd@ayIEz)8oYl)U>nU-sXR%(@2YmL?_ zMX6npTo$l^1uS3z3s}Gc7O;Q?EMNf(Sik}nuz&?DU;ztQzycPqfCVgI0Sj2b0v51< h1uS3z3s}Gc7O;Q?EMNf(Sik}nuz&?DV1a*6;0EF7+7kc( delta 160 zcmZo@U}|V!s+V}A%K!pAK+MR%AixKtnSi+L!6SpcpAYX}mH73rrQ|c|V!7$rSu>uW tCRIJqC@|m!lK+taRG5i@X=0=O<|k}YHk&`NsjzMS=On?r`4QVIO8|2nHv#|v diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/metadata.sqlite-wal b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/metadata.sqlite-wal index 0e80af47506d3d38a7f5a395a2d97b4e18ae0d52..f1c58cf1948864124d41aba95b31a954f77d9eea 100644 GIT binary patch literal 1676872 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 delta 21 ccmX@HChfx&>4p}@7N!>F7M2#)Eo=p^0Bni~@c;k- diff --git a/workers/collaboration/test/session-state.test.ts b/workers/collaboration/test/session-state.test.ts index 88ccb3fbae63..e4e5b408abb9 100644 --- a/workers/collaboration/test/session-state.test.ts +++ b/workers/collaboration/test/session-state.test.ts @@ -93,6 +93,36 @@ test("forwards terminal collaboration frames to other peers", () => { }); }); +test("preserves terminal output caret attribution", () => { + const state = new CollaborationRelaySessionState(); + const first = new FakeSocket(); + const second = new FakeSocket(); + state.addPeer("ABCD-1234", peer("p1"), first, 1000); + state.addPeer("ABCD-1234", peer("p2"), second, 1000); + + state.handleMessage( + "p1", + JSON.stringify({ + type: "terminal.output", + terminalID: "term1", + sequence: 7, + dataBase64: "b2s=", + caretPeerID: null, + }), + 1100 + ); + + expect(JSON.parse(second.sent.at(-1) ?? "{}")).toEqual({ + type: "terminal.output", + terminalID: "term1", + sequence: 7, + dataBase64: "b2s=", + caretPeerID: null, + fromPeerID: "p1", + receivedAt: 1100, + }); +}); + test("rejects malformed frames and broadcasts peer departure", () => { const state = new CollaborationRelaySessionState(); const first = new FakeSocket(); From d608ef1904b4592e7bc3468456e3194a284114ec Mon Sep 17 00:00:00 2001 From: DorsaRoh Date: Tue, 30 Jun 2026 03:38:34 -0700 Subject: [PATCH 4/5] Prepare multiplayer release validation. --- .github/workflows/collaboration.yml | 130 +++++++++++ .github/workflows/nightly.yml | 2 + .github/workflows/release.yml | 2 + Resources/Localizable.xcstrings | 17 ++ Sources/CollaborationRuntime.swift | 28 ++- cmux.xcodeproj/project.pbxproj | 32 +-- scripts/smoke-installable-artifact.sh | 222 +++++++++++++++++++ workers/collaboration/README.md | 26 ++- workers/collaboration/package.json | 1 + workers/collaboration/scripts/smoke-relay.ts | 188 ++++++++++++++++ workers/collaboration/src/session.ts | 33 ++- workers/collaboration/tsconfig.test.json | 2 +- workers/collaboration/wrangler.toml | 15 ++ 13 files changed, 665 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/collaboration.yml create mode 100755 scripts/smoke-installable-artifact.sh create mode 100644 workers/collaboration/scripts/smoke-relay.ts diff --git a/.github/workflows/collaboration.yml b/.github/workflows/collaboration.yml new file mode 100644 index 000000000000..0d9de75c9e18 --- /dev/null +++ b/.github/workflows/collaboration.yml @@ -0,0 +1,130 @@ +# CI/CD for the cmux Multiplayer collaboration relay (workers/collaboration). +# +# PRs touching the service run typecheck + unit tests + a wrangler dry-run +# build. Pushes to main additionally deploy to Cloudflare. `wrangler deploy` +# applies the Durable Object migrations declared in wrangler.toml atomically +# with the code upload, so the service's schema can never lag a deploy. +# +# Required repository secrets (deploy job): +# CLOUDFLARE_API_TOKEN API token with Workers Scripts:Edit on the account +# CLOUDFLARE_ACCOUNT_ID the Cloudflare account id + +name: collaboration + +on: + pull_request: + paths: + - "workers/collaboration/**" + - ".github/workflows/collaboration.yml" + push: + branches: [main] + paths: + - "workers/collaboration/**" + - ".github/workflows/collaboration.yml" + workflow_dispatch: + inputs: + relay_url: + description: "Relay URL to smoke-test" + required: false + default: "https://collaboration.cmux.dev" + +permissions: + contents: read + +jobs: + test: + runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + + # Wrangler requires Node >= 22; the Blacksmith ubuntu-2404 image ships + # Node 20, so pin it explicitly instead of relying on the runner's + # ambient version. + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "22" + + - name: Install dependencies + run: bun install --cwd workers/collaboration --frozen-lockfile + + - name: Typecheck + run: bun run --cwd workers/collaboration typecheck + + - name: Unit tests + run: bun run --cwd workers/collaboration test + + - name: Wrangler dry-run build + run: bun run --cwd workers/collaboration check + + deploy: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: test + runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + concurrency: + group: collaboration-deploy + cancel-in-progress: false + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "22" + + - name: Install dependencies + run: bun install --cwd workers/collaboration --frozen-lockfile + + - name: Check Cloudflare secrets + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + missing=() + [ -n "$CLOUDFLARE_API_TOKEN" ] || missing+=(CLOUDFLARE_API_TOKEN) + [ -n "$CLOUDFLARE_ACCOUNT_ID" ] || missing+=(CLOUDFLARE_ACCOUNT_ID) + if [ "${#missing[@]}" -gt 0 ]; then + echo "::error::Collaboration deploy needs repository secrets ${missing[*]}." \ + "Create an API token with Workers Scripts:Edit on the Cloudflare account" \ + "and add both secrets in repo Settings -> Secrets and variables -> Actions" \ + "(see workers/collaboration/README.md, Deploy)." + exit 1 + fi + + - name: Deploy (applies DO migrations atomically) + run: bun run --cwd workers/collaboration deploy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + + - name: Smoke production relay create/connect + run: bun run --cwd workers/collaboration smoke:relay + env: + CMUX_COLLABORATION_RELAY_URL: https://collaboration.cmux.dev + + live-smoke: + if: github.event_name == 'workflow_dispatch' + needs: test + runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + + - name: Install dependencies + run: bun install --cwd workers/collaboration --frozen-lockfile + + - name: Smoke relay create/connect + run: bun run --cwd workers/collaboration smoke:relay + env: + CMUX_COLLABORATION_RELAY_URL: ${{ inputs.relay_url }} diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 0d7baabcca52..0aeb21b67496 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -559,6 +559,7 @@ jobs: CMUX_SMOKE_ALLOW_UNSUPPORTED_GUI=1 ./scripts/smoke-launch-macos-app.sh "$app_path" CMUX_SMOKE_DIRECT_EXEC=1 ./scripts/smoke-launch-macos-app.sh "$app_path" ./scripts/verify-app-bundle-channel-metadata.sh "$app_path" nightly + ./scripts/smoke-installable-artifact.sh --channel nightly "$app_path" rm -f "$zip_submit" dmg_tmp_dir="$(mktemp -d)" @@ -588,6 +589,7 @@ jobs: fi xcrun stapler staple "$dmg_release" xcrun stapler validate "$dmg_release" + ./scripts/smoke-installable-artifact.sh --channel nightly "$dmg_release" cp "$dmg_release" "$dmg_immutable" } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 24c9f9aca873..d6b6a055aaa1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -445,6 +445,7 @@ jobs: CMUX_SMOKE_ALLOW_UNSUPPORTED_GUI=1 CMUX_SMOKE_DEBUG_LOGS=1 ./scripts/smoke-launch-macos-app.sh "$APP_PATH" CMUX_SMOKE_DIRECT_EXEC=1 CMUX_SMOKE_DEBUG_LOGS=1 ./scripts/smoke-launch-macos-app.sh "$APP_PATH" ./scripts/verify-app-bundle-channel-metadata.sh "$APP_PATH" stable + ./scripts/smoke-installable-artifact.sh --channel stable "$APP_PATH" rm -f "$ZIP_SUBMIT" # create-dmg generates a styled drag-to-install DMG create-dmg \ @@ -466,6 +467,7 @@ jobs: fi xcrun stapler staple "$DMG_RELEASE" xcrun stapler validate "$DMG_RELEASE" + ./scripts/smoke-installable-artifact.sh --channel stable "$DMG_RELEASE" - name: Upload dSYMs to Sentry if: steps.guard_release_assets.outputs.skip_all != 'true' diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 76c923379d7d..515d3b228d9e 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -217408,6 +217408,23 @@ } } } + }, + "collaboration.relay.urlPlaceholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Relay URL" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リレー URL" + } + } + } } }, "version": "1.0" diff --git a/Sources/CollaborationRuntime.swift b/Sources/CollaborationRuntime.swift index 1b468b303406..09dc4e59b3d8 100644 --- a/Sources/CollaborationRuntime.swift +++ b/Sources/CollaborationRuntime.swift @@ -217,8 +217,9 @@ struct CollaborationTerminalHeaderState: Equatable { @Observable final class CollaborationRuntime { static let shared = CollaborationRuntime() + private static let defaultRelayURLString = "https://collaboration.cmux.dev" - private(set) var relayURLString = "http://localhost:8787" + private(set) var relayURLString = CollaborationRuntime.defaultRelayURLString private(set) var sessionCode: String? private(set) var inviteToken: String? private(set) var connectionLabel = CollaborationStrings.disconnected @@ -251,6 +252,11 @@ final class CollaborationRuntime { peerIdentity = CollaborationPeerIdentity.ephemeral(displayName: displayName) } + private static func normalizedRelayURL(from value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? defaultRelayURLString : trimmed + } + func state(for panel: any CollaborationEditablePanel) -> CollaborationDocumentHeaderState { let descriptor = descriptor(for: panel) let documentID = descriptor.documentID(sessionID: sessionCode ?? "") @@ -513,8 +519,8 @@ final class CollaborationRuntime { } func createSessionForAutomation(relayURL: String?) async -> [String: Any] { - if let relayURL, !relayURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - relayURLString = relayURL.trimmingCharacters(in: .whitespacesAndNewlines) + if let relayURL { + relayURLString = Self.normalizedRelayURL(from: relayURL) } do { let response = try await createSession() @@ -545,8 +551,8 @@ final class CollaborationRuntime { } func joinSessionForAutomation(relayURL: String?, code: String, token: String) async -> [String: Any] { - if let relayURL, !relayURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - relayURLString = relayURL.trimmingCharacters(in: .whitespacesAndNewlines) + if let relayURL { + relayURLString = Self.normalizedRelayURL(from: relayURL) } await joinSession(code: code, token: token) return statusPayload() @@ -613,12 +619,12 @@ final class CollaborationRuntime { alert.addButton(withTitle: CollaborationStrings.cancel) let relayField = NSTextField(string: relayURLString) - relayField.placeholderString = "http://localhost:8787" + relayField.placeholderString = CollaborationStrings.relayURLPlaceholder relayField.frame = NSRect(x: 0, y: 0, width: 360, height: 24) alert.accessoryView = relayField let response = alert.runModal() - relayURLString = relayField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + relayURLString = Self.normalizedRelayURL(from: relayField.stringValue) switch response { case .alertFirstButtonReturn: Task { _ = await createSessionForAutomation(relayURL: relayURLString) } @@ -663,12 +669,12 @@ final class CollaborationRuntime { alert.addButton(withTitle: CollaborationStrings.cancel) let relayField = NSTextField(string: relayURLString) - relayField.placeholderString = "http://localhost:8787" + relayField.placeholderString = CollaborationStrings.relayURLPlaceholder relayField.frame = NSRect(x: 0, y: 0, width: 360, height: 24) alert.accessoryView = relayField let response = alert.runModal() - relayURLString = relayField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + relayURLString = Self.normalizedRelayURL(from: relayField.stringValue) switch response { case .alertFirstButtonReturn: Task { await createSessionAndShare(panel: panel) } @@ -1452,6 +1458,10 @@ enum CollaborationStrings { String(localized: "collaboration.start.message", defaultValue: "Enter a relay URL, then create a new invite or join an existing one.") } + static var relayURLPlaceholder: String { + String(localized: "collaboration.relay.urlPlaceholder", defaultValue: "Relay URL") + } + static var createSession: String { String(localized: "collaboration.action.createSession", defaultValue: "Create Session") } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 055c33c75940..811cc3e0b5ac 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -5332,10 +5332,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 97; + CURRENT_PROJECT_VERSION = 98; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.64.17; + MARKETING_VERSION = 0.65.0; ONLY_ACTIVE_ARCH = NO; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -5415,7 +5415,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 97; + CURRENT_PROJECT_VERSION = 98; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = NO; @@ -5424,7 +5424,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.64.17; + MARKETING_VERSION = 0.65.0; OTHER_LDFLAGS = ( "-lc++", "-framework", @@ -5461,7 +5461,7 @@ CODE_SIGN_ENTITLEMENTS = Resources/cmux.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 97; + CURRENT_PROJECT_VERSION = 98; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = NO; @@ -5470,7 +5470,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.64.17; + MARKETING_VERSION = 0.65.0; ONLY_ACTIVE_ARCH = NO; OTHER_LDFLAGS = ( "-lc++", @@ -5537,10 +5537,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 97; + CURRENT_PROJECT_VERSION = 98; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.64.17; + MARKETING_VERSION = 0.65.0; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -5555,7 +5555,7 @@ buildSettings = { CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 97; + CURRENT_PROJECT_VERSION = 98; DEVELOPMENT_TEAM = ""; ENABLE_USER_SCRIPT_SANDBOXING = YES; GENERATE_INFOPLIST_FILE = YES; @@ -5564,7 +5564,7 @@ INFOPLIST_KEY_NSPrincipalClass = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles"; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.64.17; + MARKETING_VERSION = 0.65.0; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.app.docktileplugin.debug; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -5580,7 +5580,7 @@ buildSettings = { CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 97; + CURRENT_PROJECT_VERSION = 98; DEVELOPMENT_TEAM = ""; ENABLE_USER_SCRIPT_SANDBOXING = YES; GENERATE_INFOPLIST_FILE = YES; @@ -5589,7 +5589,7 @@ INFOPLIST_KEY_NSPrincipalClass = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles"; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.64.17; + MARKETING_VERSION = 0.65.0; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.app.docktileplugin; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -5604,10 +5604,10 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 97; + CURRENT_PROJECT_VERSION = 98; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.64.17; + MARKETING_VERSION = 0.65.0; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -5623,10 +5623,10 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 97; + CURRENT_PROJECT_VERSION = 98; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.64.17; + MARKETING_VERSION = 0.65.0; ONLY_ACTIVE_ARCH = NO; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/scripts/smoke-installable-artifact.sh b/scripts/smoke-installable-artifact.sh new file mode 100755 index 000000000000..f782a8d10c36 --- /dev/null +++ b/scripts/smoke-installable-artifact.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +usage: smoke-installable-artifact.sh [--channel stable|nightly|debug] + +Runs artifact-level checks against the built product a user downloads or launches. +For a DMG, the script mounts it read-only, discovers the contained .app, verifies +the app, then detaches the image. + +Environment: + CMUX_INSTALLABLE_REQUIRE_NOTARIZATION=0 Skip stapler validation. + CMUX_INSTALLABLE_REQUIRE_SPCTL=0 Skip Gatekeeper assessment. + CMUX_INSTALLABLE_RUN_CLI=0 Skip bundled CLI help/version smoke. +EOF +} + +CHANNEL="stable" +ARTIFACT_PATH="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --channel) + [[ $# -ge 2 ]] || { usage >&2; exit 2; } + CHANNEL="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + --*) + echo "error: unknown option $1" >&2 + usage >&2 + exit 2 + ;; + *) + if [[ -n "$ARTIFACT_PATH" ]]; then + echo "error: unexpected extra argument $1" >&2 + usage >&2 + exit 2 + fi + ARTIFACT_PATH="$1" + shift + ;; + esac +done + +if [[ -z "$ARTIFACT_PATH" ]]; then + usage >&2 + exit 2 +fi + +case "$CHANNEL" in + stable) + EXPECTED_BUNDLE_ID="com.cmuxterm.app" + EXPECTED_APP_NAME="cmux" + ;; + nightly) + EXPECTED_BUNDLE_ID="com.cmuxterm.app.nightly" + EXPECTED_APP_NAME="cmux NIGHTLY" + ;; + debug) + EXPECTED_BUNDLE_ID_PREFIX="com.cmuxterm.app.debug." + EXPECTED_APP_NAME_PREFIX="cmux DEV " + ;; + *) + echo "error: unknown channel '$CHANNEL'" >&2 + usage >&2 + exit 2 + ;; +esac + +REQUIRE_NOTARIZATION="${CMUX_INSTALLABLE_REQUIRE_NOTARIZATION:-1}" +REQUIRE_SPCTL="${CMUX_INSTALLABLE_REQUIRE_SPCTL:-1}" +RUN_CLI="${CMUX_INSTALLABLE_RUN_CLI:-1}" +MOUNT_DIR="" +DEVICE="" + +cleanup() { + if [[ -n "$DEVICE" ]]; then + hdiutil detach "$DEVICE" -quiet >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +plist_value() { + local plist="$1" + local key="$2" + /usr/libexec/PlistBuddy -c "Print :$key" "$plist" +} + +expect_nonempty_plist_value() { + local plist="$1" + local key="$2" + local value + value="$(plist_value "$plist" "$key" 2>/dev/null || true)" + if [[ -z "$value" ]]; then + echo "error: $key missing or empty in $plist" >&2 + exit 1 + fi + printf '%s' "$value" +} + +discover_app_in_dmg() { + local dmg_path="$1" + local attach_output + attach_output="$(hdiutil attach -readonly -nobrowse -plist "$dmg_path")" + DEVICE="$(python3 -c 'import plistlib,sys; p=plistlib.load(sys.stdin.buffer); print(next(e["dev-entry"] for e in p["system-entities"] if "mount-point" in e))' <<<"$attach_output")" + MOUNT_DIR="$(python3 -c 'import plistlib,sys; p=plistlib.load(sys.stdin.buffer); print(next(e["mount-point"] for e in p["system-entities"] if "mount-point" in e))' <<<"$attach_output")" + python3 - "$MOUNT_DIR" <<'PY' +import pathlib +import sys + +mount = pathlib.Path(sys.argv[1]) +apps = sorted(path for path in mount.iterdir() if path.suffix == ".app" and path.is_dir()) +if len(apps) != 1: + raise SystemExit(f"expected exactly one .app in {mount}, found {len(apps)}") +print(apps[0]) +PY +} + +if [[ ! -e "$ARTIFACT_PATH" ]]; then + echo "error: artifact not found at $ARTIFACT_PATH" >&2 + exit 1 +fi + +APP_PATH="$ARTIFACT_PATH" +if [[ "$ARTIFACT_PATH" == *.dmg ]]; then + echo "==> validating DMG container: $ARTIFACT_PATH" + /usr/bin/codesign --verify --verbose=2 "$ARTIFACT_PATH" + if [[ "$REQUIRE_NOTARIZATION" == "1" ]]; then + xcrun stapler validate "$ARTIFACT_PATH" + fi + APP_PATH="$(discover_app_in_dmg "$ARTIFACT_PATH")" +fi + +if [[ ! -d "$APP_PATH/Contents" ]]; then + echo "error: app bundle not found at $APP_PATH" >&2 + exit 1 +fi + +INFO_PLIST="$APP_PATH/Contents/Info.plist" +if [[ ! -f "$INFO_PLIST" ]]; then + echo "error: Info.plist missing at $INFO_PLIST" >&2 + exit 1 +fi + +echo "==> validating app bundle: $APP_PATH" +BUNDLE_ID="$(expect_nonempty_plist_value "$INFO_PLIST" CFBundleIdentifier)" +APP_NAME="$(expect_nonempty_plist_value "$INFO_PLIST" CFBundleName)" +DISPLAY_NAME="$(expect_nonempty_plist_value "$INFO_PLIST" CFBundleDisplayName)" +EXECUTABLE_NAME="$(expect_nonempty_plist_value "$INFO_PLIST" CFBundleExecutable)" +MARKETING_VERSION="$(expect_nonempty_plist_value "$INFO_PLIST" CFBundleShortVersionString)" +BUILD_VERSION="$(expect_nonempty_plist_value "$INFO_PLIST" CFBundleVersion)" + +if [[ "$CHANNEL" == "debug" ]]; then + if [[ "$BUNDLE_ID" != "$EXPECTED_BUNDLE_ID_PREFIX"* ]]; then + echo "error: debug bundle id expected prefix '$EXPECTED_BUNDLE_ID_PREFIX', found '$BUNDLE_ID'" >&2 + exit 1 + fi + if [[ "$DISPLAY_NAME" != "$EXPECTED_APP_NAME_PREFIX"* ]]; then + echo "error: debug display name expected prefix '$EXPECTED_APP_NAME_PREFIX', found '$DISPLAY_NAME'" >&2 + exit 1 + fi +else + if [[ "$BUNDLE_ID" != "$EXPECTED_BUNDLE_ID" ]]; then + echo "error: bundle id expected '$EXPECTED_BUNDLE_ID', found '$BUNDLE_ID'" >&2 + exit 1 + fi + if [[ "$APP_NAME" != "$EXPECTED_APP_NAME" || "$DISPLAY_NAME" != "$EXPECTED_APP_NAME" ]]; then + echo "error: app name/display name expected '$EXPECTED_APP_NAME', found '$APP_NAME'/'$DISPLAY_NAME'" >&2 + exit 1 + fi +fi + +EXECUTABLE_PATH="$APP_PATH/Contents/MacOS/$EXECUTABLE_NAME" +if [[ ! -x "$EXECUTABLE_PATH" ]]; then + echo "error: app executable missing or not executable at $EXECUTABLE_PATH" >&2 + exit 1 +fi + +CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux" +if [[ ! -x "$CLI_PATH" ]]; then + echo "error: bundled CLI missing or not executable at $CLI_PATH" >&2 + exit 1 +fi + +if [[ "$RUN_CLI" == "1" ]]; then + "$CLI_PATH" --version >/dev/null + "$CLI_PATH" --help >/dev/null +fi + +if [[ "$CHANNEL" != "debug" ]]; then + PROFILE_PATH="$APP_PATH/Contents/embedded.provisionprofile" + if [[ ! -s "$PROFILE_PATH" ]]; then + echo "error: release app is missing embedded provisioning profile at $PROFILE_PATH" >&2 + exit 1 + fi + if command -v security >/dev/null; then + TMP_PROFILE_PLIST="$(mktemp -t cmux-profile.XXXXXX.plist)" + security cms -D -i "$PROFILE_PATH" > "$TMP_PROFILE_PLIST" + APP_IDENTIFIER="$(plist_value "$TMP_PROFILE_PLIST" "Entitlements:com.apple.application-identifier")" + rm -f "$TMP_PROFILE_PLIST" + EXPECTED_APP_IDENTIFIER="7WLXT3NR37.$BUNDLE_ID" + if [[ "$APP_IDENTIFIER" != "$EXPECTED_APP_IDENTIFIER" ]]; then + echo "error: provisioning profile app id expected '$EXPECTED_APP_IDENTIFIER', found '$APP_IDENTIFIER'" >&2 + exit 1 + fi + fi +fi + +/usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH" +if [[ "$REQUIRE_SPCTL" == "1" ]]; then + spctl -a -vv --type execute "$APP_PATH" +fi +if [[ "$REQUIRE_NOTARIZATION" == "1" ]]; then + xcrun stapler validate "$APP_PATH" +fi + +echo "installable artifact smoke OK: bundle=$BUNDLE_ID version=$MARKETING_VERSION build=$BUILD_VERSION" diff --git a/workers/collaboration/README.md b/workers/collaboration/README.md index 807b667f44a8..f8cfc6a9e41b 100644 --- a/workers/collaboration/README.md +++ b/workers/collaboration/README.md @@ -11,16 +11,31 @@ bun test bun run dev ``` -The local relay defaults to `http://localhost:8787`. In cmux, open a plain-text file preview, click **Collaborate**, enter that relay URL, and choose **Create Session**. A second cmux instance can join with the displayed session code and invite token. +Downloadable cmux builds default to the production relay at `https://collaboration.cmux.dev`. For local development, override the relay URL with `http://localhost:8787` in the collaboration dialog or with `cmux collaboration create --relay-url http://localhost:8787`. ## Deploy +Pushes to `main` that touch this worker run `.github/workflows/collaboration.yml`, which typechecks, runs unit tests, dry-runs Wrangler, then deploys to Cloudflare with Durable Object migrations applied atomically. + ```bash bun run check bun run deploy ``` -`wrangler.toml` binds the `COLLABORATION_SESSIONS` Durable Object namespace. Production deployments should expose the Worker behind HTTPS; the macOS client converts `https://` relay URLs to `wss://` for WebSocket joins. +The deploy job requires repository secrets `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`. `wrangler.toml` binds the `COLLABORATION_SESSIONS` Durable Object namespace and exposes the production custom domain `collaboration.cmux.dev`; the macOS client converts `https://` relay URLs to `wss://` for WebSocket joins. + +After deployment, smoke-test the public relay: + +```bash +bun run smoke:relay +``` + +The smoke test performs a real health check, session creation, two WebSocket peer joins, heartbeat handling, and document frame forwarding. Set `CMUX_COLLABORATION_RELAY_URL` or pass a URL to test another relay: + +```bash +CMUX_COLLABORATION_RELAY_URL=http://localhost:8787 bun run smoke:relay +bun run smoke:relay https://collaboration.cmux.dev +``` ## HTTP API @@ -61,6 +76,12 @@ The relay treats non-heartbeat frames as opaque JSON envelopes with a string `ty - `document.snapshot.request` - `document.snapshot` - `presence.update` +- `terminal.open` +- `terminal.output` +- `terminal.input` +- `terminal.pointer` +- `terminal.selection` +- `terminal.close` `peer.heartbeat` updates liveness and is not forwarded. @@ -70,5 +91,4 @@ The relay treats non-heartbeat frames as opaque JSON envelopes with a string `ty - No Git automation. - No account auth or ACLs beyond the invite token. - No NAT traversal or direct peer-to-peer transport. -- No terminal sharing. - Durable Object active memory is the session state; document content is never persisted by the relay. diff --git a/workers/collaboration/package.json b/workers/collaboration/package.json index 9a54211d89b3..d8ffdb5de124 100644 --- a/workers/collaboration/package.json +++ b/workers/collaboration/package.json @@ -5,6 +5,7 @@ "scripts": { "dev": "wrangler dev", "deploy": "wrangler deploy", + "smoke:relay": "bun scripts/smoke-relay.ts", "test": "bun test", "typecheck": "tsgo --noEmit && tsgo --noEmit -p tsconfig.test.json", "check": "bun run typecheck && bun test && wrangler deploy --dry-run --outdir dist" diff --git a/workers/collaboration/scripts/smoke-relay.ts b/workers/collaboration/scripts/smoke-relay.ts new file mode 100644 index 000000000000..96dfbe751665 --- /dev/null +++ b/workers/collaboration/scripts/smoke-relay.ts @@ -0,0 +1,188 @@ +type JsonObject = Record; + +const relayURL = normalizeRelayURL(process.env.CMUX_COLLABORATION_RELAY_URL ?? process.argv[2] ?? "https://collaboration.cmux.dev"); +const timeoutMs = Number(process.env.CMUX_COLLABORATION_SMOKE_TIMEOUT_MS ?? "15000"); + +function normalizeRelayURL(value: string): URL { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`relay URL must use http or https, got ${url.protocol}`); + } + url.pathname = url.pathname.replace(/\/+$/, ""); + url.search = ""; + url.hash = ""; + return url; +} + +function withPath(base: URL, path: string): URL { + const next = new URL(base); + const basePath = base.pathname === "/" ? "" : base.pathname.replace(/\/+$/, ""); + next.pathname = `${basePath}${path}`; + return next; +} + +function webSocketURL(base: URL, sessionCode: string, token: string, peerID: string): URL { + const url = withPath(base, `/v1/collaboration/sessions/${sessionCode}/connect`); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + url.searchParams.set("token", token); + url.searchParams.set("peerID", peerID); + url.searchParams.set("displayName", `smoke-${peerID}`); + url.searchParams.set("color", peerID === "peer-a" ? "#ff0000" : "#0000ff"); + return url; +} + +function fail(message: string): never { + throw new Error(message); +} + +function parseJSON(data: unknown): JsonObject { + const text = typeof data === "string" ? data : String(data); + const parsed = JSON.parse(text); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + fail(`expected JSON object frame, got ${text}`); + } + return parsed as JsonObject; +} + +class SmokePeer { + private socket: WebSocket; + private frames: JsonObject[] = []; + private waiters: Array<{ + predicate: (frame: JsonObject) => boolean; + resolve: (frame: JsonObject) => void; + reject: (error: Error) => void; + timer: ReturnType; + }> = []; + + constructor(url: URL) { + this.socket = new WebSocket(url.href); + this.socket.addEventListener("message", (event) => { + const frame = parseJSON(event.data); + this.frames.push(frame); + for (const waiter of [...this.waiters]) { + if (!waiter.predicate(frame)) continue; + clearTimeout(waiter.timer); + this.waiters = this.waiters.filter((candidate) => candidate !== waiter); + waiter.resolve(frame); + } + }); + this.socket.addEventListener("error", () => { + this.rejectWaiters(new Error(`websocket error for ${url.href}`)); + }); + this.socket.addEventListener("close", (event) => { + if (event.code === 1000 || event.code === 1005) return; + this.rejectWaiters(new Error(`websocket closed unexpectedly: ${event.code} ${event.reason}`)); + }); + } + + async open(): Promise { + if (this.socket.readyState === WebSocket.OPEN) return; + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("timed out waiting for websocket open")), timeoutMs); + this.socket.addEventListener("open", () => { + clearTimeout(timer); + resolve(); + }, { once: true }); + this.socket.addEventListener("error", () => { + clearTimeout(timer); + reject(new Error("websocket failed before open")); + }, { once: true }); + }); + } + + send(frame: JsonObject): void { + this.socket.send(JSON.stringify(frame)); + } + + async waitFor(predicate: (frame: JsonObject) => boolean, description: string): Promise { + const existing = this.frames.find(predicate); + if (existing) return existing; + return new Promise((resolve, reject) => { + const waiter = { + predicate, + resolve, + reject, + timer: setTimeout(() => { + this.waiters = this.waiters.filter((candidate) => candidate !== waiter); + reject(new Error(`timed out waiting for ${description}`)); + }, timeoutMs), + }; + this.waiters.push(waiter); + }); + } + + frameCount(type: string): number { + return this.frames.filter((frame) => frame.type === type).length; + } + + close(): void { + this.socket.close(1000, "smoke complete"); + this.rejectWaiters(new Error("smoke peer closed")); + } + + private rejectWaiters(error: Error): void { + for (const waiter of this.waiters) { + clearTimeout(waiter.timer); + waiter.reject(error); + } + this.waiters = []; + } +} + +async function main(): Promise { + const healthResponse = await fetch(withPath(relayURL, "/healthz")); + if (!healthResponse.ok) { + fail(`healthz failed: ${healthResponse.status} ${await healthResponse.text()}`); + } + const health = await healthResponse.json() as JsonObject; + if (health.ok !== true || health.service !== "cmux-collaboration") { + fail(`unexpected healthz body: ${JSON.stringify(health)}`); + } + + const createResponse = await fetch(withPath(relayURL, "/v1/collaboration/sessions"), { method: "POST" }); + if (createResponse.status !== 201) { + fail(`session create failed: ${createResponse.status} ${await createResponse.text()}`); + } + const created = await createResponse.json() as { sessionCode?: string; token?: string }; + if (!created.sessionCode || !created.token) { + fail(`session create returned incomplete invite: ${JSON.stringify(created)}`); + } + + const first = new SmokePeer(webSocketURL(relayURL, created.sessionCode, created.token, "peer-a")); + let second: SmokePeer | null = null; + try { + await first.open(); + await first.waitFor((frame) => frame.type === "session.joined", "first session.joined"); + second = new SmokePeer(webSocketURL(relayURL, created.sessionCode, created.token, "peer-b")); + await second.open(); + + const secondJoined = await second.waitFor((frame) => frame.type === "session.joined", "second session.joined"); + const peers = secondJoined.peers; + if (!Array.isArray(peers) || peers.length !== 2) { + fail(`second peer did not see both peers: ${JSON.stringify(secondJoined)}`); + } + await first.waitFor((frame) => frame.type === "peer.joined" && (frame.peer as JsonObject | undefined)?.peerID === "peer-b", "peer-a peer.joined"); + + first.send({ type: "peer.heartbeat" }); + await Bun.sleep(250); + if (second.frameCount("peer.heartbeat") !== 0) { + fail("heartbeat frame was forwarded to another peer"); + } + + first.send({ type: "document.update", documentID: "smoke-doc", updateID: "smoke-update", operations: [] }); + const forwarded = await second.waitFor((frame) => frame.type === "document.update", "document update forwarding"); + if (forwarded.documentID !== "smoke-doc" || forwarded.fromPeerID !== "peer-a") { + fail(`unexpected forwarded frame: ${JSON.stringify(forwarded)}`); + } + + console.log(`collaboration relay smoke OK: ${relayURL.href} session ${created.sessionCode}`); + } finally { + first.close(); + second?.close(); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/workers/collaboration/src/session.ts b/workers/collaboration/src/session.ts index 22a9e0f620e0..5337494e0aad 100644 --- a/workers/collaboration/src/session.ts +++ b/workers/collaboration/src/session.ts @@ -4,6 +4,7 @@ import { CollaborationRelaySessionState } from "./session-state"; import { createSessionMetadata, readSessionMetadata, type SessionMetadata } from "./session-metadata"; const HEARTBEAT_TIMEOUT_MS = 30_000; +const liveSessionStates = new Map(); export class CollaborationSessionObject extends DurableObject { private metadata: SessionMetadata | null = null; @@ -44,14 +45,16 @@ export class CollaborationSessionObject extends DurableObject { return new Response(JSON.stringify({ error: "invalid_peer" }), { status: 400 }); } + const state = this.stateFor(metadata.sessionID); const pair = new WebSocketPair(); const client = pair[0]; const server = pair[1]; + const now = Date.now(); server.accept(); - this.state.addPeer(metadata.sessionID, peer, server, Date.now()); - server.addEventListener("message", (event) => this.state.handleMessage(peer.peerID, event.data, Date.now())); - server.addEventListener("close", () => this.state.dropPeer(peer.peerID, "disconnect")); - server.addEventListener("error", () => this.state.dropPeer(peer.peerID, "disconnect")); + state.addPeer(metadata.sessionID, peer, server, now); + server.addEventListener("message", (event) => state.handleMessage(peer.peerID, event.data, Date.now())); + server.addEventListener("close", () => this.dropPeer(metadata.sessionID, peer.peerID, "disconnect")); + server.addEventListener("error", () => this.dropPeer(metadata.sessionID, peer.peerID, "disconnect")); await this.ensureAlarm(); return new Response(null, { status: 101, webSocket: client }); } @@ -70,4 +73,26 @@ export class CollaborationSessionObject extends DurableObject { this.metadata = await readSessionMetadata(this.ctx.storage); return this.metadata; } + + private stateFor(sessionID: string): CollaborationRelaySessionState { + let state = liveSessionStates.get(sessionID); + if (state === undefined) { + state = new CollaborationRelaySessionState(); + liveSessionStates.set(sessionID, state); + } + this.state = state; + return state; + } + + private dropPeer( + sessionID: string, + peerID: string, + reason: "disconnect" | "timeout" | "leave", + ): void { + const state = this.stateFor(sessionID); + state.dropPeer(peerID, reason); + if (state.peerCount === 0) { + liveSessionStates.delete(sessionID); + } + } } diff --git a/workers/collaboration/tsconfig.test.json b/workers/collaboration/tsconfig.test.json index cb94263a20c3..c87e8d74051e 100644 --- a/workers/collaboration/tsconfig.test.json +++ b/workers/collaboration/tsconfig.test.json @@ -3,5 +3,5 @@ "compilerOptions": { "types": ["bun-types", "@cloudflare/workers-types"] }, - "include": ["src/**/*.ts", "test/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts"] } diff --git a/workers/collaboration/wrangler.toml b/workers/collaboration/wrangler.toml index 8cecc4438652..7d07e8f56aed 100644 --- a/workers/collaboration/wrangler.toml +++ b/workers/collaboration/wrangler.toml @@ -1,7 +1,16 @@ +# cmux Multiplayer collaboration relay — Cloudflare Worker + Durable Objects. +# +# Deploys run from .github/workflows/collaboration.yml on push to main. The +# Durable Object migration block is the service's schema migration story: +# wrangler applies class migrations atomically with every deploy. + name = "cmux-collaboration-worker" main = "src/index.ts" compatibility_date = "2026-06-01" +[observability] +enabled = true + [[durable_objects.bindings]] name = "COLLABORATION_SESSIONS" class_name = "CollaborationSessionObject" @@ -9,3 +18,9 @@ class_name = "CollaborationSessionObject" [[migrations]] tag = "v1" new_sqlite_classes = ["CollaborationSessionObject"] + +# Production relay URL used by downloadable cmux builds. The workers.dev URL +# remains available for ad hoc dev/staging smoke tests. +[[routes]] +pattern = "collaboration.cmux.dev" +custom_domain = true From c24711c14b7acf59cfa1fdf017c8cf358210fb22 Mon Sep 17 00:00:00 2001 From: DorsaRoh Date: Tue, 30 Jun 2026 03:40:21 -0700 Subject: [PATCH 5/5] Remove local Wrangler state artifacts. Co-authored-by: Cursor --- .gitignore | 1 + .../miniflare-CacheObject/metadata.sqlite | Bin 4096 -> 0 bytes .../miniflare-CacheObject/metadata.sqlite-shm | Bin 32768 -> 0 bytes .../miniflare-CacheObject/metadata.sqlite-wal | Bin 8272 -> 0 bytes ...e7688fe85c50c637943aedc0fce1e635763.sqlite | Bin 16384 -> 0 bytes ...cc7ab46b4432a117d2a30026628566ef84b.sqlite | Bin 8192 -> 0 bytes ...a60430cf3b0d1fb1d6b52abbbeed3662534.sqlite | Bin 4096 -> 0 bytes ...30cf3b0d1fb1d6b52abbbeed3662534.sqlite-shm | Bin 32768 -> 0 bytes ...30cf3b0d1fb1d6b52abbbeed3662534.sqlite-wal | Bin 90672 -> 0 bytes ...bf0fc9a641724d672dec2a87767fd020f43.sqlite | Bin 16384 -> 0 bytes ...8dba58df4e4e4d0d123c5aba165898e40d7.sqlite | Bin 16384 -> 0 bytes ...e3640a5a436f7a8802ab11e31068e79e205.sqlite | Bin 16384 -> 0 bytes ...8a63ed3890a890aed9ca101af1ac429312a.sqlite | Bin 16384 -> 0 bytes ...9ff492d0f4fa75b748c61ac002cef767e67.sqlite | Bin 16384 -> 0 bytes ...4255ffb66998e63abf461346e712a82b17a.sqlite | Bin 16384 -> 0 bytes ...dbc40983daecb04523b77aa8be3b26ce5dc.sqlite | Bin 8192 -> 0 bytes ...838a53329c465f20cb26bd92c581dbc6c84.sqlite | Bin 16384 -> 0 bytes ...c85cac82b296003f0686a9f931a1a87e2c2.sqlite | Bin 16384 -> 0 bytes ...c886f3d34eaebd01c6994bf88c6e014c97b.sqlite | Bin 16384 -> 0 bytes ...6c58467086c2fab35fbb14f2fa6d2fc61bd.sqlite | Bin 8192 -> 0 bytes ...2038bc30ec767d07f3bbb69df1525f4ff5b.sqlite | Bin 8192 -> 0 bytes ...cb7ca0be2bd005c8c2e7dec9244caa883d8.sqlite | Bin 8192 -> 0 bytes ...ea4c8d6a631bd96af46012b1e77875d7b4b.sqlite | Bin 16384 -> 0 bytes ...c0e4d9d2702b9c6b7742d1591144870071f.sqlite | Bin 16384 -> 0 bytes ...2a5a59dcf5cb549c6b8ca293916a0045e01.sqlite | Bin 16384 -> 0 bytes ...1ba5ba0f3012e307b9758f712787e9f860b.sqlite | Bin 8192 -> 0 bytes ...625c2d5563786ecc264864a6ddff28587ea.sqlite | Bin 16384 -> 0 bytes ...9bd0798dc17e527f5848fdce647d9d060a5.sqlite | Bin 8192 -> 0 bytes ...b7f02af105c7610872008f024096afac8d7.sqlite | Bin 16384 -> 0 bytes ...0a96ae46b4ed46279578a09474dc39cc1b8.sqlite | Bin 16384 -> 0 bytes ...ea5799f71e1ddd4cb5af2330b36927da00a.sqlite | Bin 16384 -> 0 bytes ...69c4c90288e408d715546a56c1714a4816f.sqlite | Bin 16384 -> 0 bytes ...7193e7ef291afcc3810749c1ecfff805af0.sqlite | Bin 16384 -> 0 bytes ...590bf5eca1eec1dbf73744c99a2734297b9.sqlite | Bin 16384 -> 0 bytes ...d410fe62b08dfa812034a686760ba44ace3.sqlite | Bin 8192 -> 0 bytes .../metadata.sqlite | Bin 4096 -> 0 bytes .../metadata.sqlite-shm | Bin 32768 -> 0 bytes .../metadata.sqlite-wal | Bin 1676872 -> 0 bytes 38 files changed, 1 insertion(+) delete mode 100644 workers/collaboration/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite delete mode 100644 workers/collaboration/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm delete mode 100644 workers/collaboration/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/03a17dc68f0f82924c88fb48b167de7688fe85c50c637943aedc0fce1e635763.sqlite delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/14a9ad456cd030aaa651a2dafa385cc7ab46b4432a117d2a30026628566ef84b.sqlite delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/182a53b53cc95c92b7d921e8c6de3a60430cf3b0d1fb1d6b52abbbeed3662534.sqlite delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/182a53b53cc95c92b7d921e8c6de3a60430cf3b0d1fb1d6b52abbbeed3662534.sqlite-shm delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/182a53b53cc95c92b7d921e8c6de3a60430cf3b0d1fb1d6b52abbbeed3662534.sqlite-wal delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/2176ee2a3478351a9ec5b654efb98bf0fc9a641724d672dec2a87767fd020f43.sqlite delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/255f587fd05658b4177459e7215348dba58df4e4e4d0d123c5aba165898e40d7.sqlite delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/29fcbd2ad44f175c7eb1bbb784f10e3640a5a436f7a8802ab11e31068e79e205.sqlite delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/3f0f95a5f37dea778e45b46506f858a63ed3890a890aed9ca101af1ac429312a.sqlite delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/4424c75383480fc20ada4e4dcd84a9ff492d0f4fa75b748c61ac002cef767e67.sqlite delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/49f82606dd56e5349b850d37cbc274255ffb66998e63abf461346e712a82b17a.sqlite delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/4a9cc69e531b0224ce740caab4560dbc40983daecb04523b77aa8be3b26ce5dc.sqlite delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/58577c2c6c0e91055de31dad3b883838a53329c465f20cb26bd92c581dbc6c84.sqlite delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/5cf9566811a28de3c85872fdc2307c85cac82b296003f0686a9f931a1a87e2c2.sqlite delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/66a5a07644ee1e9d3514083f4b227c886f3d34eaebd01c6994bf88c6e014c97b.sqlite delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/8228cb14196d0a45f0d8293c7e8326c58467086c2fab35fbb14f2fa6d2fc61bd.sqlite delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/85a8afb99f3b6096356cfc01c57152038bc30ec767d07f3bbb69df1525f4ff5b.sqlite delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/8965da945b0071453b7afa1ca8e96cb7ca0be2bd005c8c2e7dec9244caa883d8.sqlite delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/8e7af33eae36ae7d9a08cf0bafd7aea4c8d6a631bd96af46012b1e77875d7b4b.sqlite delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/916fdbcbc6cadb6b3d28928907dddc0e4d9d2702b9c6b7742d1591144870071f.sqlite delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/966f8008da9afa6bf1f283470e9a22a5a59dcf5cb549c6b8ca293916a0045e01.sqlite delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/99b843e894d821ea0d842307990cf1ba5ba0f3012e307b9758f712787e9f860b.sqlite delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/a9e8393d05838637ddaf02a111008625c2d5563786ecc264864a6ddff28587ea.sqlite delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/c063813275fd1c69ce347418adea09bd0798dc17e527f5848fdce647d9d060a5.sqlite delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/c1000d36cb8b0052429c6b47892acb7f02af105c7610872008f024096afac8d7.sqlite delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/df9c171be52cb4e34c2a23333b13a0a96ae46b4ed46279578a09474dc39cc1b8.sqlite delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/e4d786a9d3f442592c6dee4715e15ea5799f71e1ddd4cb5af2330b36927da00a.sqlite delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/e6ea82d3126efcf151316cd7c7e9369c4c90288e408d715546a56c1714a4816f.sqlite delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/ef72c880cc9ffbbd112dda9c873527193e7ef291afcc3810749c1ecfff805af0.sqlite delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/f7f9bcbdd88d6aef504e3c4007c80590bf5eca1eec1dbf73744c99a2734297b9.sqlite delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/ffc0d014dfa96fc8b6e230d9a7676d410fe62b08dfa812034a686760ba44ace3.sqlite delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/metadata.sqlite delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/metadata.sqlite-shm delete mode 100644 workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/metadata.sqlite-wal diff --git a/.gitignore b/.gitignore index d919ee1b38d7..a30d24d3ec7d 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ zig-out/ # Node node_modules/ .next/ +workers/**/.wrangler/ # Test outputs tests/visual_output/ diff --git a/workers/collaboration/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite b/workers/collaboration/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite deleted file mode 100644 index 0b58f0dca7d6ce95907cb0b322dc4802f04688c0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AVoG{WYDXN;00+HAlr;ljiVtj n8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O6ovo**AfQj diff --git a/workers/collaboration/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm b/workers/collaboration/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm deleted file mode 100644 index 20419c74b7c0ab15384505694faad4779c2167ff..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 zcmeI)F$%&k6b9fZIJoK3_7ZNc9>I&bxCp&SLAtuSiC(~|Q;(qb34yL|LcbqEl1F$C zz5}FsZc@%HrPX}6#A=t9?_i35r45Kmeql1pplc2U0hzCu`#hEF)1fC zJ~=Jk(Z?~!7tUaE4svx2aa9O$bnVa4CR6Vsc4-QG8~KLWpZbh(bV+r>|pB zq=L6=qz+U;adJj#N@-4NN_n1Ec7`-=cChqA?_WFC{sa{|``@!sUdscS{2tWV=5P$##AOHafKmY;|fPfbG zW~DQWi$?EVBAYGcABFxd7f#6$&geL`<#9)F5>BzgX>^8`rf3ky>I=Dhf`8y1ee~R2 z$2+7lKU^6uIFqeYMa4?BI7ls@n(55koYCXM>g8I0_j1h|t5~kxsU>(rJhTwiU9nyL zB*?4Q-O^_xoyq5o-t+6Semm+!VN0GUKZyN~Y%6~}W!`?E@ddOL1%Ew1^>?KH(=PYl z%?71U()m@X!j0gob^ZPSirH6>cR*Bv l00bZa0SG_<0uX=z1Rwwb2teSU3ry=D0G_vW7?h?kG(uRNl z1Rwwb2tWV=5P$##AOHaf&$xQ_z4v!G-Ut) diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/182a53b53cc95c92b7d921e8c6de3a60430cf3b0d1fb1d6b52abbbeed3662534.sqlite b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/182a53b53cc95c92b7d921e8c6de3a60430cf3b0d1fb1d6b52abbbeed3662534.sqlite deleted file mode 100644 index 0b58f0dca7d6ce95907cb0b322dc4802f04688c0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AVoG{WYDXN;00+HAlr;ljiVtj n8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O6ovo**AfQj diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/182a53b53cc95c92b7d921e8c6de3a60430cf3b0d1fb1d6b52abbbeed3662534.sqlite-shm b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/182a53b53cc95c92b7d921e8c6de3a60430cf3b0d1fb1d6b52abbbeed3662534.sqlite-shm deleted file mode 100644 index 848f44824200ec379f4b56b1772370906973ff7b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 zcmeI)%Sl5~5C-5szT(^KBa)p3h)uY0rA@>NT)NIqtickjK`g>$&^BDeITFOJh#1KC z!<>Q3;oRXXV1{pJX*sOwCEaoLm8$E>^t5}JoSeVj-aoxxj-M}X?q04&kHa6{uctmU zsB`))v(I0x{+*?&)|Y3!&GFl+Cxie20t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyKwxeKdbMj}b#A9K z7=gbOX!}-#z+wut%|b$8F$LDr&w4hpnXPPRC%f58m;D^nCa|Ln()NG}0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk|10nT D5c(=R diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/182a53b53cc95c92b7d921e8c6de3a60430cf3b0d1fb1d6b52abbbeed3662534.sqlite-wal b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/182a53b53cc95c92b7d921e8c6de3a60430cf3b0d1fb1d6b52abbbeed3662534.sqlite-wal deleted file mode 100644 index 857884e47988b340c8454dbe186fd15f8fa327c2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 90672 zcmeI)e@xVM9LMqd0V0UrX>-6<;~T??QwuolVEV`RfLmRH7#t|BjlOtaHU``|I1AJX zIA_iD4|R?Dqu-k?mYC*j(a@Y$w30%p>4s@7myKyla<=uuKHq~c-}@HJ)gRTqJU=^* z5AWl9+xzk1pYQAQ;k+F)w%e9|oo=(aZQ3aMYV)3xKbP&OQ@*Ksq5JK3MadptzqMs> zaOimZ^~y&|8=|UIA8Bb;qLN2k?Qqy+NfN*7nE28z(}s3^LTonv6nnq8pQoBSZ1-(j zH(R`7cRgtn4+a7VAb@;mseF)itbG31@1lgY#`#i>%<+-3pnFcv|QmIpBHer+~T}|cw!)c00Iag zfB*srAb@x zpM3RkrD>BY6_u73O_UZ%YXiZD%BzA>Mfuu5$$xoXK)c6x>gNTL`*yyxarAJ{P_UwJ>q}UO;?fAbGmLuYXD|V1O}0 z009ILKmY**5I_I{1Q0;LLIQd&@bT~uQCak5$^{l48hC5?>8HLV7qHNZ(+UCzAbGFHARbj9fwWC*>3GpDq^&igmX&5+p{K}ATq&Y$BpNm$ zrb#_<-#!mVzR<25HdmCYTc?Q&9H##xTXt;AkNvwv$%lG1P8GG2rGg?Gx==XO#GuY*~_nokM#oU$v0-7+w;0XKmY;|fB*y_009U<00Izz00gwa z7b}}vSuqB0Q`zdMAZ!PFT)1UdxRd=%$>*-%B-~<^)8q`T&eAB3)G2xI1%JnV+V{OZ z*KbmVH=mCee3G3LMa62ZI2u|zF|)aa1!KU+*(;6V?v;i$m9f(JBbVTZ; z-Q2K$FkKK3fB*y_009U<00Izz00bZa0SNpz0*?%fE!CSF8yDF8DOyy5I$^2=KV8HMYcQRFybxVhfiE<~?FRcPrNzTS4W-pGyinRWgB|BBgH_jf^L mf&c^{009U<00Izz00bZa0SG|g&IRW54*(CJU0hnaWxoI<4w?%9 diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/255f587fd05658b4177459e7215348dba58df4e4e4d0d123c5aba165898e40d7.sqlite b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/255f587fd05658b4177459e7215348dba58df4e4e4d0d123c5aba165898e40d7.sqlite deleted file mode 100644 index 7279e7fad310c01abbac7add95ff21de552f922a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI&L2uJA6bEo8DOB5l?1H)-(4HzLZ4w}8y0jA)($!*7yA^`zx>Mv5M~c=q(wYrT zLr6XC4xfk5z<1%g1GrjO6^alSc0m6pTXt;A&;H$_KmY;|fB*y_009U<00Izz00gwa z2PYT$QB-TTYLlVO6Ej~}T{T90n!V8;@7`!za~T`$UvdfF5r3PA4t%lO zdMhYs9r*S4M!w)U#_0Z4+OVGtlDH?2RT!n=K=xHQKV<%Vp!o%~kwm|}KaF>!@o6{5 z@8*X6jp>4b00bZa0SG_<0uX=z1Rwwb2teS!5x8SmY^}84*?sJ8x#dNDFT?)KbU{D> z0uX=z1Rwwb2tWV=5P$##AaHX6z1)LQv#&B4$xME?)8fKV4TniO@ax5e>5F@DX)?N# zMrwBaR+b*ALGht-Dy8j;syvlW)#;XO;G1yeu(_gC9YxbF<1qc7Y}v6bKl^u!k`M3wI91e6j=M6YBAaKX$=(t% z#!Ou^y55APJCo1G&BPyClfC}F_gF8mo}4oKhdr+w1Oy-e0SG_<0uX=z1Rwwb2tYs! ze6zB-r6psqp2~xc3J=@i78g$45zb^kvk`Dda1u_<=QKG(%d<3!BlU&cZNcC1fOZ0R z%L(?$anTs?adxjY+}&$gQyIP1tz3fdiN8%myRK+7 zKMD$(yKeonk8OBh;et92)1Im!fiw_lffDVqlv-QXvO+K|oT9XGq=>eWXxKDu zn$*+o+vnMb*jM4ofw`ho-8xOXzybZAY}v6bKl^u!lEZ^qoJxw4vu==5ku5M?XRnAD zW4fvtRd2#joylkIX5t^M&ep#iKT->f$KROw)m%^w0s;_#00bZa0SG_<0uX=z1R$UU zJ{#HG^0GE~nFg(n^xKiY$AwdIgfrRC?0Vc0oP<-ZahjZ=l{p&4sr*RpJHcObkM=!x z&+$&E%1>U57rYNT=aR~`dU-Uodah@4OH0~-kF!@B!`-V5V=7~{@kcJfkH!BcqNXd} z)Q<#t^`=|-pk;H#qBdB(PV0B$UL19TGwFwk-wV3ZpB~bGKhX37T8YEI-k*j$((tsa z!*_GdykM#zAOHafKmY;|fB*y_009U<00I!WHv$hegFQR29lqu?H{Z^wduir(rV0WA z5P$##AOHafKmY;|fB*y_0D-#`=wzM_YF(KIVUPw_W|E)n%YHvjdTu2@JAQG`&yPlT zlTc2MKS+~P*~>q-Hrlqdtc{W#73@-}-HPnOmc6|bhC88E+TM($B|jP0-~TU}d3kpi nL?#G800Izz00bZa0SG_<0uX=z1a4hmUi|($yMKGYY|U?Jkj8;G=Ne0bH%C3Pp$uJD~rQEjza5Xa8t$HFGyiN{PW+)Y*~1U*n|gtD>l3p-+N-)jKmY;|fB*y_009U<00Izz00gwa zdn;eq*f7SAGuiE{FzSVeT)1^txbywoe!yM9Nw_ti)BFr=F4Htl)LZhN34Xu>dLDR( zZqT6y@7$j)crE*9ifVqdHXYhJGxLS@bz{tD*&D6N?v0kUkg?JFC70lB@wbWS$P-VS zuLK3nBd`9($QK;P7~j4~8xE3T((B7p6~<{elmitm4w*k6XmJ5;Ch>3YPm>*Ka@wuQ zySZV1W4a(9009U<00Izz00bZa0SG_<0ucCb1a24>yL-^?1P}b&naS^VT3i~b(I`oWUcI<9dvPzWOh@iKMgIVBeeKIvOSkL?5nGwo diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/49f82606dd56e5349b850d37cbc274255ffb66998e63abf461346e712a82b17a.sqlite b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/49f82606dd56e5349b850d37cbc274255ffb66998e63abf461346e712a82b17a.sqlite deleted file mode 100644 index a60776313e2295b3fb6daf1d3c6a6a10b24c8ad2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI&O>fgM7zc1CDO7ub?1IV;h)2~XZ4#i-v|T4Iq^&igb`&zxb*IQ}Tq#=HNNWZ{ zAf%qSZ=Z+nz-QTYhv8~nRp>NvVF&boWXq0i`LTbuDA|8sj}k@Q__!|w#WiJv?RhcY8%Q2nav`0uX=z1Rwwb2tWV=5P*Od z_-dsy>+8noRU+Fx6?D469v5!a74B?5Rq?qiI0?60=QKM*8;dlFBlVfQw}S6-pWgZ2 zp6ee{jUPUnF8C;WCyL7TMtL%{d19tBtE)8KKhXRF+K9p%?@!|$X?)tv z@w>TUpEF$$5P$##AOHafKmY;|fB*y_009X6Hv-Fs#h$i``<3VJ?oN48-^;Mim@Wti zKmY;|fB*y_009U<00Izz00iz%pqF|ws`phQLz&1+JI*c))o>Wa1FxE0n7+7YmnNf? zI8?La_mcQX4YF%nMW+}ROQrnF_I4rMaSFLyeygAyM{ajY?J$?mb(}BO)%X7kW?$Uh o1(68?5P$##AOHafKmY;|fB*y_0D*rlu%v$gxc~UaPfNG#7XtE_TL1t6 diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/4a9cc69e531b0224ce740caab4560dbc40983daecb04523b77aa8be3b26ce5dc.sqlite b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/4a9cc69e531b0224ce740caab4560dbc40983daecb04523b77aa8be3b26ce5dc.sqlite deleted file mode 100644 index c6d0e52467396e6bd988f5c8231e2c86c044b5b4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8192 zcmeI#u?oU45C-5&6kL?Lh@&?us7qbktQthDqA^2tDHSUetb!EW1Sj9uC$LpV!Cn6& z$t6b;$hT#=aHAyT-SHF$3ENaADXGUyMDpF!^bC=X`S(0a+4@^ZYG1rkx=UC$N*e+K z5P$##AOHafKmY;|fB*y_@XrEgQK;IsxK@&2vk!egjtsWv_y88NG`au) diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/58577c2c6c0e91055de31dad3b883838a53329c465f20cb26bd92c581dbc6c84.sqlite b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/58577c2c6c0e91055de31dad3b883838a53329c465f20cb26bd92c581dbc6c84.sqlite deleted file mode 100644 index 31d192f396a52c0232b0548a75fddfea0079fdec..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI&K~LK-6bEo8DXW07aG5#|)1Im!fi$L;Fs;=tOQ|)o6bhLsaEjcy--v>SNa zZg50CKYB4<@KJV76;*2W%4lfy)Xe6VmW%-(XZIV!-Tj6&mC$Dgg;5grWLJgLL*}msnqELFarDRg({M)`o_2Nk zZf@8=m@WtiKmY;|fB*y_009U<00Izz00jOUfrp00o|Q|x{@Qyt*qhV$GVHHR7X$<# z009U<00Izz00bZa0SG_<0{17-$vhp@x+;~COyy5I$HQqgJ3LR4NaI&wpmiiP51sigj*eiu?G(9-BSmW)Y0W^0 zX;M$S!{_117vj1DxLQ{giVzoeK>sIOc5KVf{@tSF&Ff}XC`$9=o-D{^i_A3Hb0Wr= zscT8stFUxu`q{Xe_(N;5$7lPu^#be8CuX19i@HHT00Izz00bZa0SG_<0uX=z1hl}Z zRj#b87{h0U>~vLpn8rI?_zhq9)BVzR#C^d@`1K~I=^0v`r*Ryq4-~u-`~{C_Hwt$A z=zv0g@Nlx=o$Q_{syAEp@zC0dS*|QC8ACqF9=1ojhiz*nW7z&Bm*D&2Zxhj8AYQiK z3W{2LLF2tquDGr-ynC58?q&Tf?aE^nCwbhLJr&OmnLi(Bb^)zs$#3sZqaA5<+O^TU zxnX}}x*#9`0SG_<0uX=z1Rwwb2tWV=5cqEdZW$K4@A_`^$PH_|^ZH(f{h8^4fB*y_ z009U<00Izz00bZa0SG|g`UJYA6vC)w#)wdv#$vx}7I# zdi+L_AE|!zf%Mily-uP$Pi;B1w6j@rHlA*Dob@M;AE{IGJ lfB*y_009U<00Izz00bZa0SNqafd%~oz|DJKzFN9vKLF}RmfgM7zc1CDOB5l?1IV;h({HZHVM$6EltFQbhRLAN+Fo;*4P(OR*<0P|?yau1l(E(QC70m);%^hto-dwu zUI_|2dw%ncQ7ly|#^m~0+Gv=L(qtfyRTO8@NDfuBJY@cSpydU$mBzolKTUU}>1ns8 z@8*X6jp>4b00bZa0SG_<0uX=z1Rwwb2teS!5x8Pl>{b}m+jkGW2hOU#mtlWqx*#9` z0SG_<0uX=z1Rwwb2tWV=5V$yjLE+{k7^+;xGMC@&th_Q-<8hje{APJ&{^DLUw7Px{kSH3JylEk@2s=$|EJ78y|@b^ m69gat0SG_<0uX=z1Rwwb2tWV=|6E{A{{V3L#+R>_ZrKkM6q#fI diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/8228cb14196d0a45f0d8293c7e8326c58467086c2fab35fbb14f2fa6d2fc61bd.sqlite b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/8228cb14196d0a45f0d8293c7e8326c58467086c2fab35fbb14f2fa6d2fc61bd.sqlite deleted file mode 100644 index ba2c2da6411ce00ecb6e0290cfc3e5b1b692364c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8192 zcmeI#JqyAx5C-5&6kHTt#L*iS)TJPfR*52hpf!VaDV4TRuogpx6`U`B;QE=Dy zNOH-M1oCXzZWdwca}Xzyo3cSQl9GnZL?qumOV1G5SbWd(l&`;qq~2v$$##kAjk1P- z00bZa0SG_<0uX=z1Rwwb2>i3aS(F-vA+Gh*Jsf?-?GHR%!5-J`F@t1lQ<2oksn_CbCA`Qq~j`EE7taO zrMTt{50a1<8?ENb<(<1&+pfn;<&C(!k+nMN;rDO;qVB87{CGPAAOHafKmY;|fB*y_ S009U<;4cLX(IV5?&eR*Ll`~ra diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/8965da945b0071453b7afa1ca8e96cb7ca0be2bd005c8c2e7dec9244caa883d8.sqlite b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/8965da945b0071453b7afa1ca8e96cb7ca0be2bd005c8c2e7dec9244caa883d8.sqlite deleted file mode 100644 index abe4b7463462645863121b1924e7ceea67be9ed4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8192 zcmeI#JqyAx5C-5&6kHTt#L*iS)TQ9ZW|g4g2eoFfQ>j>?U|W!ao8aXC^%vNxqu{RZ zk>rvi3FO(bSucXb=Rl!+r^VTgtkqUGUw`vEmGA$|kGDer0uX=z1Rwwb a2tWV=5P$##eode#a#VM^Q>#7b8T|(q6*P1J diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/8e7af33eae36ae7d9a08cf0bafd7aea4c8d6a631bd96af46012b1e77875d7b4b.sqlite b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/8e7af33eae36ae7d9a08cf0bafd7aea4c8d6a631bd96af46012b1e77875d7b4b.sqlite deleted file mode 100644 index baa620a9afc72f36e13461ff4c025b8a10e2ee90..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI&O>fgM7zc1CDO7ub?1H)-5Ra-&+N6OtvaVFSkggVs+R-hTZsig=#F3)4jkIP= z2!zxVclbPf2R;ke9l+JPs!)WuY=`Op$d(=3@?-yQQS$1rkz|VE^t3B8a@YbhP4=9K zF=pzT)AcGW-I;tgt|tD`n(WD^<9m96b^i;qKidnsK|lZk5P$##AOHafKmY;|fB*!v zz(*@zSXnU!JDChSDtH?Q`&_s+SGbe?+^)}E!AZE)2B*mxTAig)oT&HYy%hWf_vyg* z_FcbCb>3bdFL)Y}iR+H$j_ov~GG(7Fv z@ZH?7e=uDT5P$##AOHafKmY;|fB*y_009Wx8i9GkVvpK~+ixDbwY}=BzL#NtWx60B z009U<00Izz00bZa0SG_<0uZ=4fllt>pwU&CjASN%+G%m7uloHY?RmB0%=pE1vIr?K*v1O*~>@5*v zOjk9h>TMXRGyAOFPW++O*~`!S57h$W(N|`EF_%??fB*y_009U<00Izz00bZa0SG98 zb0c3^Thm5wvLNh9|1kEOTsSpHIJ5oSw#Oa8NjTL8r`Z`=U!+MK$un|y1>fNw?Rjp~ z@eZiY51vmKd<=RglB$hXbuzSZqUQ@ME82)pv)4Q0-Rm7=E@Qp(OD@6p#os2Pt}EWP z+Jd}R*R7pu`GRF>qsKRC{eCh?;$Cnp{V4SZL0|gwL;BALnqNTcN%Y(M(|AW3pLS#X zZmyZ%nJNefKmY;|fB*y_009U<00Izz00jOUfd`twp6+dR+ArF>-R7dYmu6ltRS*z> z00bZa0SG_<0uX=z1Rwwb2;7}OFSj~s^ko)AK^9z_X>nmFhr=WtxV7TK^u@inG#TAa zBRM;MFH4W)p!m!ROOIZ=PPcFY1s%1X_=-rv< diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/966f8008da9afa6bf1f283470e9a22a5a59dcf5cb549c6b8ca293916a0045e01.sqlite b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/966f8008da9afa6bf1f283470e9a22a5a59dcf5cb549c6b8ca293916a0045e01.sqlite deleted file mode 100644 index 9c7ed5b98c7d549256508dbe2e4047b2decc763d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI&L2uJA6bEo8DOB5l>@t-d5Kk48HVM#{hOQGA($<2g-3q~U-6?V#M~c=q(wc2T zAf%qS!{^~U@L9O-0It?mg(Ad-9nk;DmL1#jvwycJdHt%HWQyYSWFRvtu~lZ8>=_Yb z%+xif>s45~GyiN{P5hxX+2aqL+j@a@=M%F(+N-)jKmY;|fB*y_009U<00Izz00gwa zdn;eq*f7RVGuiE{uos8>TzGX)c=P>SE#RKuB)n>q)BFr=F4HuQ)m!op1b@y0dKvip zUT{PWe)Mp*;7s;U6;+$9>U3!9)XW#w*NrisWpA`6yEod_LdHh>mt2B(#NQ^OLtngT zy%7|&4*mMMkuQ`=#(3>AZ8%7VN!*twDvZ)_CJ zq~^zOWa+UQ79Vt-y?A%m>AFr_*>TF-m9pE5N^vFHc3oMn^jv3W@11q|{r`g57uR<| nWP$($AOHafKmY;|fB*y_009U<;GYYu=pO)X-u?2`(k=S|Z`GKd diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/99b843e894d821ea0d842307990cf1ba5ba0f3012e307b9758f712787e9f860b.sqlite b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/99b843e894d821ea0d842307990cf1ba5ba0f3012e307b9758f712787e9f860b.sqlite deleted file mode 100644 index 7a0cb2d6cc7f9f97596c4e5316509dd35e4c11dd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8192 zcmeI#JqyAx5C-5&6kHTt#L*iS)TM%}Rf32gXw0B4rD6+(R-qK!1SkKmzra=<3-0;1lvk2tt!$&% z*rU1PoHN`Cjyzo&HBp*Zdb+Unj%RAu;p|2>YHQl>zxjizkALRJ+aUk}2tWV=5P$## ZAOHafKmY>2CQudyYU;&k-<|aPgC`cyG?M@T diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/a9e8393d05838637ddaf02a111008625c2d5563786ecc264864a6ddff28587ea.sqlite b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/a9e8393d05838637ddaf02a111008625c2d5563786ecc264864a6ddff28587ea.sqlite deleted file mode 100644 index dc6fb8cb18aef86ee764ff29684aef934b1d9fa2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI&O>fgM7zc1CDO7ub?1IV;h({HZHVIJGpwfs7>1sh}N4Ly$?JkkKxKgyXk=hi5 zKuA4thtI>2FT`~RaJ8=LC_-G=VfsI^WyiMs*uPtp93Ir;R8cn>_GL;A+hC^2o)a;~ zOkFd&UWBDPi_gZz#9vyIJ^s|Xr59MYzcBl=y`dWf1Rwwb2tWV=5P$##AOHafKtKz8 zw6eLaEo1U5m7SgnPrBhA7hcs9-eNzq8*oo>5?-awX>o?OS7{cf>OJ|d1%JT<+7J9a zFKAPZw;#?Iyp_GNqDs9{nGNlX&1`OS)0psi_F8kgd#!0LWvn%S%O!YA{A(gQ^2N)> z8$m(i$gjRLvN^{wCU?%$hW&UDcYAWE!YBy`vaiDBA@lNqmKV@=9R2bBG~JP=r`?&p zn;Z5IrV9cB5P$##AOHafKmY;|fB*y_0D)^Gux?oFe)ZMi{-gGBxwNY9W!PVtE(i!f z00Izz00bZa0SG_<0uX=z1g=h?m$^5o_f;w*naZDbl3y9A(I`#^el@=`e{s*R%|>^V zNG*=vNRv}F$Ujg|qNprYr{Hvz>$*?NCmmHRxluv7vLs98qEjqMn>( m5P$##AOHafKmY;|fB*y_009X6cY!ti1HjF@-@aSAWxoLQ>zV)n diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/c063813275fd1c69ce347418adea09bd0798dc17e527f5848fdce647d9d060a5.sqlite b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/c063813275fd1c69ce347418adea09bd0798dc17e527f5848fdce647d9d060a5.sqlite deleted file mode 100644 index 3d05316d92b0d7666fa831b89e039da817f5b809..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8192 zcmeI#u?oU45C-5&6kHTt#L*iS)TJ(NRt-|BqHPA9O2rBVYoQd}1Sj9OPhhK#g1i1l zl1q*xkZ+5-S%hi8$2f`nlnrW-lr&~0BKhuldWOiy@_SySV*M>74X)c-zDv~als5zf zAOHafKmY;|fB*y_009U<;GYF9qS7)9acidj{uFp#6rRK5$xj0B5PN4o`c#rrN10l& zHYW?kAI@+mJg~LWYOWk!JJ!;4c5JI%kBb{wtF7+7{^l9gvw!Bt+aUk}2tWV=5P$## ZAOHafKmY>2CQub6YP1sjLjzTc)$|W+fBSmW)Y0W?g zgwzvv_&j_EJ`2|!z}32{P=vU!1NuMNvSV9*_U{%Y&!4r^TvL)A4`ogsTW7Y-9uhIe zY*P!So`+*Pi_g~i#2;Fl-TQEO)huwXePZrMcil7y2tWV=5P$##AOHafKmY;|fPfKr z?-Wa$o7Ut(E?*6F6erOE7k<+h{$jtdA97!C5`Lr2X>o?OR%sST`Yi=d1b@UsdKv}? ze%PlL@86j(cr6Dfni}m+V>YyXVi!vr8`gx+v$wj_-CJE}DPybqOD@3=#os2PULYQK zUI+?1y`cHVDwaIYnp{6i8x7M@nhfNzj#L(nC<(07>kJD@vG|MaV7x(hoY;-?U zdU5__5;g4nk@hT diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/df9c171be52cb4e34c2a23333b13a0a96ae46b4ed46279578a09474dc39cc1b8.sqlite b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/df9c171be52cb4e34c2a23333b13a0a96ae46b4ed46279578a09474dc39cc1b8.sqlite deleted file mode 100644 index bfb58b21429032f7551c603e5b82b5c491bb7f0a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI&O>fgM7zc1CDO7ub?1IV;h)2~XZ4#g@3F(0g>1u6JGYY|UFPF$`T`5}INNWZ{ zAf%qS!{^~U@L6`L@~7*a7_?*|K9>e(c{ZO5VPS(p*s|JL$=sT(-nalf5Kj zjG4L?biEEscc!0>>xn1Rwwb2tWV=5P$##AOHa^ z@YyPsR#%PTi(DRcRnqDtyIcgdKm^nM!dA=!!AS(wh|}~8tX%m>z{}fB*y_009U<00Izz00bcL&jl9r4*+)`{`hI>mR$muMVQzC diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/e4d786a9d3f442592c6dee4715e15ea5799f71e1ddd4cb5af2330b36927da00a.sqlite b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/e4d786a9d3f442592c6dee4715e15ea5799f71e1ddd4cb5af2330b36927da00a.sqlite deleted file mode 100644 index 0ffd69a80e6e0b56df774a39a52b4db88e74fb74..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI&L2uJA6bEo8DOB5l?1IV;6HgVBHVHT+rB%g+bhRL~qb!(i-6e7sM~c=q(wcz~ zXi`tRkI%z*;InYu0bH%C3Pp$uJD~rQEjza5Xa8c39r%SG(SU|%QTH6^@03Xf z-U*Ka@wuQ zySZV1W4a(9009U<00Izz00bZa0SG_<0ucCb1a27?yYF;&-A7Me9`u&=y$t&^(**$m z2tWV=5P$##AOHafKmY;|fWY+$^b70b_CVz_mbv_HXXT}l8jaFy=r_wtvlsXB%5-!) zi`D%2jXXP2!}5c;Qb`UIRjWJoYQ3sX4WJRINlxMb#+&XkC8)zhL&o^<5B| mAOHafKmY;|fB*y_009U<00I#B=K?GG2Y{RRzI?TG%YFcBu9^q{ diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/e6ea82d3126efcf151316cd7c7e9369c4c90288e408d715546a56c1714a4816f.sqlite b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/e6ea82d3126efcf151316cd7c7e9369c4c90288e408d715546a56c1714a4816f.sqlite deleted file mode 100644 index 9b5de96ca7c979b1b7e294610224fb42939bbd55..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI&K~LK-6bEo8DXV}%xJ-ou+EXH4ahU#3w(QuJpZ&WW-&g8RkKkz`1CpKOj7T*%J3qEfY18Vzlno7vpTiZS5h?3KoF_e#T>%2;Xql1uQW_}fHu?1_Wg zks!Zz?3F(l*<7(`44&Mj4Z3kJZg=FF3Zf+F$*u~fhs>W3G`)b<s$40(VWxwGVCu*7X$<# z009U<00Izz00bZa0SG_<0uLw9$*c~lU6sm6rt*iKzK1`Dfuv8LC#n33anu*lC5Ylyr8(C@Q=tw6{a2wc|LSth?|3H_X0yxC&Twq@R0Ptw}>o-fc>=sF$n(P1o diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/ef72c880cc9ffbbd112dda9c873527193e7ef291afcc3810749c1ecfff805af0.sqlite b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/ef72c880cc9ffbbd112dda9c873527193e7ef291afcc3810749c1ecfff805af0.sqlite deleted file mode 100644 index 9c3fbe559e47e52dcb091314f4b66a855ec6247e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI&O>fgM7zc1CDOB5l?1IV;h({HZHVKfnw%ejzNLP!6b`&zxms8{tU!?AVqc`*(|y<5!I&Q`AdO2Qs4)TV4n(RoE({4`Q z%?6QRIa3opt&B|AN^U*LOi= mf&c^{009U<00Izz00bZa0SG|gp9`$$9{_IN`|{P&E&BmFdzw!G diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/f7f9bcbdd88d6aef504e3c4007c80590bf5eca1eec1dbf73744c99a2734297b9.sqlite b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/f7f9bcbdd88d6aef504e3c4007c80590bf5eca1eec1dbf73744c99a2734297b9.sqlite deleted file mode 100644 index 0b4e610d974242eea6570b381fa28dc0795805fe..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI&O>fgM7zc1CDXaDZ*#(sy5LU&cO@e7{+N~28($#_}DTQF#?i9I+BSmW)Y0ZWZ z2&pIT@Ok(Ud=;)cfU9*?w@!!)JD~p~TXt;AkNvwv$=f$xl1Un;Cj*gDi>wn}C(kJ* zgy^c~RJ{yCb>^S7%ZWdtHa4^|n`L*~lil66v5?ViU(3Z=m;Y@d+Hv^H z);mu9R>x_)*YX9+(#H2M(guTMn8bZ?B7-OmhGHOt#UcIbffg6gtt7hf{&cb=Adi}!U3uC$sJJWYUYhxhsDgk1 z1Rwwb2tWV=5P$##AOHafK;ZTS`niW=Zy+-fiA?-5)8g_-jz&p3bQ;Cw*^7H|bvk;O zMsj}qPL>|aVezr3?8^NxEX$HrE7wXrS&gk~Sn9=lrKnu%mG}0;>PO?^`~NvH&u{O7 n$OHihKmY;|fB*y_009U<00Izzz&{sQRX+eMJ^22^P%ZflDF~S- diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/ffc0d014dfa96fc8b6e230d9a7676d410fe62b08dfa812034a686760ba44ace3.sqlite b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/ffc0d014dfa96fc8b6e230d9a7676d410fe62b08dfa812034a686760ba44ace3.sqlite deleted file mode 100644 index d0f5c9a0062e1202439521421ac6fadf8bc2cd45..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8192 zcmeI#u?oU45C-5&6kHTt#L*iS)TKDMStY1g#nudUDitdftb!EW1Sj9OPhhJZ1$X-& zNiI2(K)x+o+X~}=kC$uY$81o8q@*!35y`x#=^Y{+%lSM@+4@&V8r7U}9o$P*7lCU|@t|AVoG{WYDXN;00+HAlr;ljiVtj n8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O6ovo**AfQj diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/metadata.sqlite-shm b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/metadata.sqlite-shm deleted file mode 100644 index cbf7ee4c8e4e3f2f1444d87b9936d72c7a9f4bd7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 zcmeI)b!=Bx7{>AMQ{0PJa4%5Yic{Q+Q@psfI4$m4T*}6f8G|u=1LnpY#x`ueF}5*- z4H<(e{7N9fSzW;0!ne&VVy; zFEViZU*oFmq$M5c z$v{Rjk(n%HB^%kvK`=SVMQ-wtmwehL}7|hlwuU81SKg&Y03~mS;|qK zNGec~N>ru_RjEdGYEY9}L{XbM)TJKvX+T375lv&7(3EC0rv)u(MQhs7mUgr!h7NS3 z6P@WoSGv)iSbETtUi799ed$LW{TaYO1~Hf+3}qO@8No>6NnjMC8N*n{F`fxbB#|U0 zF_|e$Wg63&!Axc`n>oy79`jkiLKd-@WR|d$Wh`d}D_O;A*07co)^Q*Avz`aoz(zK) znFo1@hk1lY*}`LNWgCyPohR7APIj@IJ?v#4Px2J|Ilw`l<{6&lIiBYQUgRZS<`rJ$ z5U=q%Z}28>@iy;pm?ON)QQqTyKHwO~Il)Oz@gX1aF`w`$pYb_g@Fl1DiZgu8H+;)k zzTPxfAbI51I>PZ88ur#SW301eb24b~71)i4d$2#r*{5;RJqHAZ7K zPUAH}6P2hWP10md(Ns;-bj{FA&C+bm(Ok{bd@ayIEz)8oYl)U>nU-sXR%(@2YmL?_ zMX6npTo$l^1uS3z3s}Gc7O;Q?EMNf(Sik}nuz&?DU;ztQzycPqfCVgI0Sj2b0v51< h1uS3z3s}Gc7O;Q?EMNf(Sik}nuz&?DV1a*6;0EF7+7kc( diff --git a/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/metadata.sqlite-wal b/workers/collaboration/.wrangler/state/v3/do/cmux-collaboration-worker-CollaborationSessionObject/metadata.sqlite-wal deleted file mode 100644 index f1c58cf1948864124d41aba95b31a954f77d9eea..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1676872 zcmeI*d9+>g{rLZzkRZj4If{z0#yay*(+Og#SwscVGot3PX2)DL520p?&@@7&p@wix zH4ibxJX13z)aa0Vet2G=`x#yK@A}mdznv%eEB(6n z?+X6sivR%v1PBlyK!5-N0t5*BpAk4}u6}bYvgDHejw*e1ia+I0Fw z|8=(-K5WGBp(BQ^v&rzG)4x4*g+W7q_$rq7-)rBUcC8E@G5oh9hHkOdMw<@XYP+Go z8NS^r|LjmgP{CocY-wWROwzT|w%inU+pvE-*Cnfx!nTvxGAV7cs0RjXF5FkK+0D=F50--Lj z;Ts!X`P9#Td1|8z^y{A375vW^0RjXF5FkK+009C72oN9;9f3_|KWd(Vv1Boxt&}sl zTCA2!|n1a;11CTd8I}8G2oNAZfB*pk1PBo5BhaxfF!_%Q^aRfh zb%B?zSa5^AFFEiaU7(M+O9BK45FkK+009C72oN9;9f3}DfgNx9+wDEUu|i$o`yCc} z?u8TAkLv=_8MvJgAV7cs0RjXF5FkK+0D(RNo$3OWNssQ<6FfK61s<+WUi7N$eCy}} zeZ*Z7AV7cs0RjXF5FkK+0DH=dE^DJ08cE0htKy(IfCj0B}w{3^mqrC5t4it%_n7RzN4>2jiyP6yx5Wvb0Gv241Q1Hq5W=tx*j|^^u9o5uP1+g#qU>M!GA{(GIv9O009C72oNAZfB*pk1fn9) zu`aN|+>aK5LxsA)xVJCQoOAQLJL>{b8Ml29AV7cs0RjXF5FkK+0D+J|r@Fw=>&6Z( z1WyiifoqaO1}*c)wI9(1LgsD=5FkK+009C72oNAZfIw6PI@JYETzkYX3&Ej6T_E<_ zD+k_w*Qb+pfvAkzJ_ryXK!5-N0t5&UAV7dXNT5?);Plo1@I)baa;OVzbna=-UH0`+ z3+e(Pb2kJC5FkK+009C72oNAZASwc#>H_CxFF3Lg94gcW_TOcpg&$w|w@F%pNyUEtLP?-}`g_RD*9fvAkzJ_ryXK!5-N0t5&UAV7dXNT5?) zV6hLkIIbQ%In)LEjXGl7dgu1vP8SH7yCFb;009C72oNAZfB*pkQ4#1=7g+k;{p0oE zP@yjH$2V3SebLoJKGFrEGH&}IK!5-N0t5&UAV7cs0Rka`PIZA5-#Yijdhp~>7bwnu z|5m3Rx|H_?Lga1;5FkK+009C72oNAZfIwsfI@JYYuibKDJvddU3%oGx!`h<9d~lWb z1tK$V8zDe|009C72oNAZfB*pkeFZwz1+p)_oUR8C4t0Sm-X3?zQ@d}yk}lAf+%*9L z1PBlyK!5-N0t5&Uh>Sp|y1-h`_kXn>oGR1>y2|VPYo|@Fe^VET%)D)c009C72oNAZ zfB*pk1PJsM=u{UNK6Uw1>cN9UU0_Pr$Kxhky5d2)Kwom#1PBlyK!5-N0t5&UAV458 z0-fptn>?{`z8;(^)CJzY^*00m_U5v)=>n0Nw~Y`WK!5-N0t5&UAV7csfxZHr>H=Fo zy2qRK;K89Tu-uZX{QA`)M=h@l^d)yqfB*pk1PBlyK!5-N0t6x>(5Wsk>cKP4s0XJC zb%DxJLzZ9S?d$sK0+E@wjSwI}fB*pk1PBlyK!5;&z5<=<0>yhLtyvEq9O?p}kKA{q zal3t((gpgGyCy(@009C72oNAZfB*pkkrC)r7ue;lXWpp?rwVm}*u3v8KlqZ^%ep{h z=4~Sc2oNAZfB*pk1PBlyK%lQcr@Fviw|_IH9y~bI1?GCE`;fikyIrme^d)yqfB*pk z1PBlyK!5-N0t6x>(5WtP;H^W4)q_)oxWI(*yqZVbb-F)t_ctzK!5-N0t5&UAV7dXWCS|Z1dQUBlS5tL%jfTY?VW|6 zdfWQ~A#*nb2oNAZfB*pk1PBlyKp-jto$3OYUOC{Tp5RcSE->Hsm%sGHCl3tQ1)?%; z`yfDo009C72oNAZfB*pkA%RYHfvYZCG20V7In)J?-8Wu(|J4FLiK2oNAZ zfB*pk1PBm_ia@8jz=Vr-SiW(lP#2hR`NErJhF|r)E)bP*+Xn#x1PBlyK!5-N0t5&U z2nlqm3rsri$j2KG4t0UCbMM)I*X-Oc=mH^gHv|X}AV7cs0RjXF5FkJxDgvGA0=JI2 zvwuA}RHzGFyWYv8fByB;H|YXV8Ml29AV7cs0RjXF5FkK+0D+J|r@FvhXS_L~9y~eJ z1y;zObmw|!KeVkb5Hfc|fB*pk1PBlyK!5-N0tBKW(5Wu)z^QYMst1P(b%9$R{JeH@ za`Sg|fvAkzJ_ryXK!5-N0t5&UAV7dXNT5?)V9K9X9as;Z9O?pRUA_5Ar_bKy=MIL* z-4Gx^fB*pk1PBlyK!5;&$Ov?*3p{iD7T4EwCkvX}I5FkK+009C7 z2oNAZfB=D6Qs9Swl~0#z`E(|ei^q$JT&0>U=hC@sqE;yWY?=T80t5&U zAV7cs0RjXfDG;h}l9+~r`X}=t!3(S%qG@B+sfB*pk1PBlyK!5;&ND4%< z3rsukD_Y2j08rV!A*iKS(x3fB*pk1PBlyK!5-N0<)w*$GX5b>z&&5 z!^4XCYA%^iR$}Q~GM7naE0tm`mMF&K@mMUENu1vBgdgGXL@>z-5owY)AcOMcL7ng9U;1PBlyK!5-N0t6x{5Xmm^^?tL> zTMxKG?+ZNi-*eWVGWD%9bb&~IkZg(o0RjXF5FkK+009C7W=Vlgb%C$H*yYz9dtYFN zU0|NO9=@*jFd6 z*k=u0V3z!#*)#zH1PBlyK!5-N0t5&|QXrCDV6NRZuGIss(E9=}_FE?Thx?xIuM0%- zgJe?#2oNAZfB*pk1PBlyFiQ$_ste5Z@wI1m?0tb5c7fmR+&%4vlgFlXfm!l{X43=+ z5FkK+009C72oN9;Nr6aqfx$cPIe$Ii3cW9I)|huboA;sdH|qkC{2;j9F&$z7~aE0C%7;*9FLA#!|M^zVy>=u{V2 zUwxFYxVOA0PSQ4U1i(3q`C-bb-CL`u9WMp8o8;xY{ zV!2ErT~1We>EQdhOtoB2WKy|Is+g%%YPCczoy%5>!NX?Q1+H3p(*4VwoZ8sW7ieeR z))F8009C72oNAZfB*pk z1o{eeste?%o_=b_-WQl*7r1+u*~Tw*`S>+-fxh!|O@IIa0t5&UAV7cs0RjZt5r||L z`1Kb1yjloO6?$Lb$UmkAymsI(zS0HSnYXnB2oNAZfB*pk1PBlyK%lQcr@Fw}PyFN8 z9eZD3hF##`q3itqswsz8bb-F}b4`E%0RjXF5FkK+009C7+7XCk7Z|q5m{SVDsY34y z-1Bz-cW?S`!U4KKJM*@d009C72oNAZfB*pk1PJsM=u{V2|Iw*$b?kkC8Fqnn=R0Jb zJI>E#`V|!Q02{0`1J(S^@+J5FkK+009C72oNC9SD;f} zVEen*dADQl3(T+!WKOv>wZrQZj?)GD&d)Ug0t5&UAV7cs0RjXF5NJmrl3ifOHJ4qp z5S%LXzQCT#47lW+3r9bq3$!zDYY7k_K!5-N0t5&UAV7dXUx7|_fl^QHoQ}OOFvBiz zRsGASU;6vBk92{)^K(sr009C72oNAZfB*pk1lkdZWEZI9H+ZKIoGSFbz;7meI`|J$ zPaf3h0{yxtc6Cn-{^W}Q0RjXF5FkK+009C72oPvPVAkmZ8~-Bl?)>Mixv4JD#<(pd zK!5-N0t5&UAV7cs0Rka`NOpnU(z}f*1ltU~FR<-fUrkx1f2PZSM-UpI8v+Cf5FkK+ z009C72oNC9hQJS9AQ8`Is?|g>nabvp>3A_;EvHMFbgEh_<#VN4tX9q!GpTqsk*Z{} ziAuGcDCV-+Otw~uC1SNya>(R+cK`Um6+8Cx1!mXb`5F9G>zCdM-|GfUo71#Pu7ieSLmJ%R9fB*pk1PBlyK!5;&kU*!pz+pG; zcUZ^X7noreSaYe{cKYMvH$SHfga+t_009C72oNAZfB*pk1PHVt5Xml3U-8Z7>%pNy z?+ZNp;YLTEvB{C+bb&U;Z7Bf)1PBlyK!5-N0t5&U2nlqm3mh}?+!Z?ZzQ7E-z__Uo zzxCu{FCIIndtz6=?pjxOEx7PSfB*pk1PBlyK!5-N0t5)OA<)q-5bR^qfgSigg){5| zXTJR63U}XIAK!RiplfiKF3`r*EhRvJ009C72oNAZfB*pk|06K#bb(ja7<<%H_rH35 zqYLy4UKQw`=zRg7VgdvR5FkK+009C72oNC9oKlG1}bH^2ebA^7s zzyas~Y3u_3ec@GIpuLG(O@IIa0t5&UAV7cs0RjYi1tQr6ZoL2et?I!ZLtWs{Yc2Tw zpI$w7urAO$Jy!$>5FkK+009C72oNAZpgn;|c7faOnfzTnI9I3(Y&hxOH6H%Dwxll5 z-o&jYK!5-N0t5&UAV7cs0Rp`Ok?aEZ-0|wTda%b(7nt@m~@KDjQt-y=`2dZ;eYJ3Utf2oNAZfB*pk1PBlyK%hN= zNOpneZrEf(JvdjW3p|`&VTGsa?@rYP+MBr51PBlyK!5-N0t5&UAV8p3Ad+3+)$8{j zRS)(U>H@1QbInG3y#M_oxo?1>T-;_P}~@u22_v z=E3a-u7CAXf71opo4C~k2oNAZfB*pk1PBlyK%iG3l3n1VaW`IH5B3=90&`z<|K=n6 zKd`PY&^tX>1PBlyK!5-N0t5&UAV8o!fk<|N&&R%dX-{yjP#5^-h2x+6cH)7L=>qLd z+-d>@2oNAZfB*pk1PBly&?^whF7WN;OPto&VyFwG|NH5V%cWlWOc&^#o+|3;tc4=dap)RoNzkasYnEr1a zq6_p+&lLdz1PBlyK!5-N0t5&UXip%LUEo)L9I;8`SfMVEet+2=_W$SEPwE2gP26e% z1PBlyK!5-N0t5&UAkZri$u5vS_77h)wixOH{XUv+xj$Y0*za|L-s!m_K!5-N0t5&U zAV7cs0Rrs_M6wI4QNQ4d#<4k zUEt6IFVC;`Zok30K=1Ti5g*TTOrf0RjXF5FkK+009C7dIcic1$NqF$8`$99z$K=KVQZFaKNu#UsxCD zot`TK1PBlyK!5-N0t5&UAkdybB)dRu*ZPNr;9Q|D@aj=_zwyTvSGz+OXm8?H6Cgl< z009C72oNAZfB=DBfk<|NJ*ronUkLUX>H-@NIB~$3caGRW7wDayD*^-v5FkK+009C7 z2oNC9o@2oNAZfB*pk1PBly&?^wh zE^z3M?|o7T_895{U;gX9@&ls|d|emlot`TK1PBlyK!5-N0t5&UAkdybB)h=Tqq;9H z1m_BMfdfX|IOMwvZaqd9Xm8?H6Cgl<009C72oNAZfB=DBfk<|N6GtXDE(CiFb%DQ3 z7;xU2!=Ik23-nIU6#)VS2oNAZfB*pk1PBmlPau+A;PkCVeqIR973us009C72oNAZfB*pk1PJsBM6wG!y4s)n7lJ*8y1*xA9e2rNFCBT2F3>wYR|E(U zAV7cs0RjXF5FkLHJ%LDefu~o#WkMl1SEvi@KCSD?ogcmEb6udliCay8009C72oNAZ zfB*pk1bPJ`*#%zw<;$ZA!5%|h;IV_&*kjmTi~Bu=y~A@wfB*pk1PBlyK!5-N0tDI= zh-4RdW7+vPtOo}Rb%FP9yMN#B%2(XreStPdZaD!01PBlyK!5-N0t5&Um@W{>F7V#a z;hCObi=i&C#QbBo{%YeDUeN`n56=Yw0t5&UAV7cs0RjXF5NJ~%l3n1F#dmqFCpcKB z3;gZ;6$>jbH?5`%v^jFi2@oJafB*pk1PBlyK!Ct>fk<|NFNd6bYEQ7mP#4&J?z>i8 z^>6nK(FLXt&jkSj1PBlyK!5-N0t5&UXj34HUEqpS-=YjwM z0t5&UAV7cs0RjXFv?&nDE->%h|2?xOI9R9)Jb3A?yD$6FiRbD9ZI0Y>0t5&UAV7cs z0RjXF5FjvJAd+2Rp}AH%sH^R9TW_mR4}5mGF3{%4Ehj*L009C72oNAZfB*pk(*+{g1(xZz z&$EpkhPpud)kU6v^OK7f*9E2z&jkSj1PBlyK!5-N0t5&UXj34PU0}s;#~jx;l=(UU;!_u22^^?zTTY^4eA}FRcr-IdaPh z5FkK+009C72oNAZfWUNtNOpm>KIuBKvBOXoc=)-}%R{=)o}dd%AD#;W1PBlyK!5-N z0t5&UAkd~jB)h=y50_0h&K2qc*(aWT|FzT3I6@a_bL5s2AV7cs0RjXF5FkK+0D4=1 zl*YM2UErBd4nAbq&xgOO3$!_M%Lx!5K!5-N0t5&UAV7e?bb&~Afl;qrd}txqVyFwO z_SSKW)t^~?d0k-o@LUieK!5-N0t5&UAV7csfi?vq*#(L(-TBKxaIjDpICkTyUp%?U zVQ=XIZI0Y>0t5&UAV7cs0RjXF5FjvJAd+2Rm*?Jmq7ZB`)CE3&@%nyOF8sv>y1?|| zxgbD*009C72oNAZfB*pkZ3;xP3+y#@t|JSE^z6sv9B6C40VAmCcYRyX7A$`)di*x z&jkSj1PBlyK!5-N0t5&UXj34PUEr#Jju_iGSEvh|x!knxURvXRKX1PBlyK!5-N0t5&UAV8o!fk<|NDVMD@p>eFx`vR9-@|(vFSaR*#bb&_dI{~Ho8FX^jr}jK!5-N z0t5&UAV7csf%XI<*#%xcZ{LB9V}-iFeuV`Fo_glLch?2lo4C~k2oNAZfB*pk1PBly zK%iG3l3n1fF=MW8Y%$aYuABS1s}8EHU)BYBr{{_Q0RjXF5FkK+009C72(%{<$u980 z88?k?94piX3WI*}(6C$AK3^ATZ{k)HAV7cs0RjXF5FkK+0D)eCNOpl~r@YwsxBqY# zSogOFy!q@GJMF6r^iIze0RjXF5FkK+009C72oPvbAd+3+>pyjWQ3%czdS76~eQ$lW z=kUAzK7#fJZZ!b{1PBlyK!5-N0t5&U=pzutF7Rmo~ zfB*pk1PBlyK!5-N0(}G`*#!n4amY7?V3VOP@S7b@8@BL2FF8aP=rcZ-1PBlyK!5-N z0t5&UAV8okfk<|NMGm?6>Oyd=P#3uK)^C^py7=1XxXi>7w9uSmjnn9AV7cs0RjXF5FkLHErCdOf#hy$oL>))73u<|Q{O&+ zrRSHuNEc{p;1&}gK!5-N0t5&UAV7csfj$C}>;k{u`FHErgH49Iz&3CH>dlu&t*}s| z3-s@ReC-ymrO9BK45FkK+009C72oNAZV5SBBrwgRhwRA39tHjcobgq<&XS1nv zzM4(M)5%n>QYxl%m0GGA{85Qj;)!HAT`U#j!QYzCRa3D_cF5#M*Y0^>TH%L>rE}?Q zwwx$u%CTxb9!sYy)nvRicm2OAE(FL3R! zV5SD_Zze#1009C72oNAZfB*pk|BFB*yTIn#Ke%x{*mUT9fz|%>$t(F)FF!;V_+Q59 zFA*R>fB*pk1PBlyK!5;&nG)z!7Z@>VzkVHiUtoq^;I?@;efx}mkNKG{FjE8eHxnR0 zfB*pk1PBlyK!5;&|3x5@U0~$4AAVjBHXV9j;QP$u8_avh0{_(o{+Dt3O9TiIAV7cs z0RjXF5FkKcrUW|G1-AeDIoEXTeSsNvfl>edexJ>9<>7<6CwBGgu61?Sf(u^+2oNAZ zfB*pk1PBlyK!89S0v+uF!9F$}n9ilM*>a+sDaWe$cr2Z+RFm;av63w1a>?M2Vmg^j zjK|ic=2BSrd{`hpD)nHxGg0>fB*pk1PBlyK!5-N0wIBp zc7Y!rmd|8rxmYY$Ddvl{Vy0A!*Als8DjWP)1;s?7m@cOCm2xdzE~QiXa;B6k7Zdqp zKAtJYVySdB77zYAg1zP&J6|E#X6WY*u5{&!o1V4M&X4N?p#i!fK!5-N0t5&UAV7cs z0Rn9ZM6(NwT6=g;aHvohIOK)L?mTSL^$#|>K)>Ke4t7uUa|hcPyrl#P5FkK+009C7 z2oNAZAP|^!y1+c!9e=@qqrZ7b7x2L)K!5-N0t5&UAV7cs0Ro*8h-4QyaAfH}J;4b> z?+dJX``v&1)dH!fbb(II-g5{LAV7cs0RjXF5FkK+KqR}s;aeSlQBN?NP#3s(t(6ZR z{qHYpx`0nD0RjXF5FkK+009C72oUI$KqR}sv73$Cs3$mKs0%E*!D|;EKFH?jb zz2^`hK!5-N0t5&UAV7csfk<|NlQw?%vz}l!p)Rn|X*ch@1$=4=5FkK+009C72oNAZfIz1NBH0BV zSfRULPjJFe7dU13k<;EB|6z|V(5cyb4gmrL2oNAZfB*pk1PBm_WEYsS?68j;qX~6^ z(W}gU_=DdKzFimasU<*w009C72oNAZfB*pkof3#-7kFmqu|w;@2}50A;m@n%2K?*w z(YipVX74!!2oNAZfB*pk1PBlyKp>J`;N``ydZ-@ECe#Hc4C>nLlgAc3To>@EB|v}x z0RjXF5FkK+009D>5{P6Mcx%Xm2i1cUhPpsu_;nxeJ?MpRbb(II-g5{LAV7cs0RjXF z5FkK+KqR}s2Md0*Y(1Dws0+;Va`}}rcmMM`jV{o?yVlh`G5C`&0t5&UAV7cs0RjXF z5FkLH4S^rJKs8m#<}$^6C0R?Q66t)RoT*f+scbx54gOb5XY=`5HeQWaDwR~ZlrGj1 z$z-gQ%;Xc);1P@B3tFd_Suo-rNuReL@l}lDQqpl0IF>Xr<5FkK+ z009C72oNAZfIvu~V_o2brPk~ENxQ%c+YXBjU-rc(bb-(S-4Gx^fB*pk1PBlyK!5;& zHUuKs1*Xk6|6}#wP@(^h;FW)GzH@Hs7c1%lZH(Je0t5&UAV7cs0RjXF5Fii|=u{W@ z@}2cQ?%4YRGwcEn-g_15F7Wj{nZxSA zp+fHqEPO>`n{U2)XG>k6jd5E_fB*pk1PBlyK!5-N0t7+=9qIy)b-lUEg&lieV1`{_ z;v46_aLQx%-=zzL2Iz(W0RjXF5FkK+009C72(%#(#V+tz|G7r3P!A3jdS77Tx%>ZS zf!*IfPZwxo+?Em`K!5-N0t5&UAV7csfsjC_y1*Q-p1fhF-WQm07x?c2hfUh(q&>GA z)IG7QUw5smyB7S(7XbnU2oNAZfB*pk1PBly(1t)qyFjpyO$X*PnOZIu%T4s`+9fQA`)p`AWH#E|=1&d^uCfm5YgdG9S+rW3g1a8jBB^JmW5~RL>5h zXaC*SS2f-j=o;Lm3$!tHO9>DlK!5-N0t5&UAV7e?{|L-FUErRtZ+U9Pwbz}u(FOVi z?+kQL^uB;kF#!Su2oNAZfB*pk1PBmlPoSe+;D?8$^0izd6U$U8=}a}9Oyx_tbgYuh zmP_SCHkC-HYqe4)lh5a>nPjn4OJ(B8RHmAZCyKd5DV{9`?+YyX`}g}7f^&s_zQB>6 zzxU6#pQsGb1=^dq)dUC-AV7cs0RjXF5FkLHS0IvIVCmW&mBtoBU0}Y8cHH)qLF>M+ z3-nIU6#)VS2oNAZfB*pk1PBmlPau+AV8zm#!4D8<>H;qu{L!6jzrMh-x3&eJs^VY@|L+=ah{qcyUu3c!wx-QT=Jy!$>5FkK+009C7 z2oNAZpgn;|c7g2ntL)M^R;UXce9WX{_niHLm@d%X#H}VkfB*pk1PBlyK!5-N0=)u} z>;h|TyTyWyErz-~Bf z#|m|Uzjg2R^t!`uK3NxNZ{k)HAV7cs0RjXF5FkK+0D)eCNOpluHa~aw#uh_e;FaI) zx!m2WPJTug=$)P`0t5&UAV7cs0RjXF5FpT=KqR}s*1x%BNaI+cF0jUapXAQI0~Nj%B6#cXUnB>BAZI2)3sVDlga0E)l9Njs--gVWGYk5#uLR{q7=^-gNMzq3yjE| zzvNw0(sSqnZH(Je0t5&UAV7cs0RjXF5Fii|=vWuH|B7XQ+AeV8*HZ^v{Fg0W8Pq+o zt6$@NftvRPLZfs;fB*pk1PBlyK!5-N0t9ASprc(N_@{3=@TYoT;PZ3N8Z&!<(R!CLTx27D19K!5-N z0t5&UAV7cs0Rn9ZbhHcn@UTiPUyf(vrD{4+E~To;R5?*hB!jO~JXwqtgI{7$O$EQ~ zpjt^~64`t@n=8ihscfoJPUg$ycqteBK7vEO+v>8$nL@v(aF9>-X=t`J?VD8eO1Y@Tx%fMDGiP zM(KtC0RjXF5FkK+009C72+Xv=tkVSw$DI9(?aw}GJzZd?him}>0t5&UAV7cs0RjXF z5cm;+NOpmv*ZO2^A=q%}eSx8i47loF-ygr7F7P9x^lbtJ2oNAZfB*pk1PBlyFw+8& z>;fmQzVKFs;3%Oku)~OW<;%ez57Y%_ddLociHURd%e4S{InociHURGgF0lS#&;0%TYsPfz0zWcJ-zGqS z009C72oNAZfB*pkGc6FwE--PK_eT|iqlCJ^V;8@%@baUNIz<h+&3o4tL7Z*_s09l?a2pYgdQK!5-N0t5&UAV7cs0Rn9a zbgBzHwaDxDcm1SY;MkX@-ccX0AJwmbB@~Y{3+9R(*-&;d(R<2fB*pk1PBlyK!5-N0-fptuMb{%sfOlH*9GQSbn#`T zojCH&#?Ke%8r(c8#>j^d)>H_QhWytp5j{U_>x?0Bj!(AK~$CP07y0RjXF5FkK+ z009Dh1R~i5=Gs0#wz0=h7Z|(LHQ%py(zBCvfj;ANNq_(W0t5&UAV7cs0RjZt5{P6M z7`*L{TQyD<>H<$c_jvKM0sl_v0&NZ4VgdvR5FkK+009C72oNC9M<9}2V3Dos-!=9a z>H_82?|2}y(24Ujx%ESx&`b z*>WzH&c{l%bhTWJSF7cCrBur%v#C@$pD!k|$y6erE#-$yp0eDNBXd2$PDAet+`rEi zkA1!9i?8Vd%`` z#a`D1nrG;q009C72oNAZfB*pk1PHV$5XmmEW6v+%s|P0vb%BrfnLqyFgeUgY1zMf8 zbp!|yAV7cs0RjXF5FkLHSs;>KpmOUv=hlOrhPuG!x88c?h>IUwxzPo>26uIJPwbx9 zxb#JU009C72oNAZfB*pk1PHV$Fza-Iv11MzaMXvhf8c$AR%dM;0RjXF5FkK+009C7 z2oPu%=zJGQr4p%fHl55RQ@L2JoQM@G#Z)y_DOYl-V!l>OQVEpg534?m*|v^s0+2oNAZfB*pk1PBlyK!8BAKqR}s z5!=s|Z)`Nw1#aD;-$EzP{@OaaK=TaU6Cgl<009C72oNAZfB=D31tQr6j@x#XHyZ~E zb%8JY9e2^Y+aLL{F3{?%ts_8y009C72oNAZfB*pk%>t3^0w-_1#Tku_hPuG^^IrVa zD~~Oc&;^=j=$-%p0t5&UAV7cs0RjXFv?>tEE^yZ7`>)wJP^b&M@zuXx%zW{yKkEXm z&e}Qx1PBlyK!5-N0t5&UAkZuj$u4mIZ_a(EvC&W$cx#v0pBlTu1E1&u%`+WZK2$L*UhF2G|$jI0RjXF5FkK+009C72oPvhAd+3+ z+Cu*i8V3q>fg|?5?6U=)+G<{1pw(GhM}PnU0t5&UAV7cs0RjY?1tQr6ZdfDzd?DCr zs0%E6+8IlHcHc|$=>p9&bWeZ)0RjXF5FkK+009C7S`~<77nq#e?u0^cqEHv;AHVaO zC6+&G6H>4#^~oud7N7TFU7&e}?go0J+ByOR2oNAZfB*pk1PBly&@2$iF7V+JrNisNPD5Sb z@q@eO`C|3!hU)^&GjvaY009C72oNAZfB*pk1X>k{WEc2s(c@RF2PX=3f!lWIH~H8{ zUfog`Xm!@s5gaffaxLQ&f)t#^~`A-ZmtWoI&13)5FkK+009C72oNAZfIzcA zB)hgt}@J+X1=ivR%v1PBlyK!5-N0t5&UXjNd==>nsw zLoOS1PX0{q3$!|G>j)4aK!5-N0t5&UAV7dXvq0y&Ks8e><`R`;Jdvr^%C&epo{VS8 zm25d%%_lSYa;ltp)N4#`0RRzf3@h`x?zt7+aBXm!@s5gE8s*=v8a_M9xS1eaD*;*}L%4U=4YAhK`rxL|fEmlp$ zYWYN}QY>YY*-APe%T)8ZA(J0J;)C1nD+D_Yy)W=$*KWscaKRBb=>p9&bWeZ)0RjXF z5FkK+009C7S`~<97x?1$i!}}u>H_C3Iq37%@>gVZfmUa29RUIa2oNAZfB*pk1PBml z7KmgQIBwc0_ck^f>H;gC@Zv4^%sY5xU7&e}?gWel%aCQa7`mG-(CVzMBS3%v0RjXF z5FkK+009Ec0+H+j*S=VOw6W1p7npL>QxC6k&*pxDLmMXwb%F2BKIn+O{x;w}?+dg%YzqkxAV7cs0RjXF5FkK+K$AcuyTIh9 zZr-&JY&6sbelhLq?eBR0qvLderV+X&K!5-N0t5&UAV7cs0Rk-xM6wIqJ>{i^3c-;= zU0~9YpO3ic&@W!l1zH}qg#-u?AV7cs0RjXF5FkLHNg$G4;K4`w-BAcO8tMY~U){CO zf0v(-)CHPG=#~Hh0t5&UAV7cs0RjXFv@8(GF7WsR%k5DJjuh$wkKM4_M!D^#&fVw& zU4y&2x+iu|Y+U*xK!5-N0t5&UAV7cs0RjYC6_|Cp!0W#_X!y_j|Lu?77ie|X))62; zfB*pk1PBlyK!5;&W`WLkfn+=ti>FJeS}c}L7gMQBEtjfh@`+SFnJbnnxm-1#E|#mg zS}jq`=2Ed#wwlf*)A@WYUn-TV#UYa)fAQYUj;IGa4ZSZgX|td0bNb_BU)BYhXXu^) z0RjXF5FkK+009C72(&5?%`WixnLGCcCkl0eq0fK5&1TzYZ_@=@owaoY2oNAZfB*pk z1PBlyK%iM5l3n19NA4Nf*l4H=Ed22XQ?|RJK0p^}o}qgJ1PBlyK!5-N0t5&UAkeBn zB)hR=7t#e;el-yuI;n|KToh@xeEoGXC^`pP>u1I&13)5FkK+009C72oNAZfIzcA zB)h=k*S>Z`W22$>1+KaO4}bsu(0k9(1)68*o&W&?1PBlyK!5-N0t5)ODiFyou*~>@ zrN)6mUErQgjypX2`o^p40$s0+Mz z-QTwyu*K~?x#ri@J%5&QK$<{e0SG*_P*%x zsxHv#tgRzJfB*pk1PBlyK!5-N0?h)E>;kz<>sJ?oorb!=r6&xZZ`{#$|6Uhpo}qgJ z1PBlyK!5-N0t5&UAkeBnB)h=c7hW-<5S%E~1*Rj)4aK!5-N0t5&UAV7dX zvq0y&Kq6l&mnw;3C6%hhv*~iST8fuSrED&^ja8GGRIHdTrjnUjwwTMs62($HUQNbh znOrrSuO?#YA(Nlj{+Ii&TMu>`dS76@EiZfW{^xJmK^JJAp?d-Z2oNAZfB*pk1PBly z(5gTryTFcDr`B#9DAWbcIcmQz&Uo?bvvq-1XKft;0t5&UAV7cs0RjXF5NH;NWEZGh zvF&?}jfT2F_VD5+L+85ekGeqf4BZnTK!5-N0t5&UAV7csfmQ_~*#&mH^w4t~2MTq8 zcemc@x}I}yds`Q1b=KAqAV7cs0RjXF5FkK+0D)$KNOpmJFT7;k#zsS3V4eFPnf>Tv z9$r=#Xr7^a0t5&UAV7cs0RjXF5FpU1KqR}s!GFH=XHVR8M-GxfB*pk1PBlyK!5-N0<8)}vI~qk zamz~@2MTq8b$00g_OM6wG^eqr3=gp9&bWeZ)0RjXF5FkK+009C7 zS`~<77x?C;_f{wbCkl0e5feY2w#=kg6S_dFv$l=^0RjXF5FkK+009C72s8^su?svo z+YR$RSqOF->H?+Lzm6~Q(WF;&f#w;yCqRGz0RjXF5FkK+009E63PiFC47xs1F9atF zb%B>QJLlawuAFO>F3{?%ts_8y009C72oNAZfB*pk%>t3^0`pB6xk@3}X{Za_^ys>e z9-NwVoi5NkL-zy-5FkK+009C72oNAZpjClLc7Y+|4t}N(oG8=<4%+UKEw0?|{Ofgr iR%dM;0RjXF5FkK+009C72oPu%h-4R7a_mLN7XCl&nJ#hw