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.
This commit is contained in:
AAA authored and GitHub committed 2026-08-20 22:21:51 +08:00
1 parent ddcf9622d3
commit 8ed3b4e016
14 files changed
+483 -73

No files matched your search

+1 -1
View File
@@ -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`.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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 的授权、媒体或路由状态机。
## 信令协议
+1 -1
View File
@@ -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 的实验门槛。
+23 -5
View File
@@ -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);
+9 -2
View File
@@ -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;
+110 -13
View File
@@ -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<Map<string, PeerSnapshot>>(
() => new Map(),
);
const [viewerPresence, setViewerPresence] = useState<ViewerPresenceEntry[]>(
[],
);
const [participantPresence, setParticipantPresence] = useState<
ParticipantPresenceEntry[]
>([]);
const [displayName, setDisplayName] = useState(() => readDisplayName());
const [displayNameDraft, setDisplayNameDraft] = useState(displayName);
const [displayNameError, setDisplayNameError] = useState<string | null>(null);
const [viewerQualityEvidence, setViewerQualityEvidence] = useState<
Map<string, ViewerQualityEvidence>
>(() => new Map());
@@ -212,6 +222,8 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const signalRef = useRef<SignalingClient | null>(null);
const displayNameRef = useRef(displayName);
const hostClientIdRef = useRef<string | null>(null);
const viewerPasswordActionRef = useRef<"set" | "remove" | null>(null);
const iceConfigRef = useRef<IceConfig | null>(null);
const peersRef = useRef(new Map<string, HostPeer>());
@@ -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 = {}) {
<div className="section-heading">
<div>
<div className="title-line">
<h1 id="broadcast-heading">屏幕分享</h1>
<h1 id="broadcast-heading">
{hostPresence?.displayName ?? displayName} 的屏幕
</h1>
{room && <RoomCode roomId={room.roomId} />}
</div>
<p className="section-meta">
@@ -1455,6 +1516,42 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
)}
</div>
<form
className="viewer-name-control host-name-control"
onSubmit={(event) => {
event.preventDefault();
commitDisplayName();
}}
>
<label htmlFor="host-display-name">显示名</label>
<input
id="host-display-name"
type="text"
value={displayNameDraft}
maxLength={96}
autoComplete="nickname"
aria-invalid={displayNameError ? "true" : undefined}
onChange={(event) => {
setDisplayNameDraft(event.target.value);
setDisplayNameError(null);
}}
/>
<button
type="submit"
className="icon-button"
title="保存显示名"
aria-label="保存显示名"
disabled={displayNameDraft === displayName}
>
<Save size={17} />
</button>
{displayNameError && (
<span className="viewer-name-error" role="alert">
{displayNameError}
</span>
)}
</form>
<div
className="video-stage local-stage"
role="group"
+31
View File
@@ -17,6 +17,7 @@ import {
viewerPasswordSchema,
type IceConfig,
type MediaAssignment,
type ParticipantPresenceEntry,
type ServerMessage,
} from "../../shared/protocol";
import { AppHeader } from "../components/AppHeader";
@@ -97,6 +98,10 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
const [displayName, setDisplayName] = useState(() => readDisplayName());
const [displayNameDraft, setDisplayNameDraft] = useState(displayName);
const [displayNameError, setDisplayNameError] = useState<string | null>(null);
const [hostPresence, setHostPresence] = useState<Extract<
ParticipantPresenceEntry,
{ role: "host" }
> | null>(null);
const [viewerPasswordDraft, setViewerPasswordDraft] = useState("");
const [viewerPasswordError, setViewerPasswordError] = useState<string | null>(
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) {
</div>
</div>
{hostPresence && (
<p className="section-meta viewer-host-name">
{hostPresence.displayName} 的屏幕
</p>
)}
<form
className="viewer-name-control"
onSubmit={(event) => {
+60 -22
View File
@@ -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<WebSocket>();
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 {
+29 -2
View File
@@ -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) => {
+3
View File
@@ -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",
+15
View File
@@ -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", () => {
+196 -22
View File
@@ -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<ServerMessage, { type: "viewer-presence" }>,
) {
return message.viewers.filter((participant) => participant.role === "viewer");
}
function hostPresenceEntry(
message: Extract<ServerMessage, { type: "viewer-presence" }>,
) {
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<number>((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",