移植到新平台
如果你想开发第六个客户端(比如基于 Flutter、React Native 或 Qt,或者运行在 Linux、电视上),并让它能和现有的五个应用通话,这一页就是为你准备的。下面按大致的开发顺序,列出了客户端需要做的所有事情。做完一项勾掉一项,勾选状态会保存在当前浏览器中。
你的平台只需要提供三样东西:一个 WebRTC 实现(浏览器自带的、webrtc-sdk 或 libwebrtc)、一个 WebSocket 客户端和一个 JSON 解析器,别的都不需要。建议对照 Web 客户端阅读这份清单:它的 1:1 通话引擎 useCall.ts 在一个文件里完成了下面的每一步。
已完成 0/0保存在当前浏览器中
1. 信令与通话
- 用户加入时,建立到
ws://<host>:<port>/ws的 WebSocket 连接,并发送{"type":"join","roomId":"..."}。用户离开时,先发送{"type":"leave"},再关闭连接。 - 收到
fatal: true的error时,结束通话并显示其中的message(比如Room is full)。 - ICE server 使用
stun:stun.l.google.com:19302。 - 收到
peer joined时,你就是 offer 方。创建 peer connection,添加麦克风 track 和一个sendrecv视频 transceiver(带上摄像头 track,没有摄像头就不带),创建名为"MyApp Channel"的 data channel,然后发送{"type":"offer","sdp":"..."}。 - 如果你是第二个加入的,等待
offer。把它设为 remote description,添加你的 track;如果你还没有摄像头,把 offer 中的recvonly视频 transceiver 改成sendrecv;然后发送{"type":"answer","sdp":"..."}。 - 每个本地 ICE candidate 都以
{"type":"candidate","candidate":{"candidate":"...","sdpMid":"0","sdpMLineIndex":0}}的形式发送。在 remote description 设置好之前,收到的远端 candidate 先放进队列。 - 通话中信令 socket 关闭时,结束通话并告知用户,不要重连。
- 来自已关闭或已被替换的 peer connection 的回调,一律忽略。
2. 摄像头和麦克风
- 两端连接成功后发送一次
{"type":"media state","state":{"audio":true,"video":true,"screen":false}},之后每次状态变化时再发送。 - 关闭摄像头:先发送状态,300 ms 后禁用 track,然后释放摄像头。
- 打开摄像头:先打开摄像头并启用 track,300 ms 后再发送状态。
- 对端的状态为
video: false时,显示占位画面而不是对方的视频。状态为screen: true时,以 fit 方式显示对方的视频,而不是裁剪。 - 静音对方的音频或隐藏对方的视频只在本地生效,不发送任何消息。
3. 共享与切换视频源
- 在同一个视频 sender 上切换摄像头、屏幕和视频文件(
replaceTrack()、setTrack(),或者让不同的采集器向同一个 video source 供帧)。切换时绝不重新协商。 - 永远不替换麦克风 track,这样共享期间静音依然有效。
- 共享屏幕或文件期间,在
media state中设置screen: true。
4. 聊天
- 如果你是 answer 方,从
ondatachannel中拿到通道。 - 每条聊天消息都作为一条普通的 UTF-8 文本消息在通道上发送,发送前去掉首尾空白。收到的文本原样显示。
- 如果 peer connection 还在而通道关闭了,视为对方已挂断。
5. 端到端加密
- 如果开启了 E2EE 且你是 offer 方,生成 32 个随机字节,以
{"type":"encryption key","key":"<base64>"}的形式在 offer 之前发送。 - 收到
encryption key时,设置密钥并回复{"type":"encryption key received"}。 - 使用 webrtc-sdk 或 libwebrtc 时,用
FrameCryptor并配置这些 key provider 选项:共享密钥,salt 为LKFrameEncryptionKey,ratchet window 为 0,不使用 magic bytes,failure tolerance 为 -1,key ring size 为 16,PBKDF2,key index 为 0。 - 没有 FrameCryptor 时,自己实现帧格式:用 PBKDF2-HMAC-SHA256 迭代 100 000 次派生出 AES-128-GCM 密钥,开头是各 codec 不加密的头部(作为附加数据),后面依次是密文和 16 字节的 tag、IV(12 字节)、
0x0C和 key index0。该页的交互演示展示了具体的字节。 - 给每个 sender 挂上加密器,给每个 receiver 挂上解密器,并立即启用。
- 在创建 offer 或 answer 之前,把 VP8 排在视频 codec 首选项的第一位。
- 无法加密或解密的帧一律丢弃,绝不以明文形式发送或解码。
6. 多人通话(可选)
- 连接
ws://<host>:4001/ws上的 SFU,发送{"type":"join","roomId":"...","name":"YourPlatform","e2ee":false}。开启 E2EE 时,设置e2ee: true,并把 32 个随机字节(base64)放在e2eeKey中。 - 收到
joined后,用其中的e2eeKey作为房间密钥,发送你的media state,然后创建 publish 连接:一个sendonly音频 transceiver 和一个sendonly视频 transceiver,VP8 排首位,挂好加密器。以pc: "publish"发送一次 offer,之后永远不再对它重新协商。 - 以
pc: "subscribe"回复每一个offer,按顺序逐个处理。设置好 remote description 之后、回复 answer 之前,给每个 receiver 挂上解密器。 - 发送的每个 candidate 都要带上
pc: "publish"或pc: "subscribe",收到的 candidate 也按pc分发到对应的连接。 - 按 stream ID(
streams[0].id)把每个远端 track 映射到对应的参与者。注意处理被复用的 m-line:重新协商之后,同一个 transceiver 可能承载的是另一个人的 track。 - 根据
joined和participant joined创建小窗,收到participant left时移除。track 可能在参与者信息之前或之后到达。 - 聊天消息以
{"type":"chat","text":"..."}的形式发送。收到的聊天消息带有发送者的participantId和name。 - 大约每 300 ms 从每个远端音频 receiver 的统计信息中读取一次
audioLevel,用来找出当前说话的人。
7. 与现有应用对照测试
- 分别以两种角色呼叫 Web 客户端:一次是你的客户端先进入房间,一次是后加入。
- 关闭再打开摄像头。Web 客户端会显示你的占位画面,恢复时不会出现黑屏或画面卡住。
- 共享屏幕。Web 客户端会以 fit 方式显示。
- 双向发送聊天消息。
- 双方都开启 E2EE:音视频双向正常。然后只在一方开启:另一方看不到也听不到任何内容,这是预期行为。
- 开启 E2EE 呼叫一个 Android 或 iOS 客户端,验证与原生
FrameCryptor的互通。 - 用 Web 客户端和一个原生客户端加入多人通话,分别在开启和关闭 E2EE 的情况下测试。离开后再重新加入。
- 在通话中停掉信令服务器。你的客户端应该结束通话并给出提示。
建议
- 先不做 E2EE 和特效。 一个能和 Web 客户端互通的普通 1:1 通话,是其他所有功能的基础。
- 记录收发的每一条消息。 信令服务器也会记录加入事件。大多数互通 bug 都是少了某个字段,或者消息顺序不对。
- 在多人通话的
join中写上你的平台名(name)。其他应用会把它显示在你的小窗和成员列表中。 - 如果你基于 webrtc-sdk,Android 和 iOS 的代码几乎可以逐行对照,因为它们用的是同一个 SDK。如果直接基于 libwebrtc,可以看看 Windows 的 C shim 头文件,里面列出了一个完整客户端需要的所有原生调用。
所有消息、字段和错误都列在消息中。
