mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
* 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
80 lines
2.2 KiB
TypeScript
80 lines
2.2 KiB
TypeScript
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();
|