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
@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from "vitest";
import type { WebSocket } from "ws";
import { collabRoomHub } from "../../../hosts/collab/room-hub.js";
function fakeWs(open = true): WebSocket {
return {
OPEN: 1,
readyState: open ? 1 : 3,
send: vi.fn(),
} as unknown as WebSocket;
}
describe("collabRoomHub", () => {
it("announces the online list on subscribe and unsubscribe, deduplicated per user", () => {
const a1 = fakeWs();
const a2 = fakeWs();
const b = fakeWs();
collabRoomHub.subscribe("room-1", { ws: a1, userId: "a", username: "A" });
collabRoomHub.subscribe("room-1", { ws: a2, userId: "a", username: "A" });
collabRoomHub.subscribe("room-1", { ws: b, userId: "b", username: "B" });
expect(collabRoomHub.onlineUsers("room-1")).toEqual([
{ userId: "a", username: "A" },
{ userId: "b", username: "B" },
]);
const last = JSON.parse(
(b.send as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[0] as string,
);
expect(last).toEqual({
type: "collab_online",
roomId: "room-1",
users: [
{ userId: "a", username: "A" },
{ userId: "b", username: "B" },
],
});
collabRoomHub.unsubscribe(a1);
expect(collabRoomHub.onlineUsers("room-1")).toHaveLength(2);
collabRoomHub.unsubscribe(a2);
expect(collabRoomHub.onlineUsers("room-1")).toEqual([
{ userId: "b", username: "B" },
]);
collabRoomHub.unsubscribe(b);
expect(collabRoomHub.onlineUsers("room-1")).toEqual([]);
});
it("subscribing the same socket twice keeps one subscription", () => {
const ws = fakeWs();
collabRoomHub.subscribe("room-2", { ws, userId: "a", username: "A" });
collabRoomHub.subscribe("room-2", { ws, userId: "a", username: "A" });
expect((ws.send as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(1);
collabRoomHub.unsubscribe(ws);
});
it("broadcast skips closed sockets and rooms nobody watches", () => {
const open = fakeWs();
const closed = fakeWs(false);
collabRoomHub.subscribe("room-3", { ws: open, userId: "a", username: "A" });
collabRoomHub.subscribe("room-3", {
ws: closed,
userId: "b",
username: "B",
});
(open.send as ReturnType<typeof vi.fn>).mockClear();
(closed.send as ReturnType<typeof vi.fn>).mockClear();
collabRoomHub.broadcast("room-3", { type: "collab_stage_changed" });
collabRoomHub.broadcast("nobody", { type: "collab_stage_changed" });
expect(open.send).toHaveBeenCalledTimes(1);
expect(closed.send).not.toHaveBeenCalled();
collabRoomHub.unsubscribe(open);
collabRoomHub.unsubscribe(closed);
});
});