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:
1 parent
ddcf9622d3
commit
8ed3b4e016
14 files changed
+483
-73
No files matched your search
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 的实验门槛。
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user