From 8ed3b4e016bc5b6e66b62a7df237cac57cf6d221 Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:21:51 +0800 Subject: [PATCH] feat(presence): let Web Hosts set display names Web Hosts can set local display names through the existing opt-in presence roster; Viewer/Host subscribers see the current Host name. Native exact wire remains unchanged. --- docs/project-memory.md | 2 +- docs/research/display-name-presence.md | 2 +- docs/status.md | 2 +- docs/方案设计.md | 6 +- docs/需求理解.md | 2 +- src/client/lib/display-name.ts | 28 +++- src/client/lib/signaling.ts | 11 +- src/client/pages/HostPage.tsx | 123 ++++++++++++-- src/client/pages/ViewerPage.tsx | 31 ++++ src/server/signaling.ts | 82 +++++++--- src/shared/protocol.ts | 31 +++- tests/client-state.test.ts | 3 + tests/protocol.test.ts | 15 ++ tests/server-signal.test.ts | 218 ++++++++++++++++++++++--- 14 files changed, 483 insertions(+), 73 deletions(-) diff --git a/docs/project-memory.md b/docs/project-memory.md index aa46a4e..804fd4a 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -31,7 +31,7 @@ Last updated: 2026-08-20 - Flagship media is UDP; HTTPS/WSS stays TLS/TCP; the old release remains rollback-only. - Treat settings as ceilings and degradation as unclassified. Use correlated Host A+B/Viewer C and one-variable evidence; never force AV1, infer by UA, or create a composite score. - Production reports poor film audio and self-echo when system capture includes voice software. Diagnose audio A/B/C and sync; Web cannot isolate arbitrary processes and `maxBitrate` is not quality-up. A Windows 11 native candidate defaults to game-process-tree audio and never widens silently; Windows 10 remains unresolved/unsupported. See `docs/research/browser-screen-audio-quality.md`. -- Viewer-local names and the opt-in Web Host roster are deployed without media changes. Host self-name, endpoint details, and RTP loss remain pending. +- Viewer-local names and the opt-in Web participant roster remain control-plane-only. The Web Host now has a local stable-ID fallback and can rename itself; names stay socket/localStorage-only, and Native remains outside the capability boundary. - Do not add scene detection, dynamic-FPS control, or forced AV1 without negotiation, encode, game, CPU/GPU, and sender evidence. - ADR-0002 grant/public access is deployed; room-password is source-only. Names/presence remain session-only without account or roster tables. - Deferred architecture audit: `docs/maintenance.md`. diff --git a/docs/research/display-name-presence.md b/docs/research/display-name-presence.md index a359ecd..c60aecc 100644 --- a/docs/research/display-name-presence.md +++ b/docs/research/display-name-presence.md @@ -10,4 +10,4 @@ Accessed 2026-08-20. This note records only the browser and Unicode facts that c ## Retained design -The browser is the only durable owner of a display-name preference. The signaling server retains the canonical value only on the authenticated socket state and derives an authoritative online snapshot from current connected sessions. Presence is display-only: it neither authorizes nor changes media routing. A Web Host must opt in so the existing Native sender exact parser receives no new server message or field. +The browser is the only durable owner of a display-name preference. The signaling server retains the canonical value only on the authenticated socket state and derives an authoritative online snapshot from current connected sessions. Presence is display-only: it neither authorizes nor changes media routing. Web Hosts and Viewers explicitly opt in to the shared participant snapshot; a Native sender does not, so its exact parser receives no new server message or field. Host defaults may be derived from the room-scoped stable client ID, but the value is still local/socket-only. diff --git a/docs/status.md b/docs/status.md index 3bec77d..135f5b9 100644 --- a/docs/status.md +++ b/docs/status.md @@ -24,7 +24,7 @@ and is not part of production. - After an answer, a generation-bound 15s initial-connect deadline enters ICE restart; success/replacement/disposal cancels it. It is deployed but not mobile-verified. - Room `1` can publish exactly `HIGH+LOW` with Dynacast/backup codec off and subscriber `HIGH` ceilings; it has no retained real media frame. - Room `1` C+B reparenting remains uncalibrated. Deployed admission rescue promotes an unassigned one-slot relay over the oldest childless zero-capacity Host leaf, with no scores or periodic optimization. -- Viewer names/true Host roster are deployed; Native wire/media remains unchanged. +- Web opt-in roster includes Host self-name; Native wire/media remains unchanged. ## Verified Evidence diff --git a/docs/方案设计.md b/docs/方案设计.md index d864b14..c43c477 100644 --- a/docs/方案设计.md +++ b/docs/方案设计.md @@ -96,11 +96,11 @@ rotate、revoke 或从 public-watch 切回 private-link 都是当前 Viewer 的 客户端用 `sessionStorage` 保存随机的 128-bit `clientId`,作为当前标签页内稳定的参与者 key。同一 role/clientId 的新 WebSocket 会原子替换旧 socket;观看者断线后保留五秒 grace,期间不发送 `peer-left` 且 `peerId` 不变,重新鉴权时重发幂等的 `peer-joined`,超时才释放名额。host 鉴权或重连后,`authenticated` 携带包含 grace 成员的权威 viewer ID 列表供客户端删除已离开的 peer,服务端再重放当前在线观看者的 `peer-joined`,因此观看者可以先于 host 打开邀请,host 离线期间的离开也不会遗留幽灵连接。 -显示名是独立的本地偏好:同源 `localStorage` 只保存浏览器输入,存储不可用时静默退回内存 fallback;服务端只把 Viewer 的规范化名称挂在当前 authenticated socket state,断线即丢弃,不进入 RoomStore、SQLite 或日志。shared schema 先做 NFC、合并 Unicode space separators、去除边缘空格,再限制为 1 至 24 个 Unicode code points,并拒绝控制字符、行/段分隔符、BOM、双向控制符和孤立 surrogate;未设置时使用通用“访客”。普通多语言文本、组合字符和 emoji 仍可用。 +显示名是独立的本地偏好:同源 `localStorage` 只保存浏览器输入,存储不可用时静默退回内存 fallback;服务端只把参与者的规范化名称挂在当前 authenticated socket state,断线即丢弃,不进入 RoomStore、SQLite 或日志。shared schema 先做 NFC、合并 Unicode space separators、去除边缘空格,再限制为 1 至 24 个 Unicode code points,并拒绝控制字符、行/段分隔符、BOM、双向控制符和孤立 surrogate;未设置时 Viewer 使用通用“访客”,Web Host 可由 room-scoped stable client ID 生成本地 fallback。普通多语言文本、组合字符和 emoji 仍可用。 -Web Host 在 `authenticate` 中显式发送 `viewerPresence: true` capability;Native sender 和旧 Web Host 不发送,因此继续只收到原有 exact server wire。Viewer 可在 `authenticate.displayName` 发送名称,并以 `set-display-name` 更新当前会话。服务端只向已订阅的当前 Web Host 发送完整 `viewer-presence` snapshot,列表严格来自 RoomStore 当前带 sessionId 的在线 Viewer,不含五秒媒体恢复 grace 成员。加入、离线、会话替换、改名和 active route 改变都会重发快照;该只读广播不调用 connect/reconcile,也不建立任何媒体边。Host 的“在线”总数取 snapshot 长度,“Host 直连”取其中 active media topology 为 `host-direct` 的条目,最多两条的媒体 peer snapshot 只补充本机连接指标。 +Web Host 在 `authenticate` 中显式发送 `viewerPresence: true` capability,Viewer 也可显式订阅同一 participant snapshot;Native sender 不发送,因此继续只收到原有 exact server wire。两端可在 `authenticate.displayName` 发送名称,并以 `set-display-name` 更新当前会话。服务端只向已订阅的 Web participant 发送完整 `viewer-presence` snapshot,列表严格来自当前带 sessionId 的在线参与者,不含五秒媒体恢复 grace 成员;条目用 `role` discriminator 区分 Host/Viewer。加入、离线、会话替换、改名和 active route 改变都会重发快照;该只读广播不调用 connect/reconcile,也不建立任何媒体边。Host 的“在线”Viewer 总数取 Viewer 条目数,“Host 直连”取其中 active media topology 为 `host-direct` 的条目,最多两条的媒体 peer snapshot 只补充本机连接指标。 -每个 presence 条目只有 `peerId`、`displayName` 和当前 active media topology:`host-direct`、`peer-relay`、`sfu` 或 `pending`。UI 始终展示名称加 peerId 尾部至少六字符短后缀;碰撞时逐字符有界延长至完整 peerId。peerId 不因此成为授权身份或路由输入。媒体拓扑标签使用“Host 直连 / Peer 中转 / SFU / 连接中”;ICE candidate path 单独使用“ICE 直连 / TURN 传输 / ICE 未知”,不能把 TURN transport 伪装成媒体拓扑。首个 slice 只实现 Viewer 本地改名和 Host 权威 Viewer roster,不上传或持久化 IP,也不改变 Host/Viewer 的授权、媒体或路由状态机。 +每个 presence 条目包含 `role`、`peerId`、`displayName` 和当前 active media topology;Host 使用 `host`,Viewer 使用 `host-direct`、`peer-relay`、`sfu` 或 `pending`。UI 始终展示名称加 peerId 尾部至少六字符短后缀;碰撞时逐字符有界延长至完整 peerId。peerId 不因此成为授权身份或路由输入。媒体拓扑标签使用“Host 直连 / Peer 中转 / SFU / 连接中”;ICE candidate path 单独使用“ICE 直连 / TURN 传输 / ICE 未知”,不能把 TURN transport 伪装成媒体拓扑。Host 与 Viewer 的本地改名和订阅仍不上传或持久化 IP,也不改变 Host/Viewer 的授权、媒体或路由状态机。 ## 信令协议 diff --git a/docs/需求理解.md b/docs/需求理解.md index 62bf1f9..8d4915e 100644 --- a/docs/需求理解.md +++ b/docs/需求理解.md @@ -18,7 +18,7 @@ - [ ] 屏幕采集:分享者可主动选择显示器或应用窗口,开始、停止、临时暂停画面和切换分享来源。Host 本地预览必须直接使用 capture stream,不得为预览建立 viewer/媒体边或消耗 TURN、SFU、应用服务器 media egress;预览仍可能产生本地合成、缩放和 GPU 显示成本,不能把隐藏预览称为停止采集或编码。 - [ ] 游戏声音:Windows Chrome/Edge 目标环境可请求系统音频;没有返回音轨时必须明确提示,而不是静默无声。 - [ ] 观看端本地音量:播放控制栏提供 `0%` 至 `100%` 音量条和静音按钮,只改变当前浏览器的媒体元素,不修改 Host 音轨、编码、房间状态或其他观看者。切流、换父和 ICE 恢复保留当前页面内的值;刷新后不持久化。静音保留上次非零音量,拖动到非零值自动取消静音。 -- [ ] 本地显示名与房间成员:用户可设置一个只保存在当前浏览器同源 `localStorage` 的显示名;信令只在当前在线 socket 会话内保存和广播 NFC 规范化、最多 24 个 Unicode code points、拒绝控制/换行/双向覆盖符与孤立 surrogate 的纯文本标签,不写 SQLite 或日志。空值使用通用角色 fallback。Host 必须看到全部当前在线 Viewer 的权威总数与 roster,不能拿最多两条 Host 媒体直连数冒充在线人数;presence 更新也不得创建媒体连接。展示身份使用“名称 + 当前房间 `peerId` 的至少六字符短后缀”,房内碰撞时有界延长。显示名、后缀和 `clientId` 都不是账号、授权身份或全局 UUID,重复名称允许且不得参与授权、路由或质量决策。首个独立 slice 交付 Viewer 本地改名与 Viewer -> Host roster;Host 名称及 Viewer 侧完整 roster 留在同一需求的后续 slice。 +- [ ] 本地显示名与房间成员:用户可设置一个只保存在当前浏览器同源 `localStorage` 的显示名;信令只在当前在线 socket 会话内保存和广播 NFC 规范化、最多 24 个 Unicode code points、拒绝控制/换行/双向覆盖符与孤立 surrogate 的纯文本标签,不写 SQLite 或日志。空值使用通用角色 fallback。Host 必须看到全部当前在线 Viewer 的权威总数与 roster,不能拿最多两条 Host 媒体直连数冒充在线人数;presence 更新也不得创建媒体连接。展示身份使用“名称 + 当前房间 `peerId` 的至少六字符短后缀”,房内碰撞时有界延长。显示名、后缀和 `clientId` 都不是账号、授权身份或全局 UUID,重复名称允许且不得参与授权、路由或质量决策。Web Host 与 Viewer 通过同一 opt-in participant roster 看到 Host 名称,Host 可编辑或清除本地名称;Native 不发送 capability,wire 保持不变。 - [ ] 一对多观看:当前安全默认上限为 8、可配置范围为 1 至 16;自动分布式拓扑验收后,同一 release 将默认值和上限改为 20。当前 PoC 的逐观看者连接是测量基线,不再是超过两名观看者时的目标拓扑;未通过 20 人门禁时继续明确拒绝超额加入。 - [ ] 端点 fanout 硬上限:任一非服务器端点任何时刻最多维护两条 active downstream media edges,包括加入、断线、换父和恢复过程;上游接收不计入这项上传预算。当前 browser relay 因逐跳重编码仍保守限制为一个 child,只有资源矩阵通过后才可升到二。第三名及后续观看者由系统自动分配给通过验收的 peer-assisted 拓扑,不能隐藏地回退为 Host 第三条连接。没有可行 peer path 时,SFU 作为主要中央兜底 virtual parent 正常向一至两个必要 roots 送流;媒体到达 root 后其余可行观看者仍优先沿受限 peer descendants 扩散。多个无法挂到健康 root 的极端 viewer 可成为额外 server-fed 例外,但必须受显式中央 egress/admission 上限约束,不能无界整房 fanout。TURN 只作为可选的极端网络 transport 兼容层,不定义房间拓扑。 - [ ] P2P 优先:每条逻辑媒体边的 ICE 必须先尝试直接 UDP 路径,服务器不得默认中转可直连的媒体。一个或两个观看者优先直接连接分享端;更多观看者的 peer-assisted 行为须先通过 ADR-0004 的实验门槛。 diff --git a/src/client/lib/display-name.ts b/src/client/lib/display-name.ts index 8b2322b..8befde5 100644 --- a/src/client/lib/display-name.ts +++ b/src/client/lib/display-name.ts @@ -1,4 +1,5 @@ import { + DEFAULT_HOST_DISPLAY_NAME_PREFIX, DEFAULT_VIEWER_DISPLAY_NAME, normalizeDisplayName, type DisplayName, @@ -6,11 +7,11 @@ import { const DISPLAY_NAME_STORAGE_KEY = "screener:display-name:v1"; -export function readDisplayName(): DisplayName { +export function readStoredDisplayName(): DisplayName | null { try { const stored = window.localStorage.getItem(DISPLAY_NAME_STORAGE_KEY); if (!stored) { - return DEFAULT_VIEWER_DISPLAY_NAME; + return null; } const normalized = normalizeDisplayName(stored); if (normalized === stored) { @@ -20,16 +21,33 @@ export function readDisplayName(): DisplayName { } catch { // Restricted storage must not block joining a room. } - return DEFAULT_VIEWER_DISPLAY_NAME; + return null; } -export function saveDisplayName(value: string): DisplayName | null { +export function readDisplayName( + fallback: DisplayName = DEFAULT_VIEWER_DISPLAY_NAME, +): DisplayName { + return readStoredDisplayName() ?? fallback; +} + +export function defaultHostDisplayName(clientId: string): DisplayName { + const suffix = clientId.slice(-6); + return ( + normalizeDisplayName(`${DEFAULT_HOST_DISPLAY_NAME_PREFIX}-${suffix}`) ?? + DEFAULT_HOST_DISPLAY_NAME_PREFIX + ); +} + +export function saveDisplayName( + value: string, + fallback: DisplayName = DEFAULT_VIEWER_DISPLAY_NAME, +): DisplayName | null { const normalized = normalizeDisplayName(value); const useFallback = value.length === 0 || /^\p{Zs}+$/u.test(value); if (!normalized && !useFallback) { return null; } - const displayName = normalized ?? DEFAULT_VIEWER_DISPLAY_NAME; + const displayName = normalized ?? fallback; try { if (useFallback) { window.localStorage.removeItem(DISPLAY_NAME_STORAGE_KEY); diff --git a/src/client/lib/signaling.ts b/src/client/lib/signaling.ts index 9959518..42fe6dc 100644 --- a/src/client/lib/signaling.ts +++ b/src/client/lib/signaling.ts @@ -99,14 +99,21 @@ export class SignalingClient { return true; } - setViewerDisplayName(displayName: DisplayName): boolean { - if (this.identity.role !== "viewer") { + setDisplayName(displayName: DisplayName): boolean { + if ( + this.identity.role === "host" && + this.identity.viewerPresence !== true + ) { return false; } this.identity.displayName = displayName; return this.send({ type: "set-display-name", displayName }); } + setViewerDisplayName(displayName: DisplayName): boolean { + return this.setDisplayName(displayName); + } + sendThenStop(message: ClientMessage): void { if (this.stopped) { return; diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx index b32d34d..8900d28 100644 --- a/src/client/pages/HostPage.tsx +++ b/src/client/pages/HostPage.tsx @@ -9,21 +9,23 @@ import { Pause, Play, RefreshCw, + Save, Square, Trash2, Users, } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { + DEFAULT_HOST_DISPLAY_NAME_PREFIX, DEFAULT_QUALITY_SETTINGS, MAX_VIEWER_PASSWORD_LENGTH, VIEWER_QUALITY_EVIDENCE_EXPIRY_MS, viewerPasswordSchema, type CreateRoomResponse, type IceConfig, + type ParticipantPresenceEntry, type ServerMessage, type ViewerAccessPolicy, - type ViewerPresenceEntry, } from "../../shared/protocol"; import { AppHeader } from "../components/AppHeader"; import { ConnectionDetailsToggle } from "../components/ConnectionDetailsToggle"; @@ -39,6 +41,11 @@ import { import { StatsGrid } from "../components/StatsGrid"; import { ApiError, createRoom } from "../lib/api"; import { createOpaqueId } from "../lib/opaque-id"; +import { + defaultHostDisplayName, + readDisplayName, + saveDisplayName, +} from "../lib/display-name"; import { clearHostRoom, clearViewerGrant, @@ -196,9 +203,12 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { const [peerSnapshots, setPeerSnapshots] = useState>( () => new Map(), ); - const [viewerPresence, setViewerPresence] = useState( - [], - ); + const [participantPresence, setParticipantPresence] = useState< + ParticipantPresenceEntry[] + >([]); + const [displayName, setDisplayName] = useState(() => readDisplayName()); + const [displayNameDraft, setDisplayNameDraft] = useState(displayName); + const [displayNameError, setDisplayNameError] = useState(null); const [viewerQualityEvidence, setViewerQualityEvidence] = useState< Map >(() => new Map()); @@ -212,6 +222,8 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { const videoRef = useRef(null); const streamRef = useRef(null); const signalRef = useRef(null); + const displayNameRef = useRef(displayName); + const hostClientIdRef = useRef(null); const viewerPasswordActionRef = useRef<"set" | "remove" | null>(null); const iceConfigRef = useRef(null); const peersRef = useRef(new Map()); @@ -243,15 +255,35 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { [peerSnapshots], ); const viewers = useMemo( - () => labelViewerPresence(viewerPresence), - [viewerPresence], + () => + labelViewerPresence( + participantPresence.filter( + (participant): participant is Extract< + ParticipantPresenceEntry, + { role: "viewer" } + > => participant.role === "viewer", + ), + ), + [participantPresence], + ); + const hostPresence = useMemo( + () => + participantPresence.find( + (participant): participant is Extract< + ParticipantPresenceEntry, + { role: "host" } + > => participant.role === "host", + ) ?? null, + [participantPresence], ); const hostDirectViewerCount = useMemo( () => - viewerPresence.filter( - (viewer) => viewer.mediaTopology === "host-direct", + participantPresence.filter( + (participant) => + participant.role === "viewer" && + participant.mediaTopology === "host-direct", ).length, - [viewerPresence], + [participantPresence], ); const selectedQualityProfileId = useMemo( () => matchingQualityProfileId(qualitySettings), @@ -395,7 +427,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { setDetails(null); setMaxViewers(null); setPeerSnapshots(new Map()); - setViewerPresence([]); + setParticipantPresence([]); viewerQualityEvidenceTimersRef.current.forEach((timer) => window.clearTimeout(timer), ); @@ -910,7 +942,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { return; } if (message.type === "viewer-presence") { - setViewerPresence(message.viewers); + setParticipantPresence(message.viewers); return; } if (message.type === "route-update") { @@ -1083,15 +1115,24 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { claimedRoom = true; } const activeRoom = createdRoom; + const hostClientId = getStableClientId("host", activeRoom.roomId); + hostClientIdRef.current = hostClientId; + const hostFallback = defaultHostDisplayName(hostClientId); + const initialDisplayName = readDisplayName(hostFallback); + displayNameRef.current = initialDisplayName; + setDisplayName(initialDisplayName); + setDisplayNameDraft(initialDisplayName); + setDisplayNameError(null); const signal = new SignalingClient( { roomId: activeRoom.roomId, role: "host", token: activeRoom.hostToken, - clientId: getStableClientId("host", activeRoom.roomId), + clientId: hostClientId, shareGeneration, viewerPresence: true, viewerPasswordSettings: true, + displayName: initialDisplayName, }, { onStatus: (status) => { @@ -1370,6 +1411,24 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { setNotice(null); } + function commitDisplayName(): void { + const hostFallback = hostClientIdRef.current + ? defaultHostDisplayName(hostClientIdRef.current) + : DEFAULT_HOST_DISPLAY_NAME_PREFIX; + const saved = saveDisplayName(displayNameDraft, hostFallback); + if (!saved) { + setDisplayNameError("名称格式无效或超过 24 个字符"); + return; + } + displayNameRef.current = saved; + setDisplayName(saved); + setDisplayNameDraft(saved); + setDisplayNameError(null); + if (!signalRef.current?.setDisplayName(saved)) { + setNotice("开始分享并连接后才能修改显示名"); + } + } + const expirationText = room ? room.expiresAt ? `${new Intl.DateTimeFormat("zh-CN", { @@ -1394,7 +1453,9 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
-

