From d3e3a52ad962ca526daad952cf734458ca23032d Mon Sep 17 00:00:00 2001 From: Sivert <316918682+grytchat@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:37:59 +0100 Subject: [PATCH 1/3] feat: a voice client can announce several shared windows (GRYT-1905) voice:screen:state may carry shares: [{videoStreamId, audioStreamId}], checked and capped at 8. Clients get screenShares; the old single fields keep the first window for older apps. Co-Authored-By: Claude Opus 5.5 --- src/socket/handlers/voice.ts | 12 +++++++++-- src/socket/index.ts | 1 + src/socket/utils/clients.ts | 2 ++ src/socket/utils/screenShares.test.ts | 27 +++++++++++++++++++++++++ src/socket/utils/screenShares.ts | 29 +++++++++++++++++++++++++++ src/types.ts | 2 ++ 6 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 src/socket/utils/screenShares.test.ts create mode 100644 src/socket/utils/screenShares.ts diff --git a/src/socket/handlers/voice.ts b/src/socket/handlers/voice.ts index 0bb6eb9a..04f0eef6 100644 --- a/src/socket/handlers/voice.ts +++ b/src/socket/handlers/voice.ts @@ -3,6 +3,7 @@ import consola from "consola"; import type { HandlerContext, EventHandlerMap } from "./types"; import { requireAuth, requireOutranks } from "../middleware/auth"; import { syncAllClients, broadcastMemberList } from "../utils/clients"; +import { screenSharesFrom } from "../utils/screenShares"; import { checkRateLimit, type RateLimitRule } from "../../utils/rateLimiter"; import { getVoiceSeatLimit } from "../../utils/voiceSeats"; import { insertServerAudit } from "../../db"; @@ -138,9 +139,11 @@ export function registerVoiceHandlers(ctx: HandlerContext): EventHandlerMap { return; } consola.info(`[ScreenShare] voice:screen:state from=${clientId} enabled=${enabled} videoStreamId=${videoStreamId} audioStreamId=${audioStreamId}`); + const shares = enabled ? screenSharesFrom(payload) : []; clientsInfo[clientId].screenShareEnabled = enabled; - clientsInfo[clientId].screenShareVideoStreamID = enabled ? videoStreamId : ""; - clientsInfo[clientId].screenShareAudioStreamID = enabled ? audioStreamId : ""; + clientsInfo[clientId].screenShares = shares; + clientsInfo[clientId].screenShareVideoStreamID = enabled ? (shares[0]?.videoStreamID ?? videoStreamId) : ""; + clientsInfo[clientId].screenShareAudioStreamID = enabled ? (shares[0]?.audioStreamID ?? audioStreamId) : ""; syncAllClients(io, clientsInfo); }, @@ -232,6 +235,7 @@ export function registerVoiceHandlers(ctx: HandlerContext): EventHandlerMap { clientsInfo[existingClientId].screenShareEnabled = false; clientsInfo[existingClientId].screenShareVideoStreamID = ""; clientsInfo[existingClientId].screenShareAudioStreamID = ""; + clientsInfo[existingClientId].screenShares = []; if (sfuClient) sfuClient.untrackUserConnection(serverUserId); } } @@ -258,6 +262,7 @@ export function registerVoiceHandlers(ctx: HandlerContext): EventHandlerMap { clientsInfo[clientId].screenShareEnabled = false; clientsInfo[clientId].screenShareVideoStreamID = ""; clientsInfo[clientId].screenShareAudioStreamID = ""; + clientsInfo[clientId].screenShares = []; if (sfuClient && serverUserId) sfuClient.untrackUserConnection(serverUserId); } @@ -461,6 +466,7 @@ export function registerVoiceHandlers(ctx: HandlerContext): EventHandlerMap { carried.screenShareEnabled = false; carried.screenShareVideoStreamID = ""; carried.screenShareAudioStreamID = ""; + carried.screenShares = []; } consola.success(`[Voice:Step 5] Granting room access: client=${clientId} room=${uniqueRoomId} sfu_urls=${sfuPublicUrls.join(", ")}`); @@ -547,6 +553,7 @@ export function registerVoiceHandlers(ctx: HandlerContext): EventHandlerMap { clientsInfo[clientId].screenShareEnabled = false; clientsInfo[clientId].screenShareVideoStreamID = ""; clientsInfo[clientId].screenShareAudioStreamID = ""; + clientsInfo[clientId].screenShares = []; } syncAllClients(io, clientsInfo); @@ -676,6 +683,7 @@ export function registerVoiceHandlers(ctx: HandlerContext): EventHandlerMap { targetClient.screenShareEnabled = false; targetClient.screenShareVideoStreamID = ""; targetClient.screenShareAudioStreamID = ""; + targetClient.screenShares = []; if (sfuClient) sfuClient.untrackUserConnection(targetUserId); syncAllClients(io, clientsInfo); diff --git a/src/socket/index.ts b/src/socket/index.ts index 84659f5d..813e2626 100644 --- a/src/socket/index.ts +++ b/src/socket/index.ts @@ -223,6 +223,7 @@ export function setupSFUSync(io: Server, sfuClient: SFUClient): void { ci.screenShareEnabled = false; ci.screenShareVideoStreamID = ""; ci.screenShareAudioStreamID = ""; + ci.screenShares = []; sfuClient.untrackUserConnection(ci.serverUserId); const sock = io.sockets.sockets.get(sid); diff --git a/src/socket/utils/clients.ts b/src/socket/utils/clients.ts index 4fe2be3f..9c4c35a9 100644 --- a/src/socket/utils/clients.ts +++ b/src/socket/utils/clients.ts @@ -82,6 +82,7 @@ export function publicClientRecord(client: Clients[string], voiceChannelId: stri screenShareEnabled: client.screenShareEnabled, screenShareVideoStreamID: client.screenShareVideoStreamID, screenShareAudioStreamID: client.screenShareAudioStreamID, + screenShares: client.screenShares ?? [], }; } @@ -161,6 +162,7 @@ export function syncAllClients(io: Server, clientsInfo: Clients) { screenShareEnabled: client.screenShareEnabled, screenShareVideoStreamID: client.screenShareVideoStreamID, screenShareAudioStreamID: client.screenShareAudioStreamID, + screenShares: client.screenShares ?? [], isServerMuted: client.isServerMuted, isServerDeafened: client.isServerDeafened, })) diff --git a/src/socket/utils/screenShares.test.ts b/src/socket/utils/screenShares.test.ts new file mode 100644 index 00000000..18e1939a --- /dev/null +++ b/src/socket/utils/screenShares.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { MAX_SCREEN_SHARES, screenSharesFrom } from "./screenShares"; + +describe("screenSharesFrom", () => { + it("reads the single pair an older app sends", () => { + assert.deepEqual(screenSharesFrom({ enabled: true, videoStreamId: "v", audioStreamId: "a" }), [{ videoStreamID: "v", audioStreamID: "a" }]); + }); + + it("reads every window from shares", () => { + const shares = [{ videoStreamId: "v1", audioStreamId: "a1" }, { videoStreamId: "v2" }]; + assert.deepEqual(screenSharesFrom({ enabled: true, shares }), [ + { videoStreamID: "v1", audioStreamID: "a1" }, + { videoStreamID: "v2", audioStreamID: "" }, + ]); + }); + + it("drops what is malformed and stops at the ceiling", () => { + const shares = [null, 5, { videoStreamId: 7 }, { videoStreamId: "x".repeat(200) }, ...Array.from({ length: 20 }, (_, i) => ({ videoStreamId: `v${i}` }))]; + const out = screenSharesFrom({ enabled: true, shares }); + assert.ok(out.length <= MAX_SCREEN_SHARES); + assert.ok(out.every((s) => s.videoStreamID.startsWith("v"))); + assert.deepEqual(screenSharesFrom("yes"), []); + assert.deepEqual(screenSharesFrom({ enabled: true }), []); + }); +}); diff --git a/src/socket/utils/screenShares.ts b/src/socket/utils/screenShares.ts new file mode 100644 index 00000000..1f5c8bf1 --- /dev/null +++ b/src/socket/utils/screenShares.ts @@ -0,0 +1,29 @@ +/** Ceiling on windows one client can announce, matching the SFU's `maxScreenShares`. */ +export const MAX_SCREEN_SHARES = 8; +const MAX_ID_LENGTH = 128; + +export interface ScreenShare { + videoStreamID: string; + audioStreamID: string; +} + +const id = (v: unknown): string | null => (typeof v === "string" && v.length <= MAX_ID_LENGTH ? v : null); + +/** + * The windows a `voice:screen:state` announces. `shares` from an app that shares several, + * otherwise the single pair older apps send. Anything malformed is dropped, not trusted. + */ +export function screenSharesFrom(payload: unknown): ScreenShare[] { + if (typeof payload !== "object" || payload === null) return []; + const p = payload as { shares?: unknown; videoStreamId?: unknown; audioStreamId?: unknown }; + const raw = Array.isArray(p.shares) ? p.shares : [{ videoStreamId: p.videoStreamId, audioStreamId: p.audioStreamId }]; + const out: ScreenShare[] = []; + for (const entry of raw.slice(0, MAX_SCREEN_SHARES)) { + if (typeof entry !== "object" || entry === null) continue; + const e = entry as { videoStreamId?: unknown; audioStreamId?: unknown }; + const video = id(e.videoStreamId); + if (!video) continue; + out.push({ videoStreamID: video, audioStreamID: id(e.audioStreamId) ?? "" }); + } + return out; +} diff --git a/src/types.ts b/src/types.ts index 0363a418..4cc302ef 100644 --- a/src/types.ts +++ b/src/types.ts @@ -18,6 +18,8 @@ export interface Clients { screenShareEnabled: boolean; screenShareVideoStreamID: string; screenShareAudioStreamID: string; + /** Every shared window, first one also in the two fields above for older apps (GRYT-1905). */ + screenShares?: { videoStreamID: string; audioStreamID: string }[]; isServerMuted: boolean; isServerDeafened: boolean; /** On the connection rather than in `users`, like `isAFK`: a stored one would From fdc88aa84a17103dcf86ce1c527554d0d6abbc4f Mon Sep 17 00:00:00 2001 From: Sivert <316918682+grytchat@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:46:37 +0100 Subject: [PATCH 2/3] test: screenShares is a public client field Every member already sees the single share's stream ids; this is the same thing per window. Co-Authored-By: Claude Opus 5.5 --- src/socket/utils/publicClientList.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/socket/utils/publicClientList.test.ts b/src/socket/utils/publicClientList.test.ts index eb61ff4c..a4bc01a4 100644 --- a/src/socket/utils/publicClientList.test.ts +++ b/src/socket/utils/publicClientList.test.ts @@ -18,7 +18,7 @@ import { publicClientList, publicClientRecord } from "./clients"; const PUBLIC_FIELDS = [ "cameraEnabled", "cameraStreamID", "hasJoinedChannel", "isAFK", "isConnectedToVoice", "isDeafened", "isMuted", "isServerDeafened", "isServerMuted", "nickname", - "screenShareAudioStreamID", "screenShareEnabled", "screenShareVideoStreamID", + "screenShareAudioStreamID", "screenShareEnabled", "screenShareVideoStreamID", "screenShares", "serverUserId", "streamID", "voiceChannelId", ]; @@ -40,6 +40,7 @@ const everything: Required = { screenShareEnabled: true, screenShareVideoStreamID: "share-video-a", screenShareAudioStreamID: "share-audio-a", + screenShares: [{ videoStreamID: "share-video-a", audioStreamID: "share-audio-a" }], isServerMuted: false, isServerDeafened: true, activity: "Playing something", From 59f1cc083444017e1cb1ed5142cd71ece38548e8 Mon Sep 17 00:00:00 2001 From: Sivert <316918682+grytchat@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:52:43 +0100 Subject: [PATCH 3/3] chore: list screenShares.ts with the other normalisers of outside input Co-Authored-By: Claude Opus 5.5 --- eslint.config.mts | 1 + 1 file changed, 1 insertion(+) diff --git a/eslint.config.mts b/eslint.config.mts index 3a543b13..8b165ced 100644 --- a/eslint.config.mts +++ b/eslint.config.mts @@ -198,6 +198,7 @@ export default tseslint.config( 'src/polls/poll.ts', 'src/routes/uploadStorage.ts', 'src/services/mlsWire.ts', + 'src/socket/utils/screenShares.ts', 'src/utils/activityText.ts', 'src/utils/linkResolvers.ts', 'src/utils/profanityFilter.ts',