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/.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/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..824881957d6a --- /dev/null +++ b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationRelayFrame.swift @@ -0,0 +1,23 @@ +public 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) + /// 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 new file mode 100644 index 000000000000..9e1a87f0dfd1 --- /dev/null +++ b/Packages/Shared/CmuxCollaboration/Sources/CmuxCollaboration/CollaborationSession.swift @@ -0,0 +1,240 @@ +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)) + case .terminalOpen, .terminalOutput, .terminalInput, .terminalClose: + break + } + } + + /// 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/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/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/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..36ada9486ffc --- /dev/null +++ b/Packages/Shared/CmuxCollaboration/Tests/CmuxCollaborationTests/CollaborationRelayStateTests.swift @@ -0,0 +1,57 @@ +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) + } + + @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/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/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/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 4e8469384bc6..555e873cf6ff 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -217663,6 +217663,448 @@ } } } + }, + "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": "共同編集" + } + } + } + }, + "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": "ターミナル共有を停止" + } + } + } + }, + "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 new file mode 100644 index 000000000000..09dc4e59b3d8 --- /dev/null +++ b/Sources/CollaborationRuntime.swift @@ -0,0 +1,1524 @@ +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 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 + 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 { + let type: String + 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 { + let type: String + let terminalID: 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 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() { + let displayName = NSFullUserName().isEmpty ? Host.current().localizedName ?? "cmux" : NSFullUserName() + 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 ?? "") + 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 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) + removeTerminalSurfaceMappings(for: terminalID) + hostedTerminalOutputSequencesByID.removeValue(forKey: terminalID) + hostedTerminalOutputCaretSuppressionsByID.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 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) + 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, + "shared_terminals": terminalStatesByID.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 { + relayURLString = Self.normalizedRelayURL(from: relayURL) + } + 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 { + relayURLString = Self.normalizedRelayURL(from: relayURL) + } + 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 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 + sessionCode = nil + inviteToken = nil + panelsByDocumentID.removeAll() + descriptorsByDocumentID.removeAll() + statesByDocumentID.removeAll() + 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() + } + + 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 = CollaborationStrings.relayURLPlaceholder + relayField.frame = NSRect(x: 0, y: 0, width: 360, height: 24) + alert.accessoryView = relayField + + let response = alert.runModal() + relayURLString = Self.normalizedRelayURL(from: relayField.stringValue) + 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 + 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 = CollaborationStrings.relayURLPlaceholder + relayField.frame = NSRect(x: 0, y: 0, width: 360, height: 24) + alert.accessoryView = relayField + + let response = alert.runModal() + relayURLString = Self.normalizedRelayURL(from: relayField.stringValue) + 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() + reopenSharedTerminalsForCurrentSession() + } + + 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 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) + 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 reopenSharedTerminalsForCurrentSession() { + guard let sessionCode else { return } + let openTerminals = hostedTerminalsByID.values.compactMap(\.panel) + guard !openTerminals.isEmpty else { return } + + hostedTerminalsByID.removeAll() + hostedTerminalIDsBySurfaceID.removeAll() + hostedTerminalOutputSequencesByID.removeAll() + hostedTerminalOutputCaretSuppressionsByID.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 + 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 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) + mirroredTerminalIDsBySurfaceID[panel.id] = terminalID + terminalStatesByID[terminalID] = CollaborationTerminalHeaderState( + isShared: true, + statusText: CollaborationStrings.shared, + peerSummary: peerSummary + ) + } + + 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, 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: + panel.surface.forceRefresh(reason: "collaboration.terminalInput") + case .queued, .inputQueueFull, .surfaceUnavailable, .processExited: + break + } + } + + 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) + } + + 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)) + 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, 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, 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) + 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 .terminalOpen(let terminalID, let descriptor): + try await send(CollaborationTerminalOpenWire( + type: "terminal.open", + terminalID: terminalID, + 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(), + caretPeerID: caretPeerID + )) + case .terminalInput(let terminalID, let inputID, let data): + try await send(CollaborationTerminalInputWire( + type: "terminal.input", + terminalID: terminalID, + inputID: inputID, + dataBase64: data.base64EncodedString(), + fromPeerID: peerIdentity.peerID + )) + case .terminalClose(let terminalID): + try await send(CollaborationTerminalCloseWire(type: "terminal.close", terminalID: terminalID)) + 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) + } + 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 { + 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 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 + 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/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/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/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/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 efe4422fb44b..b44d383096df 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1106,6 +1106,48 @@ 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 "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": @@ -1806,6 +1848,39 @@ 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()) + 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 @@ -1947,6 +2022,11 @@ class TerminalController { var methods: [String] = [ "system.ping", "system.capabilities", + "collaboration.status", + "collaboration.session.create", + "collaboration.session.join", + "collaboration.session.leave", + "collaboration.terminal.share_selected", "system.identify", "system.tree", "sidebar.custom.open", diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index ab179b30a9fc..99c94adbb8c7 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -310,6 +310,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 */; }; @@ -412,6 +414,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 */; }; @@ -1648,6 +1651,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 = ""; }; @@ -2459,6 +2463,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 */, @@ -2536,6 +2541,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 */, @@ -2720,6 +2726,7 @@ A5001011 /* cmuxApp.swift */, CA5CADF00000000000000001 /* CanvasDebugMenuButtons.swift */, C46790000000000000000004 /* CLIForwardingLaunchRouter.swift */, + C011AB020000000000000002 /* CollaborationRuntime.swift */, C46790000000000000000006 /* RosettaNativeRelaunch.swift */, 5E2701020000000000000002 /* SentryEventScrubber.swift */, D9CCF001D9CCF001D9CCF001 /* SettingsLazyLoadHelpers.swift */, @@ -3846,6 +3853,7 @@ A5B00002A1B2C3D4E5F60718 /* CMUXAgentLaunch */, CA52A005CA52A005CA52A005 /* CmuxCanvas */, CA53A005CA53A005CA53A005 /* CmuxCanvasUI */, + C011AB010000000000000002 /* CmuxCollaboration */, C5A1FED000000000000000C3 /* CmuxSwiftRender */, C5A1FED100000000000000D3 /* CmuxSwiftRenderUI */, C5A1FED200000000000000E3 /* CmuxSidebarInterpreterClient */, @@ -3962,6 +3970,7 @@ C9A2B00000000000000000C2 /* CmuxAppKitSupportUI */, D7032A060000000000000002 /* CmuxBrowser */, 5E55500000000000000000E2 /* CmuxCommandPalette */, + C011AB010000000000000002 /* CmuxCollaboration */, C0DE4A000000000000000004 /* CmuxSidebarProviderKit */, CC0DE00000000000000000A2 /* CmuxCore */, A5001262 /* Bonsplit */, @@ -4015,6 +4024,7 @@ 40B63BB4A170F0BD2D1DEFD1 /* XCLocalSwiftPackageReference "CMUXAuthCore" */, 53750001A0B1C2D3E4F50001 /* XCLocalSwiftPackageReference "CmuxAuthRuntime" */, AC4A6E7C4A700001AC4A0003 /* XCLocalSwiftPackageReference "CmuxAgentChat" */, + C011AB010000000000000003 /* XCLocalSwiftPackageReference "CmuxCollaboration" */, C0DE45000000000000000003 /* XCLocalSwiftPackageReference "CmuxExtensionKit" */, C0DE4A000000000000000003 /* XCLocalSwiftPackageReference "CmuxSidebarProviderKit" */, C0DE49000000000000000003 /* XCLocalSwiftPackageReference "CmuxExtensionSidebarExamples" */, @@ -4419,6 +4429,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 */, @@ -5413,10 +5424,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)"; @@ -5496,7 +5507,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; @@ -5505,7 +5516,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.64.17; + MARKETING_VERSION = 0.65.0; OTHER_LDFLAGS = ( "-lc++", "-framework", @@ -5542,7 +5553,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; @@ -5551,7 +5562,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.64.17; + MARKETING_VERSION = 0.65.0; ONLY_ACTIVE_ARCH = NO; OTHER_LDFLAGS = ( "-lc++", @@ -5618,10 +5629,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)"; @@ -5636,7 +5647,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; @@ -5645,7 +5656,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 = ""; @@ -5661,7 +5672,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; @@ -5670,7 +5681,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 = ""; @@ -5685,10 +5696,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)"; @@ -5704,10 +5715,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)"; @@ -5821,6 +5832,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; @@ -5994,6 +6009,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/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/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/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 new file mode 100644 index 000000000000..f8cfc6a9e41b --- /dev/null +++ b/workers/collaboration/README.md @@ -0,0 +1,94 @@ +# 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 +``` + +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 +``` + +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 + +### `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` +- `terminal.open` +- `terminal.output` +- `terminal.input` +- `terminal.pointer` +- `terminal.selection` +- `terminal.close` + +`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. +- 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..d8ffdb5de124 --- /dev/null +++ b/workers/collaboration/package.json @@ -0,0 +1,20 @@ +{ + "name": "cmux-collaboration-worker", + "private": true, + "type": "module", + "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" + }, + "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/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/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..5337494e0aad --- /dev/null +++ b/workers/collaboration/src/session.ts @@ -0,0 +1,98 @@ +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; +const liveSessionStates = new Map(); + +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 state = this.stateFor(metadata.sessionID); + const pair = new WebSocketPair(); + const client = pair[0]; + const server = pair[1]; + const now = Date.now(); + server.accept(); + 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 }); + } + + 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; + } + + 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/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..e4e5b408abb9 --- /dev/null +++ b/workers/collaboration/test/session-state.test.ts @@ -0,0 +1,173 @@ +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("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("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(); + 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..c87e8d74051e --- /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", "scripts/**/*.ts"] +} diff --git a/workers/collaboration/wrangler.toml b/workers/collaboration/wrangler.toml new file mode 100644 index 000000000000..7d07e8f56aed --- /dev/null +++ b/workers/collaboration/wrangler.toml @@ -0,0 +1,26 @@ +# 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" + +[[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