feat: collaboration rooms with switchable presenter (#1328)

* feat: add collaboration rooms with switchable presenter

Rooms are a group of members watching one stage - the live SSH/RDP/VNC
session the current presenter shares. Any member can take over the
stage; the host can invite, force-stop and end the meeting. Stages
reuse session_shares (new room share type), so gating, recording,
expiry and the global sharing toggle all apply unchanged.

* feat: add stage control handoff to collaboration rooms

The presenter or host can grant any member write access to the live
stage and take it back; members can raise a hand to ask. SSH flips the
participant's permission on the live gate; RDP/VNC re-mint the viewer's
join token. Control clears on every stage switch.

* feat: guest links, role invites and invite awareness for collab rooms

- Anonymous guest link per room (host toggles/rotates), followed by
  polling the public resolve endpoint; SSH guests join over the terminal
  WS with roomGuestToken, guac guests get read-only join tokens
- Invite by role (expands to current members, snapshot semantics)
- Toast when a room you were invited to appears
- Stale stages are cleared lazily when the presenter is gone
- Telnet presenting, expired-tab fallback, documented single-instance
  and guac-kick limits
- Tests for the collab routes, room hub, share access and control flip

* fix: keep remote desktop collaboration read-only
This commit is contained in:
ZacharyZcR
2026-08-25 00:56:04 +08:00
committed by GitHub
parent d35458f78b
commit 81d79cc89b
50 changed files with 56698 additions and 81 deletions
+79
View File
@@ -0,0 +1,79 @@
import type { WebSocket } from "ws";
export interface CollabRoomClient {
ws: WebSocket;
userId: string;
username: string;
}
/**
* In-memory fan-out for collab room events, mirroring the single-instance
* assumption TerminalSessionManager already makes. REST mutations broadcast
* through it; the terminal WS server feeds subscribe/unsubscribe.
*/
class CollabRoomHub {
private rooms = new Map<string, Set<CollabRoomClient>>();
subscribe(roomId: string, client: CollabRoomClient): void {
let clients = this.rooms.get(roomId);
if (!clients) {
clients = new Set();
this.rooms.set(roomId, clients);
}
for (const existing of clients) {
if (existing.ws === client.ws) return;
}
clients.add(client);
this.broadcastOnline(roomId);
}
/** Drops the socket from one room, or from every room when roomId is omitted. */
unsubscribe(ws: WebSocket, roomId?: string): void {
for (const [id, clients] of this.rooms) {
if (roomId && id !== roomId) continue;
let removed = false;
for (const client of clients) {
if (client.ws === ws) {
clients.delete(client);
removed = true;
}
}
if (clients.size === 0) this.rooms.delete(id);
if (removed) this.broadcastOnline(id);
}
}
broadcast(roomId: string, message: object): void {
const clients = this.rooms.get(roomId);
if (!clients) return;
const payload = JSON.stringify(message);
for (const client of clients) {
if (client.ws.readyState !== client.ws.OPEN) continue;
try {
client.ws.send(payload);
} catch {
/* keep broadcasting to the rest */
}
}
}
onlineUsers(roomId: string): Array<{ userId: string; username: string }> {
const clients = this.rooms.get(roomId);
if (!clients) return [];
const seen = new Map<string, string>();
for (const client of clients) {
seen.set(client.userId, client.username);
}
return Array.from(seen, ([userId, username]) => ({ userId, username }));
}
private broadcastOnline(roomId: string): void {
this.broadcast(roomId, {
type: "collab_online",
roomId,
users: this.onlineUsers(roomId),
});
}
}
export const collabRoomHub = new CollabRoomHub();