屏幕分享

+

+ {hostPresence?.displayName ?? displayName} 的屏幕 +

{room && }

@@ -1455,6 +1516,42 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { )}

+
{ + event.preventDefault(); + commitDisplayName(); + }} + > + + { + setDisplayNameDraft(event.target.value); + setDisplayNameError(null); + }} + /> + + {displayNameError && ( + + {displayNameError} + + )} +
+
readDisplayName()); const [displayNameDraft, setDisplayNameDraft] = useState(displayName); const [displayNameError, setDisplayNameError] = useState(null); + const [hostPresence, setHostPresence] = useState | null>(null); const [viewerPasswordDraft, setViewerPasswordDraft] = useState(""); const [viewerPasswordError, setViewerPasswordError] = useState( null, @@ -171,6 +176,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { ? { viewerPassword: viewerPasswordAttempt.password } : {}), displayName: displayNameRef.current, + viewerPresence: true, }, { onStatus: (status) => { @@ -186,6 +192,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { setSfuStandbyUrl(null); clearViewerSfuRoute(); clearPeerState(); + setHostPresence(null); if (!viewerAuthenticated) { setAccessState("denied"); } @@ -197,6 +204,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { setSfuStandbyUrl(null); clearViewerSfuRoute(); clearPeerState(); + setHostPresence(null); viewerAuthenticated = false; setAccessState("denied"); setStatusText("邀请无效或已失效"); @@ -758,11 +766,23 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { } return; } + if (message.type === "viewer-presence") { + setHostPresence( + message.viewers.find( + (participant): participant is Extract< + ParticipantPresenceEntry, + { role: "host" } + > => participant.role === "host", + ) ?? null, + ); + return; + } if (message.type === "sharing-stopped") { setSfuStandbyUrl(null); currentHostOnline = false; clearViewerSfuRoute(); clearPeerState(); + setHostPresence(null); setHostOnline(false); setStatusText("等待开始分享"); return; @@ -781,6 +801,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { setSfuStandbyUrl(null); clearViewerSfuRoute(); clearPeerState(); + setHostPresence(null); setStatusText("邀请已失效,请向分享者获取新链接"); signal.stop(); return; @@ -791,6 +812,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { setSfuStandbyUrl(null); clearViewerSfuRoute(); clearPeerState(); + setHostPresence(null); setStatusText(message.reason === "expired" ? "房间已过期" : "房间已关闭"); signal.stop(); return; @@ -798,6 +820,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { if (message.type === "error") { if (message.code === "PEER_NOT_FOUND" && !currentHostOnline) { clearPeerState(); + setHostPresence(null); setStatusText("等待开始分享"); return; } @@ -812,6 +835,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { setSfuStandbyUrl(null); clearViewerSfuRoute(); clearPeerState(); + setHostPresence(null); viewerAuthenticated = false; setAccessState("denied"); } @@ -840,6 +864,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) { } sfuStandbyPrewarmer?.dispose(); signal.stop(); + setHostPresence(null); if (signalRef.current === signal) { signalRef.current = null; } @@ -1042,6 +1067,12 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
+ {hostPresence && ( +

+ {hostPresence.displayName} 的屏幕 +

+ )} +
{ diff --git a/src/server/signaling.ts b/src/server/signaling.ts index 975cdf0..98f33e1 100644 --- a/src/server/signaling.ts +++ b/src/server/signaling.ts @@ -5,6 +5,7 @@ import type { Duplex } from "node:stream"; import WebSocket, { WebSocketServer } from "ws"; import { + DEFAULT_HOST_DISPLAY_NAME_PREFIX, DEFAULT_VIEWER_DISPLAY_NAME, DEFAULT_QUALITY_SETTINGS, MAX_PARENT_EDGE_QUALITY_EVIDENCE_BYTES, @@ -18,7 +19,7 @@ import { type QualitySettings, type Role, type ServerMessage, - type ViewerPresenceEntry, + type ParticipantPresenceEntry, } from "../shared/protocol.js"; import { RoomStore, @@ -445,10 +446,12 @@ export class SignalingServer { : Date.parse(participant.expiresAt), shareGeneration, displayName: - message.role === "viewer" - ? (message.displayName ?? DEFAULT_VIEWER_DISPLAY_NAME) - : null, - viewerPresence: false, + message.role === "host" + ? message.viewerPresence === true + ? (message.displayName ?? DEFAULT_HOST_DISPLAY_NAME_PREFIX) + : null + : (message.displayName ?? DEFAULT_VIEWER_DISPLAY_NAME), + viewerPresence: message.viewerPresence === true, viewerPasswordSettings: false, }; if (participant.role === "viewer") { @@ -517,8 +520,6 @@ export class SignalingServer { } else { this.send(socket, authenticatedMessage); } - state.authenticated.viewerPresence = - message.role === "host" && message.viewerPresence === true; state.authenticated.viewerPasswordSettings = message.role === "host" && message.viewerPasswordSettings === true; if (state.authenticated.viewerPasswordSettings) { @@ -694,8 +695,15 @@ export class SignalingServer { this.handleParentEdgeQualityEvidence(socket, authenticated, message); return; case "set-display-name": - if (authenticated.role !== "viewer") { - this.sendError(socket, "FORBIDDEN", "Only viewers may set a display name"); + if ( + authenticated.role === "host" && + !authenticated.viewerPresence + ) { + this.sendError( + socket, + "FORBIDDEN", + "Display names require the Web presence capability", + ); return; } authenticated.displayName = message.displayName; @@ -764,6 +772,7 @@ export class SignalingServer { authenticated.peerId, state.sessionId, ); + this.sendViewerPresence(authenticated.roomId); } this.stopSharing(authenticated.roomId); socket.close(1000, "Sharing stopped"); @@ -1277,6 +1286,7 @@ export class SignalingServer { )) { this.sendToSession(viewer.sessionId, { type: "host-status", online: false }); } + this.sendViewerPresence(disconnected.roomId); return; } @@ -1445,25 +1455,31 @@ export class SignalingServer { if (this.deferredViewerPresenceRooms.has(roomId)) { return; } + + const viewers: ParticipantPresenceEntry[] = []; const host = this.options.roomStore.getConnectedHost(roomId); - if (!host) { - return; - } - const hostSocket = this.socketsBySessionId.get(host.sessionId); + const hostSocket = host + ? this.socketsBySessionId.get(host.sessionId) + : undefined; const hostState = hostSocket ? this.socketStates.get(hostSocket)?.authenticated : undefined; if ( - !hostSocket || - hostState?.role !== "host" || - hostState.roomId !== roomId || - hostState.peerId !== host.peerId || - !hostState.viewerPresence + host && + hostSocket && + hostState?.role === "host" && + hostState.roomId === roomId && + hostState.peerId === host.peerId && + hostState.displayName !== null ) { - return; + viewers.push({ + role: "host", + peerId: host.peerId, + displayName: hostState.displayName, + mediaTopology: "host", + }); } - const viewers: ViewerPresenceEntry[] = []; for (const viewer of this.options.roomStore.getConnectedViewers(roomId)) { const viewerSocket = this.socketsBySessionId.get(viewer.sessionId); const viewerState = viewerSocket @@ -1478,6 +1494,7 @@ export class SignalingServer { continue; } viewers.push({ + role: "viewer", peerId: viewer.peerId, displayName: viewerState.displayName, mediaTopology: this.isPeerAssistedRoom(roomId) @@ -1485,10 +1502,31 @@ export class SignalingServer { roomId, viewer.peerId, ) - : "host-direct", + : "host-direct", }); } - this.send(hostSocket, { type: "viewer-presence", viewers }); + + const message = { type: "viewer-presence" as const, viewers }; + const recipients = new Set(); + if (hostSocket && hostState?.viewerPresence) { + recipients.add(hostSocket); + } + for (const viewer of this.options.roomStore.getConnectedViewers(roomId)) { + const viewerSocket = this.socketsBySessionId.get(viewer.sessionId); + const viewerState = viewerSocket + ? this.socketStates.get(viewerSocket)?.authenticated + : undefined; + if ( + viewerSocket && + viewerState?.role === "viewer" && + viewerState.roomId === roomId && + viewerState.peerId === viewer.peerId && + viewerState.viewerPresence + ) { + recipients.add(viewerSocket); + } + } + recipients.forEach((socket) => this.send(socket, message)); } private sendToSession(sessionId: string, message: ServerMessage): void { diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 61c513c..0eda4d6 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { isCanonicalVideoCodecEvidence } from "./video-codec-evidence.js"; export const MAX_VIEWERS_PER_ROOM_LIMIT = 16; +export const MAX_PARTICIPANTS_PER_ROOM_LIMIT = MAX_VIEWERS_PER_ROOM_LIMIT + 1; export const MAX_SIGNAL_BYTES = 64 * 1024; export const SIGNALING_PROTOCOL = "screener-v2"; export const ROOM_CODE_LENGTH = 12; @@ -16,6 +17,7 @@ export const VIEWER_QUALITY_EVIDENCE_INTERVAL_MS = 2_000; export const VIEWER_QUALITY_EVIDENCE_EXPIRY_MS = 5_000; export const MAX_DISPLAY_NAME_CODE_POINTS = 24; export const DEFAULT_VIEWER_DISPLAY_NAME = "访客"; +export const DEFAULT_HOST_DISPLAY_NAME_PREFIX = "分享者"; export const MIN_VIEWER_PASSWORD_LENGTH = 1; export const MAX_VIEWER_PASSWORD_LENGTH = 64; @@ -63,8 +65,31 @@ const opaqueIdSchema = z .max(128) .regex(/^[A-Za-z0-9_-]+$/); +export const participantPresenceEntrySchema = z.discriminatedUnion("role", [ + z + .object({ + role: z.literal("host"), + peerId: opaqueIdSchema, + displayName: displayNameSchema, + mediaTopology: z.literal("host"), + }) + .strict(), + z + .object({ + role: z.literal("viewer"), + peerId: opaqueIdSchema, + displayName: displayNameSchema, + mediaTopology: viewerMediaTopologySchema, + }) + .strict(), +]); +export type ParticipantPresenceEntry = z.infer< + typeof participantPresenceEntrySchema +>; + export const viewerPresenceEntrySchema = z .object({ + role: z.literal("viewer"), peerId: opaqueIdSchema, displayName: displayNameSchema, mediaTopology: viewerMediaTopologySchema, @@ -476,6 +501,7 @@ const authenticateMessageSchema = z.discriminatedUnion("role", [ shareGeneration: opaqueIdSchema.optional(), viewerPresence: z.literal(true).optional(), viewerPasswordSettings: z.literal(true).optional(), + displayName: displayNameSchema.optional(), }) .strict(), z @@ -488,6 +514,7 @@ const authenticateMessageSchema = z.discriminatedUnion("role", [ viewerGrant: viewerGrantSchema.optional(), viewerPassword: viewerPasswordSchema.optional(), displayName: displayNameSchema.optional(), + viewerPresence: z.literal(true).optional(), }) .strict(), ]); @@ -705,8 +732,8 @@ export const serverMessageSchema = z.union([ .object({ type: z.literal("viewer-presence"), viewers: z - .array(viewerPresenceEntrySchema) - .max(MAX_VIEWERS_PER_ROOM_LIMIT), + .array(participantPresenceEntrySchema) + .max(MAX_PARTICIPANTS_PER_ROOM_LIMIT), }) .strict() .superRefine((message, context) => { diff --git a/tests/client-state.test.ts b/tests/client-state.test.ts index 14a3e56..dc0cb39 100644 --- a/tests/client-state.test.ts +++ b/tests/client-state.test.ts @@ -56,16 +56,19 @@ describe("browser-local display name", () => { it("extends only colliding room-scoped peer ID suffixes", () => { const labeled = labelViewerPresence([ { + role: "viewer", peerId: "viewer_AAAAAAsuffix", displayName: "同名", mediaTopology: "host-direct", }, { + role: "viewer", peerId: "viewer_BBBBBBsuffix", displayName: "同名", mediaTopology: "peer-relay", }, { + role: "viewer", peerId: "viewer_independent", displayName: "朋友", mediaTopology: "sfu", diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index 2c1c5b8..e992dcb 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -723,6 +723,7 @@ describe("server signaling protocol", () => { it("accepts a strict, unique and bounded Viewer presence snapshot", () => { const viewer = { + role: "viewer", peerId: "viewer_12345678", displayName: "小明", mediaTopology: "peer-relay", @@ -745,6 +746,20 @@ describe("server signaling protocol", () => { viewers: [{ ...viewer, ip: "203.0.113.1" }], }).success, ).toBe(false); + expect( + serverMessageSchema.safeParse({ + type: "viewer-presence", + viewers: [ + viewer, + { + role: "host", + peerId: "host_12345678", + displayName: "分享者", + mediaTopology: "host", + }, + ], + }).success, + ).toBe(true); }); it("represents persistent rooms without a room expiry", () => { diff --git a/tests/server-signal.test.ts b/tests/server-signal.test.ts index 5829436..056c31d 100644 --- a/tests/server-signal.test.ts +++ b/tests/server-signal.test.ts @@ -465,6 +465,9 @@ async function authenticate( ...(presence.viewerPasswordSettings ? { viewerPasswordSettings: true } : {}), + ...(presence.displayName + ? { displayName: presence.displayName } + : {}), } : { type: "authenticate", @@ -480,6 +483,7 @@ async function authenticate( ...(presence.displayName ? { displayName: presence.displayName } : {}), + ...(presence.viewerPresence ? { viewerPresence: true } : {}), }, ), ); @@ -535,6 +539,18 @@ async function nextViewerPresenceMatching( throw new Error("Viewer presence did not reach the expected state"); } +function viewerPresenceEntries( + message: Extract, +) { + return message.viewers.filter((participant) => participant.role === "viewer"); +} + +function hostPresenceEntry( + message: Extract, +) { + return message.viewers.find((participant) => participant.role === "host"); +} + async function nextPreparedRoute(client: TestClient) { while (true) { const message = await client.inbox.next("route-update"); @@ -827,6 +843,12 @@ describe("WebSocket signaling", () => { expect(await host.inbox.next("error")).toMatchObject({ code: "FORBIDDEN", }); + host.socket.send( + JSON.stringify({ type: "set-display-name", displayName: "Native 不应改名" }), + ); + expect(await host.inbox.next("error")).toMatchObject({ + code: "FORBIDDEN", + }); await host.inbox.expectNone(40); }); @@ -935,7 +957,9 @@ describe("WebSocket signaling", () => { undefined, { viewerPresence: true }, ); - expect((await host.inbox.next("viewer-presence")).viewers).toEqual([]); + expect( + viewerPresenceEntries(await host.inbox.next("viewer-presence")), + ).toEqual([]); const viewerNames = ["阿明", "阿青", undefined] as const; const viewers: TestClient[] = []; @@ -956,18 +980,18 @@ describe("WebSocket signaling", () => { const full = await nextViewerPresenceMatching( host, (message) => - message.viewers.length === 3 && - message.viewers.some( + viewerPresenceEntries(message).length === 3 && + viewerPresenceEntries(message).some( (viewer) => viewer.mediaTopology === "peer-relay", ), ); - expect(full.viewers.map((viewer) => viewer.displayName)).toEqual([ + expect(viewerPresenceEntries(full).map((viewer) => viewer.displayName)).toEqual([ "阿明", "阿青", "访客", ]); expect( - full.viewers.filter( + viewerPresenceEntries(full).filter( (viewer) => viewer.mediaTopology === "host-direct", ), ).toHaveLength(2); @@ -976,21 +1000,146 @@ describe("WebSocket signaling", () => { JSON.stringify({ type: "set-display-name", displayName: "后来改名" }), ); const renamed = await nextViewerPresenceMatching(host, (message) => - message.viewers.some((viewer) => viewer.displayName === "后来改名"), + viewerPresenceEntries(message).some( + (viewer) => viewer.displayName === "后来改名", + ), ); - expect(renamed.viewers).toHaveLength(3); + expect(viewerPresenceEntries(renamed)).toHaveLength(3); await closeClient(viewers[2]); const afterDisconnect = await nextViewerPresenceMatching( host, - (message) => message.viewers.length === 2, + (message) => viewerPresenceEntries(message).length === 2, ); - expect(afterDisconnect.viewers.map((viewer) => viewer.displayName)).toEqual([ + expect( + viewerPresenceEntries(afterDisconnect).map((viewer) => viewer.displayName), + ).toEqual([ "阿明", "阿青", ]); }); + it("shares an opted-in Host name with Host and Viewer roster subscribers", async () => { + const harness = await startHarness(); + const host = await openClient(harness.webSocketUrl); + const hostAuth = await authenticate( + host, + harness.room, + "host", + "named-host-client", + 1, + undefined, + { viewerPresence: true, displayName: "原始分享者" }, + ); + const initial = await host.inbox.next("viewer-presence"); + expect(hostPresenceEntry(initial)).toMatchObject({ + role: "host", + peerId: hostAuth.peerId, + displayName: "原始分享者", + mediaTopology: "host", + }); + + const viewer = await openClient(harness.webSocketUrl); + const viewerAuth = await authenticate( + viewer, + harness.room, + "viewer", + "named-viewer-client", + 1, + undefined, + { viewerPresence: true, displayName: "观看者" }, + ); + const viewerSnapshot = await nextViewerPresenceMatching( + viewer, + (message) => hostPresenceEntry(message)?.displayName === "原始分享者", + ); + expect(hostPresenceEntry(viewerSnapshot)?.peerId).toBe(hostAuth.peerId); + expect( + viewerPresenceEntries(viewerSnapshot).some( + (entry) => entry.peerId === viewerAuth.peerId, + ), + ).toBe(true); + + const hostSnapshot = await nextViewerPresenceMatching( + host, + (message) => + hostPresenceEntry(message)?.displayName === "原始分享者" && + viewerPresenceEntries(message).length === 1, + ); + expect(hostPresenceEntry(hostSnapshot)?.peerId).toBe(hostAuth.peerId); + + host.socket.send( + JSON.stringify({ type: "set-display-name", displayName: "改名后的分享者" }), + ); + const renamedViewer = await nextViewerPresenceMatching( + viewer, + (message) => hostPresenceEntry(message)?.displayName === "改名后的分享者", + ); + expect(hostPresenceEntry(renamedViewer)?.peerId).toBe(hostAuth.peerId); + const renamedHost = await nextViewerPresenceMatching( + host, + (message) => hostPresenceEntry(message)?.displayName === "改名后的分享者", + ); + expect(hostPresenceEntry(renamedHost)?.peerId).toBe(hostAuth.peerId); + }); + + it("clears a disconnected Host name and publishes the replacement name", async () => { + const harness = await startHarness({ viewerDisconnectGraceMs: 40 }); + const firstHost = await openClient(harness.webSocketUrl); + const firstAuth = await authenticate( + firstHost, + harness.room, + "host", + "replaceable-host-client", + 1, + undefined, + { viewerPresence: true, displayName: "旧分享者" }, + ); + await firstHost.inbox.next("viewer-presence"); + + const viewer = await openClient(harness.webSocketUrl); + await authenticate( + viewer, + harness.room, + "viewer", + "replacement-roster-viewer", + 1, + undefined, + { viewerPresence: true }, + ); + await nextViewerPresenceMatching( + viewer, + (message) => hostPresenceEntry(message)?.displayName === "旧分享者", + ); + + await closeClient(firstHost); + await viewer.inbox.next("host-status"); + const offline = await nextViewerPresenceMatching( + viewer, + (message) => hostPresenceEntry(message) === undefined, + ); + expect(offline.viewers.some((entry) => entry.role === "host")).toBe(false); + + const replacement = await openClient(harness.webSocketUrl); + const replacementAuth = await authenticate( + replacement, + harness.room, + "host", + "replaceable-host-client", + 1, + undefined, + { viewerPresence: true, displayName: "新分享者" }, + ); + expect(replacementAuth.peerId).toBe(firstAuth.peerId); + const online = await nextViewerPresenceMatching( + viewer, + (message) => hostPresenceEntry(message)?.displayName === "新分享者", + ); + expect(hostPresenceEntry(online)?.peerId).toBe(firstAuth.peerId); + await closeClient(replacement); + await closeClient(viewer); + }); + it("publishes one empty Viewer roster after ordinary rotate and revoke", async () => { const harness = await startHarness(); const host = await openClient(harness.webSocketUrl); @@ -1003,7 +1152,9 @@ describe("WebSocket signaling", () => { undefined, { viewerPresence: true }, ); - expect((await host.inbox.next("viewer-presence")).viewers).toEqual([]); + expect( + viewerPresenceEntries(await host.inbox.next("viewer-presence")), + ).toEqual([]); const firstViewer = await openClient(harness.webSocketUrl); const firstViewerAuth = await authenticate( @@ -1018,8 +1169,9 @@ describe("WebSocket signaling", () => { expect((await host.inbox.next("peer-joined")).peerId).toBe( firstViewerAuth.peerId, ); - expect((await host.inbox.next("viewer-presence")).viewers).toEqual([ + expect(viewerPresenceEntries(await host.inbox.next("viewer-presence"))).toEqual([ { + role: "viewer", peerId: firstViewerAuth.peerId, displayName: "第一位", mediaTopology: "host-direct", @@ -1040,7 +1192,9 @@ describe("WebSocket signaling", () => { expect((await host.inbox.next("peer-left")).peerId).toBe( firstViewerAuth.peerId, ); - expect((await host.inbox.next("viewer-presence")).viewers).toEqual([]); + expect( + viewerPresenceEntries(await host.inbox.next("viewer-presence")), + ).toEqual([]); const rotated = await host.inbox.next("viewer-access-updated"); await host.inbox.expectNone(30); @@ -1065,7 +1219,9 @@ describe("WebSocket signaling", () => { expect((await host.inbox.next("peer-joined")).peerId).toBe( secondViewerAuth.peerId, ); - expect((await host.inbox.next("viewer-presence")).viewers).toHaveLength(1); + expect( + viewerPresenceEntries(await host.inbox.next("viewer-presence")), + ).toHaveLength(1); const secondClosed = new Promise((resolve) => secondViewer.socket.once("close", (code) => resolve(code)), @@ -1078,7 +1234,9 @@ describe("WebSocket signaling", () => { expect((await host.inbox.next("peer-left")).peerId).toBe( secondViewerAuth.peerId, ); - expect((await host.inbox.next("viewer-presence")).viewers).toEqual([]); + expect( + viewerPresenceEntries(await host.inbox.next("viewer-presence")), + ).toEqual([]); expect(await host.inbox.next("viewer-access-updated")).toMatchObject({ viewerPolicy: "private-link", inviteUrl: null, @@ -5121,7 +5279,9 @@ describe("WebSocket signaling", () => { undefined, { viewerPresence: true }, ); - expect((await host.inbox.next("viewer-presence")).viewers).toEqual([]); + expect( + viewerPresenceEntries(await host.inbox.next("viewer-presence")), + ).toEqual([]); const original = await openClient(harness.webSocketUrl); const originalAuth = await authenticate( original, @@ -5133,8 +5293,9 @@ describe("WebSocket signaling", () => { { displayName: "旧会话" }, ); await host.inbox.next("peer-joined"); - expect((await host.inbox.next("viewer-presence")).viewers).toEqual([ + expect(viewerPresenceEntries(await host.inbox.next("viewer-presence"))).toEqual([ { + role: "viewer", peerId: originalAuth.peerId, displayName: "旧会话", mediaTopology: "host-direct", @@ -5158,8 +5319,9 @@ describe("WebSocket signaling", () => { expect(replacementAuth.peerId).toBe(originalAuth.peerId); expect(await originalClosed).toBe(4001); expect((await host.inbox.next("peer-joined")).peerId).toBe(originalAuth.peerId); - expect((await host.inbox.next("viewer-presence")).viewers).toEqual([ + expect(viewerPresenceEntries(await host.inbox.next("viewer-presence"))).toEqual([ { + role: "viewer", peerId: originalAuth.peerId, displayName: "新会话", mediaTopology: "host-direct", @@ -5343,7 +5505,9 @@ describe("WebSocket signaling", () => { undefined, { viewerPresence: true }, ); - expect((await firstHost.inbox.next("viewer-presence")).viewers).toEqual([]); + expect( + viewerPresenceEntries(await firstHost.inbox.next("viewer-presence")), + ).toEqual([]); const firstViewer = await openClient(harness.webSocketUrl); const firstViewerAuth = await authenticate( firstViewer, @@ -5355,8 +5519,11 @@ describe("WebSocket signaling", () => { { displayName: "短线重连" }, ); await firstHost.inbox.next("peer-joined"); - expect((await firstHost.inbox.next("viewer-presence")).viewers).toEqual([ + expect( + viewerPresenceEntries(await firstHost.inbox.next("viewer-presence")), + ).toEqual([ { + role: "viewer", peerId: firstViewerAuth.peerId, displayName: "短线重连", mediaTopology: "host-direct", @@ -5376,7 +5543,9 @@ describe("WebSocket signaling", () => { await firstViewer.inbox.next("signal"); await closeClient(firstViewer); - expect((await firstHost.inbox.next("viewer-presence")).viewers).toEqual([]); + expect( + viewerPresenceEntries(await firstHost.inbox.next("viewer-presence")), + ).toEqual([]); await closeClient(firstHost); const secondHost = await openClient(harness.webSocketUrl); @@ -5390,7 +5559,9 @@ describe("WebSocket signaling", () => { { viewerPresence: true }, ); expect(secondHostAuth.viewerPeerIds).toEqual([firstViewerAuth.peerId]); - expect((await secondHost.inbox.next("viewer-presence")).viewers).toEqual([]); + expect( + viewerPresenceEntries(await secondHost.inbox.next("viewer-presence")), + ).toEqual([]); await secondHost.inbox.expectNone(30); const secondViewer = await openClient(harness.webSocketUrl); @@ -5408,8 +5579,11 @@ describe("WebSocket signaling", () => { expect((await secondHost.inbox.next("peer-joined")).peerId).toBe( firstViewerAuth.peerId, ); - expect((await secondHost.inbox.next("viewer-presence")).viewers).toEqual([ + expect( + viewerPresenceEntries(await secondHost.inbox.next("viewer-presence")), + ).toEqual([ { + role: "viewer", peerId: firstViewerAuth.peerId, displayName: "短线重连", mediaTopology: "host-direct",