diff --git a/CHANGELOG.md b/CHANGELOG.md index 918218fcb..5add1eb15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -576,6 +576,16 @@ - 修复恢复记录保存失败后仍删除消息的问题,保留写库失败时的恢复信息 - 修复确认框被其他提示替换后操作悬挂,消息编辑器补齐名称与键盘焦点支持 - 修复桌面打包缺少 Remote 网络辅助程序,构建时核对目标平台与包内文件 +- 修复视频通话画面播放二十来秒后永久卡住的问题:帧序号跨 256 帧后不再被误判为「非基础层」丢弃,本端上行也改为 16 位帧序号,两端画面持续更新。 +- 修复视频通话画面卡顿:摄像头帧改走二进制 IPC 通道,不再把每帧展开成 1.2 MB 文本经 JSON 传输,采集与编码不再互相拖慢。 +- 修复视频通话界面读不到对方分辨率的问题,关键帧参数集解析偏移已校正。 +- 视频通话补齐 GCS 视频流状态握手,并停止伪造仅由服务端下发的视频参数推送。 +- 继续对齐视频通话的上行帧格式:对端未发视频时基准字段填 0、收到后改用对端时间基准,分片标记字段按实测分布填写。 +- 修复视频通话媒体退化成全程服务器中继、两端画面都极卡的问题:服务器选路不再选中继端口,对端直连地址识别不再被 IPv4 映射地址混淆。 +- 修复视频通话拿不到对端直连地址、媒体只能全程走服务器中继的问题:GCS 认证改为发送与 QQ 一致的音视频能力集。 +- 继续对齐视频通话信令:开房与邀请请求的字段与 QQ 本体逐字节一致。 +- 修复视频通话对端久等才看到画面、媒体全程走服务器中继的问题:补齐 P2P 端口交换与直连握手,媒体确认直连后不再全程走服务器中继;同时修正中继注册报文的序号与对端字段,服务器重新给予中继应答。 +- 修复私聊位置共享发起报 `param error`:会话键改为按 uin 升序排列两个参与者,两个方向的发起现在都能成功。 - 弹射表情和互动表情直接纳入表情面板,录音与文字转语音合并到输入框内的语音面板 - 优化闪传文件浏览界面,移除独立下载任务列表;一次下载作为一项常规任务,集中查看文件进度和取消下载 - 减少在线凭据的重复获取,修复 QQ 重启后复用旧票据的问题;邮箱明确要求安全验证时暂停后台重试,完成验证后可手动重连 @@ -583,3 +593,6 @@ - 修复已创建克隆体在会话列表和新建克隆群聊中消失的问题,后台创建完成后自动刷新列表 - 好友克隆支持使用最新聊天记录更新画像,保留原有配置、聊天历史、记忆和群聊关系,更新失败不覆盖旧画像 - 补齐主页、存储管理、聊天消息等设置页标题缺失的图标 +- 修复资源浏览页右侧内容区没有撑满窗口、右边留出大片空白的问题,所有资源分类一并生效 +- 修复数据库管理页标题上方留白明显大于同级设置页的问题,标题与内容边距已对齐 +- 修复视频通话拿到服务端下发的对端 P2P 端口后仍打不通直连的问题:权威端口到达时立即补发打洞探测,不再等下一拍而被探测超时清掉。 diff --git a/apps/desktop/src/main/call.ts b/apps/desktop/src/main/call.ts index 043d7900a..2541fda96 100644 --- a/apps/desktop/src/main/call.ts +++ b/apps/desktop/src/main/call.ts @@ -48,34 +48,66 @@ import { const logger = getLogger().child({ scope: 'call' }); +/** + * 补发缓存上限(帧)。 + * + * 实测对端关键帧间隔约 17 秒(15 fps ≈ 255 帧),所以 600 帧足够覆盖一整段。 + * 平均每帧 2 KB 时峰值约 1.2 MB —— 只为「订阅晚到」这一种情况准备,值得。 + * 超出说明码流异常(关键帧间隔极大),整段作废、等下一个关键帧。 + */ +const REPLAY_FRAMES = 600; + // ─────────────────────────── UDP ─────────────────────────── /** `node:dgram` 的 udp4 套接字适配成 `CallSession` 要的形状。 */ class NodeUdpTransport implements UdpTransport { private socket: dgram.Socket | null = null; + private p2pSocket: dgram.Socket | null = null; async bind(): Promise { - return new Promise((resolve, reject) => { - // 双栈套接字:视频通话的 P2P 直连地址是 **IPv6**(服务端下发的 bt=9 - // 里是 16 字节裸 IPv6),udp4 发不出去。`ipv6Only: false` 让它同时 - // 接受 IPv4 目标(Linux 上会自动走 ::ffff: 映射)。 - const socket = dgram.createSocket({ type: 'udp6', reuseAddr: false, ipv6Only: false }); - socket.once('error', reject); - socket.bind(0, () => { - socket.removeListener('error', reject); - this.socket = socket; - resolve(); + const open = (): Promise => + new Promise((resolve, reject) => { + const socket = dgram.createSocket({ type: 'udp6', reuseAddr: false, ipv6Only: false }); + socket.once('error', reject); + socket.bind(0, () => { + socket.removeListener('error', reject); + resolve(socket); + }); }); - }); + try { + const [control, p2p] = await Promise.all([open(), open()]); + this.socket = control; + this.p2pSocket = p2p; + } catch (error) { + this.close(); + throw error; + } + } + + /** + * 绑定后的本地端口。 + * + * `CallSession` 要用它填 `bt=25`(向服务端公告本端 P2P 端口)—— 公告的端口必须 + * 就是真正收发媒体这个 socket 的端口,否则对端打洞打不通。 + */ + localPort(): number { + if (!this.p2pSocket) return 0; + try { + return this.p2pSocket.address().port; + } catch { + // 已关闭 / 未绑定。 + return 0; + } } send(data: Uint8Array, port: number, address: string): void { - if (!this.socket) return; + const socket = [8000, 8001, 8800].includes(port) ? this.socket : this.p2pSocket; + if (!socket) return; // 直接拷一份:dgram 的回调可能在原缓冲被复写之后才跑。 const buffer = Buffer.from(data); // udp6 套接字发纯 IPv4 目标时,部分平台需要显式映射成 ::ffff: 形式。 const target = address.includes(':') ? address : `::ffff:${address}`; - this.socket.send(buffer, 0, buffer.length, port, target, (error) => { + socket.send(buffer, 0, buffer.length, port, target, (error) => { // 发送失败以前是静默吞掉的:地址不可达 / 套接字类型不匹配时 // 上层完全看不出「对方为什么收不到」。这里至少留一条日志。 if (error) { @@ -85,13 +117,17 @@ class NodeUdpTransport implements UdpTransport { } close(): void { - const socket = this.socket; + const sockets = [this.socket, this.p2pSocket].filter( + (socket): socket is dgram.Socket => !!socket, + ); this.socket = null; - if (!socket) return; - try { - socket.close(); - } catch { - // 已关闭 / 未绑定,忽略。 + this.p2pSocket = null; + for (const socket of sockets) { + try { + socket.close(); + } catch { + // 已关闭 / 未绑定,忽略。 + } } } @@ -113,14 +149,22 @@ class NodeUdpTransport implements UdpTransport { */ private pending: Array<(socket: dgram.Socket) => void> = []; private watch(register: (socket: dgram.Socket) => void): void { - if (this.socket) register(this.socket); - else this.pending.push(register); + if (!this.socket) { + this.pending.push(register); + return; + } + register(this.socket); + if (this.p2pSocket) register(this.p2pSocket); } /** 绑定完成后把排队的监听器挂上。 */ attach(): void { - if (!this.socket) return; - for (const register of this.pending) register(this.socket); + const sockets = [this.socket, this.p2pSocket].filter( + (socket): socket is dgram.Socket => !!socket, + ); + for (const register of this.pending) { + for (const socket of sockets) register(socket); + } this.pending = []; } } @@ -195,12 +239,20 @@ export interface StartCallParams { export type CallEvent = | { type: 'state'; call: ActiveCallInfo } | { type: 'audio'; pcm: Uint8Array } - /** 对端一帧视频(Annex B 裸流,只推基础层)。 */ + /** 对端一帧视频(Annex B 裸流)。 */ | { type: 'video'; hevc: Uint8Array; timestamp: number; keyframe: boolean; + /** + * 16 位帧序号 `((fidx - 1) << 8) | gidx`。 + * + * 渲染层要靠它判断「喂给解码器的帧是否连续」—— HEVC 的 P 帧依赖前序参考 + * 帧,跳过若干帧后继续喂 delta 帧会让硬解输出错乱的 YUV(表现为整屏纯绿), + * 且要等到下一个关键帧才恢复。见 useCallVideo 的 continuity 检查。 + */ + frameIndex: number; info: VideoStreamInfo | null; } /** 录音收尾完成(含内部收尾:对端挂断 / 被踢 / 崩溃)。 */ @@ -223,10 +275,19 @@ export class CallManager extends EventEmitter { /** 视频编码器(只有视频通话才非 null)。 */ private videoEncoder: NativeVideoEncoder | null = null; - /** 上一帧是否基础层(用于「基础层优先」的取舍)。 */ - private lastFrameWasBase = false; - /** 最近收到的一个关键帧(新订阅者补发用)。 */ - private lastKeyframe: VideoFramePayload | null = null; + /** + * 从**最近一个关键帧**开始的一段连续帧(新订阅者补发用)。 + * + * ⚠️ **只补关键帧是不够的。** 2026-10-26 实测:渲染层订阅比收帧晚约 1.9 秒, + * 补发关键帧(帧 0)之后紧接着喂实时帧(帧 29),中间 1~28 缺失。HEVC 的 P 帧 + * 依赖前序参考帧,硬解拒绝这些 delta 后内部状态被污染,输出错乱的 YUV —— + * 整屏纯绿,直到下一个关键帧(约 15 秒)才恢复。 + * + * 所以这里缓存「关键帧 + 其后所有帧」的连续片段,订阅时整段补发,渲染层就能 + * 从头连续解码。上限 {@link REPLAY_FRAMES} 帧,超出就丢最旧的(那些帧的参考 + * 链已经断了,补发也没用)。 + */ + private replayBuffer: VideoFramePayload[] = []; /** 当前这通电话的录音设置(发起时取一次,通话中改设置不影响进行中的录音)。 */ private recordingConfig: CallRecordingConfig = { mode: 'mixed', systemAudio: false, dir: null }; private recorder: CallRecorder | null = null; @@ -253,14 +314,16 @@ export class CallManager extends EventEmitter { } /** - * 取最近的关键帧,供**新建立**的渲染层订阅补发。 + * 取一段**从关键帧开始、帧序号连续**的帧,供新建立的渲染层订阅补发。 * * 渲染层是在「对端开始发视频」的状态推送之后才订阅 `onCallVideo` 的,而首个 - * 关键帧通常已经过去。没有关键帧起解,后面的 delta 帧全解不出来 —— 所以 - * 订阅时先补这一帧。 + * 关键帧通常已经过去。**只补关键帧不够**:跳帧会让硬解输出错乱(整屏纯绿), + * 必须把关键帧之后的帧一并补上,让渲染层从头连续解码。详见 `replayBuffer`。 + * + * 返回空数组表示还没有可用的起点(还没收到过关键帧)。 */ - latestKeyframe(): VideoFramePayload | null { - return this.lastKeyframe; + replayFrames(): VideoFramePayload[] { + return this.replayBuffer; } /** 发起一路通话。已有通话在跑时直接报错,由界面提示先挂断。 */ @@ -290,6 +353,8 @@ export class CallManager extends EventEmitter { await transport.bind(); transport.attach(); }, + // 必须透传:会话靠它填 bt=25 的 P2P 端口,漏掉这一行整条 P2P 通路就断了。 + localPort: () => transport.localPort(), send: (data, port, address) => transport.send(data, port, address), close: () => transport.close(), onMessage: (handler) => transport.onMessage(handler), @@ -328,25 +393,21 @@ export class CallManager extends EventEmitter { this.emit('event', { type: 'audio', pcm } satisfies CallEvent); }); session.onVideo((frame) => { - // 只推基础层:增强层依赖基础层,单独解会花屏。 + // **每一帧都推,不要按 fidx 过滤。** // - // 但实测对端会在基础层之外再发 fidx=2 的增强层,而**基础层不是每帧都有**—— - // 一旦对端把画面切成纯增强层(长时间静止后重新编码),全丢就会永久黑屏。 - // 所以:基础层优先,没有基础层时退回增强层(解码器能解多少算多少, - // 花屏好过没画面)。 + // 早先这里按「fidx === 1 才是基础层」丢弃 fidx>=2 的帧,前提是错的:fidx + // 其实是**16 位帧序号的高字节**(每 256 帧 +1),不是 SVC 层号。于是通话 + // 进行到第 256 帧(10 fps 下约 25 秒)之后,`isBaseLayer` 恒为 false, + // 而标记又停在 true 且不再更新 —— 后续每一帧都被静默丢掉,画面永久冻结。 + // 真机日志里 `g=249/f=1 → g=43/f=2` 的序号连续性就是证据。 if (!frame.hevc) return; - // 增强层只在没有基础层可用时才推 —— 见上面的注释。 - if (!frame.isBaseLayer && this.lastFrameWasBase) return; - this.lastFrameWasBase = frame.isBaseLayer; - // 缓存最近一个关键帧:渲染层的解码订阅是在「对端开始发视频」的状态推送 - // **之后**才建立的,而首个关键帧往往已经过去 —— 后面的 delta 帧没有 - // 关键帧起解,画面永远出不来。新订阅先用这份补一帧。 - if (frame.isKeyframe) this.lastKeyframe = frame; + this.rememberForReplay(frame); this.emit('event', { type: 'video', hevc: frame.hevc, timestamp: frame.timestamp, keyframe: frame.isKeyframe, + frameIndex: frame.frameIndex, info: frame.info, } satisfies CallEvent); }); @@ -643,12 +704,43 @@ export class CallManager extends EventEmitter { this.transport?.close(); this.videoEncoder?.close(); this.videoEncoder = null; - this.lastKeyframe = null; + this.replayBuffer = []; this.session = null; this.transport = null; this.info = null; } + /** + * 维护「从关键帧开始的一段连续帧」缓存,供新订阅补发。 + * + * 见 {@link replayBuffer} 的注释:只补关键帧会让渲染层跳帧喂入,硬解输出 + * 错乱(整屏纯绿)。所以: + * + * - 遇到关键帧 → 清空重来(这是新的参考链起点); + * - 帧序号不连续 → 同样清空(参考链已断,之前的帧补发也没用); + * - 其余帧追加,超过上限丢最旧的。 + */ + private rememberForReplay(frame: VideoFramePayload): void { + const last = this.replayBuffer[this.replayBuffer.length - 1]; + if (frame.isKeyframe) { + this.replayBuffer = [frame]; + return; + } + if (!last) return; // 还没见过关键帧,先不缓存 + // 16 位序号会回绕,按模判断是否正好 +1。 + const continuous = (frame.frameIndex - last.frameIndex + 65536) % 65536 === 1; + if (!continuous) { + this.replayBuffer = []; + return; + } + this.replayBuffer.push(frame); + // 超出上限时丢**最旧的 delta**、保留关键帧?不行 —— 关键帧之后的帧必须 + // 连续才有意义。这里改为整段作废等下一个关键帧:对端关键帧间隔实测约 + // 17 秒,缓存峰值 255 帧 × 平均 2 KB ≈ 500 KB,完全可以一直留着。 + // 上限只是防止异常码流(关键帧间隔极大)把内存吃爆。 + if (this.replayBuffer.length > REPLAY_FRAMES) this.replayBuffer = []; + } + /** * `AVSDKPlugin` 和 `wrapper.node` 同在 QQ 的 `resources/app` 下; * linux 的插件在它下面的 `avsdk/` 子目录,`nt_helper` 会自己找。 diff --git a/apps/desktop/src/main/call_video_encoder.ts b/apps/desktop/src/main/call_video_encoder.ts index 57c52c9b1..59c7b10b9 100644 --- a/apps/desktop/src/main/call_video_encoder.ts +++ b/apps/desktop/src/main/call_video_encoder.ts @@ -68,7 +68,9 @@ export class NativeVideoEncoder implements CallVideoEncoder { this.width = options.width ?? 640; this.height = options.height ?? 360; this.fps = options.fps ?? 15; - this.bitrateKbps = options.bitrateKbps ?? 400; + // 30 kbps:与 QQ 本体对齐(实测静态 6 kbps、运动峰值 26 kbps)。 + // 旧的 400 kbps 会让每帧涨到 3KB+、拆成 3~28 个 UDP 包,丢一片整帧报废。 + this.bitrateKbps = options.bitrateKbps ?? 30; } private readonly fps: number; @@ -150,12 +152,21 @@ export class NativeVideoEncoder implements CallVideoEncoder { ); // 编码器还在 lookahead 里攒帧时是 null,下一拍再取。 if (!result) return null; - this.dumpAnnexB(new Uint8Array(result.data)); - return new Uint8Array(result.data); + const out = new Uint8Array(result.data); + this.dumpAnnexB(out); + return out; } info(): VideoStreamInfo { - return { width: this.width, height: this.height, profile: 1, codec: 'hevc' }; + return { + width: this.width, + height: this.height, + profile: 1, + codec: 'hevc', + // `bt=77` 视频参数公告要用:对端按这里声明的规格准备解码。 + fps: this.fps, + bitrateKbps: this.bitrateKbps, + }; } close(): void { diff --git a/apps/desktop/src/main/call_video_ipc.ts b/apps/desktop/src/main/call_video_ipc.ts new file mode 100644 index 000000000..608c5701d --- /dev/null +++ b/apps/desktop/src/main/call_video_ipc.ts @@ -0,0 +1,46 @@ +/** + * 通话视频帧的二进制 IPC 接收端(主进程)。 + * + * 与 `shared/call_video_ipc.ts` 配对:渲染层用 `ipcRenderer.send` 把 I420 平面 + * 直接推过来(结构化克隆,不经 JSON),这里转交给当前通话的编码器。 + * + * 为什么不用 tRPC:见 `shared/call_video_ipc.ts` 的说明 —— 一帧 345 KB 的 + * `Uint8Array` 经 superjson 会变成 1.2 MB 的十进制文本,单帧开销 30 ms, + * 15 fps 下渲染层主线程被吃满,两端画面都卡。 + * + * 只注册一次;没有进行中的通话时直接丢弃(视频帧不需要排队,过期的画面没有意义)。 + */ + +import { ipcMain } from 'electron'; +import { getLogger } from '@weq/service'; +import { CALL_VIDEO_FRAME_CHANNEL, type CallVideoFrame } from '../shared/call_video_ipc'; +import { findCallManager } from './call_registry'; + +const logger = getLogger().child({ scope: 'call-video-ipc' }); + +/** 尺寸校验用:一帧 I420 的 Y 平面必须恰好 `w*h`。 */ +function asBytes(value: unknown): Uint8Array | null { + if (value instanceof Uint8Array) return value; + if (ArrayBuffer.isView(value)) { + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + } + if (value instanceof ArrayBuffer) return new Uint8Array(value); + return null; +} + +export function registerCallVideoIpc(): void { + ipcMain.on(CALL_VIDEO_FRAME_CHANNEL, (_event, raw: unknown) => { + if (!raw || typeof raw !== 'object') return; + const frame = raw as Partial; + const y = asBytes(frame.y); + const u = asBytes(frame.u); + const v = asBytes(frame.v); + if (!y || !u || !v) return; + + const manager = findCallManager(); + if (!manager) return; + // 主进程侧还会再校验一次尺寸(见 NativeVideoEncoder.pushFrame)。 + manager.pushVideoFrame(y, u, v); + }); + logger.debug('通话视频帧 IPC 已注册'); +} diff --git a/apps/desktop/src/main/context/app_context.ts b/apps/desktop/src/main/context/app_context.ts index 245325d04..111d8900d 100644 --- a/apps/desktop/src/main/context/app_context.ts +++ b/apps/desktop/src/main/context/app_context.ts @@ -142,6 +142,7 @@ import { ChatImageOcrService, InteractionService, GroupModerationService, + LocationShareService, AccountWriteService, DbWatchService, checkAccountDatabaseHealth, @@ -856,6 +857,8 @@ export interface AccountServices { interaction: InteractionService; /** 会话治理:撤回 + 群管理(改群名片 / 踢人 / 禁言 / 设撤管理员,需在线 QQ 发包)。 */ groupModeration: GroupModerationService; + /** 位置共享(QQLBSShareSvc 发起 / 上报 / 查询,需在线 QQ 发包)。 */ + locationShare: LocationShareService; accountWrite: AccountWriteService; /** 闪传浏览 / 下载(匿名 HTTP2RPC,不需 QQ 在线)。 */ flashTransferFiles: FlashTransferFilesService; @@ -1969,6 +1972,7 @@ export function initAppContext(fileRoot?: string): AppContext { ), interaction: new InteractionService(platform.native.ntHelper, session, resolveOnlinePid), groupModeration: new GroupModerationService(platform.native.ntHelper, resolveOnlinePid), + locationShare: new LocationShareService(platform.native.ntHelper, resolveOnlinePid), accountWrite: new AccountWriteService( platform.native.ntHelper, { @@ -2713,6 +2717,7 @@ export function initAppContext(fileRoot?: string): AppContext { ), interaction: new InteractionService(platform.native.ntHelper, session, livePid), groupModeration: new GroupModerationService(platform.native.ntHelper, livePid), + locationShare: new LocationShareService(platform.native.ntHelper, livePid), accountWrite: new AccountWriteService( platform.native.ntHelper, { diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 4df4bbfcf..4b6dd7f71 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -79,6 +79,7 @@ import { createRendererRecovery } from './renderer_recovery'; import { startRendererDiagnostics } from './renderer_diagnostics_runtime'; import { macApplicationMenu, nativeMenuLabels } from './application_menu'; import { registerEditUndoIpc, undoFocusedContent, redoFocusedContent } from './edit_undo'; +import { registerCallVideoIpc } from './call_video_ipc'; import { registerAttachHintIpc } from './attach_hint_ipc'; import { registerElevationIpc } from './elevation_ipc'; import { registerLongTermAuthorizationIpc } from './authorization/ipc'; @@ -426,6 +427,32 @@ function registerLogIpc(): void { await electronHost.revealPath(dir); return true; }); + + /** + * 渲染层 → 主进程日志文件(`weq.debugLog`)。 + * + * 通话视频的解码全在渲染层,而渲染层的 `console.*` **不会**进日志文件 —— + * 「等待对方画面」这类问题因此一直只能盲猜。这个通道只接受结构化字段, + * 走同一个 `getLogger()`,落进同一天的日志里,便于和主进程的 `scope=call` + * 行按时间对齐。 + * + * 刻意**不**转发 `console` 全量:主进程 `console-message` 会把整个渲染层的 + * 噪声(React 警告、第三方库日志)都灌进文件。调用方显式调用才记。 + */ + ipcMain.on('logs:renderer', (_event, payload: unknown) => { + if (!payload || typeof payload !== 'object') return; + const record = payload as { level?: unknown; message?: unknown; context?: unknown }; + if (typeof record.message !== 'string' || record.message.length > 2000) return; + const level = + record.level === 'warn' || record.level === 'error' || record.level === 'debug' + ? record.level + : 'info'; + const context = + record.context && typeof record.context === 'object' + ? (record.context as Record) + : {}; + logger[level](record.message, { scope: 'renderer', ...context }); + }); } function registerSystemAuthIpc(): void { @@ -769,6 +796,7 @@ void app.whenReady().then(async () => { registerElevationIpc(); registerLongTermAuthorizationIpc(); registerEditUndoIpc(); + registerCallVideoIpc(); app.on('browser-window-created', (_, win) => { optimizer.watchWindowShortcuts(win); diff --git a/apps/desktop/src/main/ipc/routers/account.ts b/apps/desktop/src/main/ipc/routers/account.ts index 21c69f8d6..78af1820a 100644 --- a/apps/desktop/src/main/ipc/routers/account.ts +++ b/apps/desktop/src/main/ipc/routers/account.ts @@ -62,7 +62,7 @@ import type { SalvageLedgerEntry } from '@weq/db'; import { databasePerformance } from '@weq/db'; import { classifyChatType, ProtoMsg } from '@weq/codec'; import { WalletFlag48417Wire } from '@weq/codec/proto/msg/element'; -import { detectThumbType, type OcrBlock } from '@weq/protocol'; +import { buildShareLocationElements, detectThumbType, type OcrBlock } from '@weq/protocol'; /** 48417 的嵌套块解码器(红包定位:orderId + packetId)。 */ const walletFlag48417Wire = new ProtoMsg(WalletFlag48417Wire); @@ -5738,6 +5738,121 @@ export const accountRouter = router({ }), ), + // ---- 位置共享(QQLBSShareSvc) ---- + + /** + * 发起一次位置共享(私聊 / 群聊同一套命令,只有会话键不同)。 + * + * 房间**不由服务端分配** —— 私聊的房间就是「这两个人」,群聊的就是「这个群」。 + * 发起后必须按 {@link locationShareReport} 的响应间隔持续上报,否则约 60 秒后 + * 服务端会把房间标记为已结束。没有「退出共享」命令:停上报即可。 + */ + locationShareStart: procedure + .input( + z.object({ + scene: z.enum(['c2c', 'group']), + /** 私聊:对端 QQ 号。群聊:群号。 */ + peer: z.number().int().safe().positive(), + /** 自己的 QQ 号(私聊会话键要用;上报也需要)。 */ + selfUin: z.number().int().safe().positive(), + longitude: z.number(), + latitude: z.number(), + interval: z.number().int().min(1).max(60).optional(), + }), + ) + .mutation(async ({ input }) => { + requireQqOnlineForAlbum(); + return requireServices().locationShare.start({ + scene: input.scene, + peer: input.peer, + selfUin: input.selfUin, + longitude: input.longitude, + latitude: input.latitude, + ...(input.interval !== undefined ? { interval: input.interval } : {}), + }); + }), + + /** 上报一次自己的坐标;响应里的 `interval` 是服务端要求的下次间隔(秒)。 */ + locationShareReport: procedure + .input( + z.object({ + scene: z.enum(['c2c', 'group']), + peer: z.number().int().safe().positive(), + selfUin: z.number().int().safe().positive(), + longitude: z.number(), + latitude: z.number(), + extra: z.number().optional(), + }), + ) + .mutation(async ({ input }) => { + requireQqOnlineForAlbum(); + return requireServices().locationShare.report({ + scene: input.scene, + peer: input.peer, + selfUin: input.selfUin, + longitude: input.longitude, + latitude: input.latitude, + ...(input.extra !== undefined ? { extra: input.extra } : {}), + }); + }), + + /** + * 查询房间里所有成员的坐标 —— **唯一**能拿到别人位置的途径(位置不会推送)。 + * + * `status === 2` 表示房间已结束(停上报超时),此时 `members` 为空。 + */ + locationShareQuery: procedure + .input( + z.object({ + scene: z.enum(['c2c', 'group']), + peer: z.number().int().safe().positive(), + selfUin: z.number().int().safe().positive(), + mode: z.union([z.literal(0), z.literal(1)]).optional(), + }), + ) + .query(async ({ input }) => { + requireQqOnlineForAlbum(); + return requireServices().locationShare.query({ + scene: input.scene, + peer: input.peer, + selfUin: input.selfUin, + ...(input.mode !== undefined ? { mode: input.mode } : {}), + }); + }), + + /** + * 发「发起了位置共享」消息卡片。 + * + * 真实客户端由服务端推送这条消息;WeQ 的 SSO 是「发一包就断」,服务端判定发起方 + * 不在线就不投递 —— 所以自己发。 + * + * **元素必须在主进程构造**:`buildShareLocationElements` 在 `@weq/protocol` 里, + * 而渲染层不能引那个包(远程 Web 端复用渲染层代码,protocol 里有 `node:crypto` + * 的红包签名,浏览器端打不出来)。所以这里收「场景 + 目标」,元素自己拼。 + */ + sendLocationShareCard: procedure + .input( + z.object({ + expectedAccountUin: z.string().min(1).optional(), + expectedAccountConfigId: z.string().min(1).optional(), + scene: z.enum(['c2c', 'group']), + /** 私聊:对端 QQ 号。群聊:群号。 */ + targetId: z.string().min(1), + }), + ) + .mutation(async ({ input }) => { + const services = requireExpectedAccount( + input.expectedAccountUin, + input.expectedAccountConfigId, + ); + requireQqOnlineForAlbum(services); + return services.messageSend.sendElements({ + peerType: input.scene === 'group' ? 'group' : 'c2c', + targetId: input.targetId, + elements: buildShareLocationElements(input.scene) as never, + }); + }), + // ---- database decrypt ---- /** List encrypted `*.db` files under the open account's nt_db directory. */ diff --git a/apps/desktop/src/main/ipc/routers/call.ts b/apps/desktop/src/main/ipc/routers/call.ts index 29c2edd0d..25bd4ee10 100644 --- a/apps/desktop/src/main/ipc/routers/call.ts +++ b/apps/desktop/src/main/ipc/routers/call.ts @@ -179,27 +179,40 @@ export const callRouter = router({ hevc: Uint8Array; timestamp: number; keyframe: boolean; + /** 16 位帧序号;渲染层据此判断喂入是否连续(见 useCallVideo)。 */ + frameIndex: number; info: VideoStreamInfo | null; }>((emit) => { const manager = findCallManager(); if (!manager) return () => {}; - // 先补最近的关键帧:订阅是在「对端开始发视频」之后才建立的,首个关键帧 - // 往往已经过去,没有它解码器起不来(后续 delta 帧会全被丢弃)。 - const kf = manager.latestKeyframe(); - if (kf?.hevc) { + // 补发「关键帧 + 其后的连续帧」:订阅是在「对端开始发视频」之后才建立的, + // 首个关键帧往往已经过去。 + // + // ⚠️ **不能只补关键帧。** 只补关键帧的话,渲染层喂完它之后紧接着收到的是 + // 实时帧,中间缺一大段;HEVC 的 P 帧依赖前序参考帧,硬解会输出错乱的 YUV + // (整屏纯绿,约 15 秒后才被下一个关键帧纠正)。整段补发让渲染层从头连续 + // 解码,代价是订阅瞬间多传几十帧。 + const replay = manager.replayFrames(); + for (const frame of replay) { + if (!frame.hevc) continue; emit.next({ - hevc: kf.hevc, - timestamp: kf.timestamp, - keyframe: true, - info: kf.info, + hevc: frame.hevc, + timestamp: frame.timestamp, + keyframe: frame.isKeyframe, + frameIndex: frame.frameIndex, + info: frame.info, }); } + // 补发期间实时帧可能已经往前走了,两者会重叠(补发到帧 N,实时流已从帧 M + // 开始)。重叠与跳帧由渲染层的 `acceptsFrame` 统一处理:连续的照常解, + // 不连续的就丢弃等关键帧 —— 这里不做额外过滤,避免两处逻辑各自为政。 const handler = (event: CallEvent) => { if (event.type !== 'video') return; emit.next({ hevc: event.hevc, timestamp: event.timestamp, keyframe: event.keyframe, + frameIndex: event.frameIndex, info: event.info, }); }; diff --git a/apps/desktop/src/preload/index.d.ts b/apps/desktop/src/preload/index.d.ts index d4fe45e9e..9082910f8 100644 --- a/apps/desktop/src/preload/index.d.ts +++ b/apps/desktop/src/preload/index.d.ts @@ -58,6 +58,8 @@ declare global { capture: { window(): Promise<{ ok: boolean; error?: string }>; }; + /** 通话视频上行(二进制 IPC,不经 superjson)。 */ + callVideo: import('../shared/call_video_ipc').CallVideoBridge; analyticsShot: { /** 主进程在隐藏窗口里渲染同一张卡片、抓图、弹保存框(用户窗口全程不动)。 */ render(payload: AnalyticsExportPayload): Promise; diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 62869295e..f07c8e47b 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -16,6 +16,7 @@ import { createRequire } from 'node:module'; import type { AnalyticsExportPayload, AnalyticsExportResult } from '../shared/analytics_export'; import { installRendererDiagnosticSampling } from './renderer_diagnostics'; import type { EditMenuState } from '../shared/edit_menu'; +import { CALL_VIDEO_FRAME_CHANNEL, type CallVideoFrame } from '../shared/call_video_ipc'; import type { LongTermAuthorizationBridge } from '../shared/long_term_authorization'; // Same CJS workaround as src/main/index.ts — electron-trpc 0.7's ESM @@ -67,6 +68,19 @@ const weqBridge = { }, }, openLogDir: (): Promise => ipcRenderer.invoke('logs:open-dir') as Promise, + /** + * 往主进程日志文件里写一条结构化记录(fire-and-forget)。 + * + * 渲染层的 `console.*` 不会落盘,通话视频解码这类纯渲染层的链路出了问题就 + * 完全看不见。调用方显式调用才记,别拿来替代 `console`。 + */ + debugLog: (level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: object) => { + try { + ipcRenderer.send('logs:renderer', { level, message, context }); + } catch { + // 日志永远不能影响业务流程。 + } + }, /** * 用户选 / 拖 / 粘贴进来的文件的**本机绝对路径**(Electron 32+ 的 `webUtils`)。 * @@ -178,6 +192,18 @@ const weqBridge = { claimPayload: () => ipcRenderer.invoke('analytics-shot:claim') as Promise, }, + callVideo: { + /** + * 推一帧 I420(通话视频上行)。 + * + * 走原生 IPC 而不是 tRPC:`Uint8Array` 在 `ipcRenderer.send` 里直接结构化 + * 克隆(0.17 ms),经 tRPC + superjson 会被展开成十进制数组(一帧 1.2 MB + * 文本、30 ms)。视频帧本来就该 fire-and-forget,不等回执。 + */ + pushFrame(frame: CallVideoFrame): void { + ipcRenderer.send(CALL_VIDEO_FRAME_CHANNEL, frame); + }, + }, }; if (process.contextIsolated) { diff --git a/apps/desktop/src/renderer/src/env.d.ts b/apps/desktop/src/renderer/src/env.d.ts index a33ea097a..cedd3172f 100644 --- a/apps/desktop/src/renderer/src/env.d.ts +++ b/apps/desktop/src/renderer/src/env.d.ts @@ -6,6 +6,8 @@ interface Window { authorization?: import('../../shared/long_term_authorization').LongTermAuthorizationBridge; editUndo?: import('../../shared/edit_menu').EditUndoBridge; openLogDir(): Promise; + /** 往主进程日志文件写一条结构化记录;web 构建下为 undefined。 */ + debugLog?(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: object): void; /** 用户选 / 拖 / 粘贴文件的绝对路径(Electron webUtils);拿不到返回 ''。 */ pathForFile?(file: File): string; channel: { @@ -54,6 +56,13 @@ interface Window { capture: { window(): Promise<{ ok: boolean; error?: string }>; }; + /** + * 通话视频上行(二进制 IPC)。 + * + * 只用于 Electron;web 构建下为 undefined,调用方要回退到 tRPC 的 + * `pushVideoFrame`。 + */ + callVideo?: import('../../shared/call_video_ipc').CallVideoBridge; analyticsShot: { /** 主进程在隐藏窗口里渲染同一张卡片、抓成长图、弹保存框(用户窗口全程不动)。 */ render( diff --git a/apps/desktop/src/renderer/src/hooks/useCallCamera.ts b/apps/desktop/src/renderer/src/hooks/useCallCamera.ts index 39f639c22..01018ac5d 100644 --- a/apps/desktop/src/renderer/src/hooks/useCallCamera.ts +++ b/apps/desktop/src/renderer/src/hooks/useCallCamera.ts @@ -156,6 +156,15 @@ export function useCallCamera(options: { enabled: boolean }): CallCameraState { const rgba = ctx.getImageData(0, 0, ENCODE_WIDTH, ENCODE_HEIGHT).data; const { y, u, v } = rgbaToI420(rgba, ENCODE_WIDTH, ENCODE_HEIGHT); pushCount.current++; + // Electron 走二进制 IPC(结构化克隆,一帧 ~0.2 ms);tRPC + superjson 会把 + // 345 KB 的 Uint8Array 展开成 1.2 MB 十进制文本(实测 30 ms/帧),15 fps + // 下渲染层主线程被吃满 —— 采集、解码、绘制全都跟着卡。 + // web 构建(apps/web 复用同一份 renderer)没有这个桥,回退到 tRPC。 + const bridge = window.weq?.callVideo; + if (bridge) { + bridge.pushFrame({ y, u, v }); + return; + } // 只在首批帧失败时报错:持续失败会刷屏,但完全静默又没法排查。 pushRef.current.mutate( { y, u, v }, diff --git a/apps/desktop/src/renderer/src/hooks/useCallVideo.ts b/apps/desktop/src/renderer/src/hooks/useCallVideo.ts index 37206fa36..9aaa608d2 100644 --- a/apps/desktop/src/renderer/src/hooks/useCallVideo.ts +++ b/apps/desktop/src/renderer/src/hooks/useCallVideo.ts @@ -28,7 +28,7 @@ */ import { useCallback, useEffect, useRef, useState } from 'react'; -import { payloadForDecoder, toAccessUnit } from '../lib/callVideo'; +import { acceptsFrame, payloadForDecoder, toAccessUnit } from '../lib/callVideo'; import { trpc } from '../trpc/client'; /** HEVC 的 codec string:Main profile、level 1.5(实测 640×360@10fps 用这档)。 */ @@ -51,6 +51,31 @@ const HEVC_CODEC_CANDIDATES = [ 'hev1.1.6.L93.B0', ] as const; +/** + * 把渲染层的解码**错误与关键状态**写进主进程日志文件。 + * + * 通话视频的解码全在这一层,而渲染层的 `console.*` 不落盘 —— 解码失败时用户 + * 只看到「等待对方画面」,日志里却什么都没有。`weq.debugLog` 由 preload 提供; + * web 构建没有这个桥,退回 `console`。 + * + * 只记错误、断帧和首帧里程碑,不逐帧记录(一通通话上千帧,会淹没日志)。 + */ +function trace( + level: 'debug' | 'info' | 'warn' | 'error', + message: string, + context?: Record, +): void { + const bridge = window.weq?.debugLog; + if (bridge) { + bridge(level, message, { scope: 'call-video', ...context }); + return; + } + const line = `[call-video] ${message}`; + if (level === 'error') console.error(line, context); + else if (level === 'warn') console.warn(line, context); + else console.info(line, context); +} + export interface CallVideoState { /** 解码器可用。false 时界面应提示「当前环境不支持视频解码」。 */ supported: boolean; @@ -103,8 +128,25 @@ export function useCallVideo(options: { const needsParamSets = useRef(false); /** 丢帧计数:连续解不动时重建解码器。 */ const decodeFailures = useRef(0); - /** 收到的帧数(只用于前几帧的排查日志)。 */ - const frameCount = useRef(0); + /** 解出的帧数(只用于第一帧的里程碑日志)。 */ + const decodedFrames = useRef(0); + /** + * 上一个喂进解码器的帧序号。 + * + * HEVC 的 P 帧依赖前序参考帧:跳帧后继续喂 delta 会让硬解输出错乱(整屏纯绿), + * 要等到下一个关键帧才恢复。见 onData 里的连续性检查。 + */ + const lastFrameIndex = useRef(null); + /** 已经因为断帧而进入「等关键帧」状态(只用于日志去重)。 */ + const awaitingKeyframe = useRef(false); + /** + * 是否正在异步建解码器。 + * + * `ensureDecoder` 里有 `await VideoDecoder.isConfigSupported`,期间后续帧的 + * `onData` 会并发进来并重复触发重建 —— 第二次重建会 `close()` 掉第一次刚建 + * 好的解码器,导致「只解出第一帧」。见 `onData` 里的详细说明。 + */ + const buildingDecoder = useRef(false); /** 建 / 重建解码器。 */ const ensureDecoder = useCallback( @@ -142,7 +184,7 @@ export function useCallVideo(options: { } if (!config) { // 把试过的候选连同报错一起抛给界面:只说「不支持」没法排查。 - console.error('[call-video] 所有 HEVC 候选都不支持', tried, { width, height }); + trace('error', '所有 HEVC 候选都不支持', { tried, width, height }); setState((prev) => ({ ...prev, supported: false, @@ -150,30 +192,35 @@ export function useCallVideo(options: { })); return null; } - decoderRef.current?.close(); const decoder = new VideoDecoder({ output: (frame) => { + // ⚠️ **尺寸要在 `frame.close()` 之前读。** close() 会把 VideoFrame 的 + // 所有属性重置为 0 —— 在它之后读 `displayWidth` 拿到的是 0。 + const width = frame.displayWidth; + const height = frame.displayHeight; const canvas = canvasRef.current; if (canvas) { // 按帧尺寸调整画布,避免拉伸。 - if (canvas.width !== frame.displayWidth || canvas.height !== frame.displayHeight) { - canvas.width = frame.displayWidth; - canvas.height = frame.displayHeight; + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; } const ctx = canvas.getContext('2d'); ctx?.drawImage(frame, 0, 0); } frame.close(); decodeFailures.current = 0; - setState((prev) => - prev.active - ? prev - : { ...prev, active: true, width: frame.displayWidth, height: frame.displayHeight }, - ); + decodedFrames.current++; + // 第一帧解出来是整条链路唯一的关键里程碑(有它才说明「等待对方画面」 + // 会消失),只记这一次,不逐帧刷屏。 + if (decodedFrames.current === 1) { + trace('info', '解出画面', { width, height }); + } + setState((prev) => (prev.active ? prev : { ...prev, active: true, width, height })); }, error: (error) => { - console.error('[call-video] 解码器错误', error); + trace('error', '解码器错误', { error: String(error) }); setState((prev) => ({ ...prev, error: String(error) })); }, }); @@ -192,6 +239,8 @@ export function useCallVideo(options: { configured.current = false; paramSets.current = []; needsParamSets.current = false; + lastFrameIndex.current = null; + awaitingKeyframe.current = false; setState({ supported: true, active: false, width: 0, height: 0, error: null }); return; } @@ -200,7 +249,6 @@ export function useCallVideo(options: { trpc.call.onCallVideo.useSubscription(undefined, { enabled, onData: (frame) => { - frameCount.current++; void (async () => { // 先拆 NAL 再判断关键帧。 // @@ -214,11 +262,56 @@ export function useCallVideo(options: { if (au.params.length > 0) paramSets.current = au.params; if (!configured.current && !au.keyframe) return; + // ⚠️ **连续性检查:关键帧之后,帧序号必须连续。** + // + // HEVC 的 P 帧依赖前序参考帧。跳帧后继续喂 delta,Chromium 硬解会拒绝 + // 这些「无参考帧」的帧,但**内部状态被污染**,继续输出错乱的 YUV —— + // 2026-10-26 实测整屏纯绿(RGB `[0,88,0]`,R/B 恒为 0),直到下一个 + // 关键帧(约 15 秒后)才重新同步。 + // + // 主进程侧已经改为「整段补发连续帧」(见 `CallManager.replayBuffer`), + // 正常情况下这里不会触发。它保留作为**安全网**:网络丢包、补发与实时流 + // 的衔接缝隙、对端中途重启编码,都可能造成序号断裂。 + // + // 触发时丢弃本帧并等下一个关键帧 —— 会短暂冻画面,但好过整屏绿。 + const previousIndex = lastFrameIndex.current; + if (!acceptsFrame(previousIndex, frame.frameIndex, au.keyframe)) { + if (!awaitingKeyframe.current) { + awaitingKeyframe.current = true; + trace('warn', '帧序号不连续,丢弃直到下一个关键帧', { + previous: previousIndex, + current: frame.frameIndex, + gap: ((frame.frameIndex - (previousIndex ?? 0) + 65536) % 65536) >>> 0, + }); + } + return; + } + if (au.keyframe) awaitingKeyframe.current = false; + lastFrameIndex.current = frame.frameIndex; + const existing = decoderRef.current; const rebuilt = !existing || existing.state === 'closed'; let decoder = existing; if (rebuilt) { - decoder = await ensureDecoder(frame.info?.width ?? 640, frame.info?.height ?? 360); + // ⚠️ **必须互斥。** `onData` 是同步回调,但这里是 async 函数 —— 帧 N + // 在 `await ensureDecoder()` 期间,帧 N+1 的 `onData` 已经进来了, + // 它看到的 `decoderRef.current` 仍是 null,于是也走 `rebuilt` 分支, + // 再次 `await ensureDecoder()`。而 `ensureDecoder` 第一件事就是 + // `decoderRef.current?.close()` —— **它会把帧 N 刚建好的解码器关掉**。 + // + // 结果:帧 N 的 `decode()` 落到一个已关闭的解码器上(或被丢弃), + // 帧 N+1 建的新解码器又没收到关键帧(`configured=false` + 后续全是 + // delta 帧),此后每一帧都在 `if (!configured.current) return` 处被 + // 丢掉 —— 表现就是「解出第一帧,画面永久停住」。 + // + // 用一个在建标记挡住并发重建:谁先拿到谁建,其余帧直接返回等下一帧。 + if (buildingDecoder.current) return; + buildingDecoder.current = true; + try { + decoder = await ensureDecoder(frame.info?.width ?? 640, frame.info?.height ?? 360); + } finally { + buildingDecoder.current = false; + } if (!decoder) return; configured.current = false; // 一直挂到真的喂出一帧为止 —— 重建点落在 delta 帧上时,那帧会被下面 @@ -246,6 +339,10 @@ export function useCallVideo(options: { ); } catch (error) { decodeFailures.current++; + trace('warn', 'decode() 抛错', { + failures: decodeFailures.current, + error: String(error), + }); setState((prev) => ({ ...prev, error: String(error) })); if (decodeFailures.current > 3) { decoder.close(); @@ -256,7 +353,10 @@ export function useCallVideo(options: { } })(); }, - onError: (error) => setState((prev) => ({ ...prev, error: String(error) })), + onError: (error) => { + trace('error', '视频订阅出错', { error: String(error) }); + setState((prev) => ({ ...prev, error: String(error) })); + }, }); useEffect(() => { diff --git a/apps/desktop/src/renderer/src/im-template/styles.css b/apps/desktop/src/renderer/src/im-template/styles.css index e047a672c..3f1050f33 100644 --- a/apps/desktop/src/renderer/src/im-template/styles.css +++ b/apps/desktop/src/renderer/src/im-template/styles.css @@ -10,3 +10,4 @@ @import './styles/composer-media.css'; @import './styles/ark-panel.css'; @import './styles/flash-composer.css'; +@import './styles/location-share.css'; diff --git a/apps/desktop/src/renderer/src/im-template/styles/location-share.css b/apps/desktop/src/renderer/src/im-template/styles/location-share.css new file mode 100644 index 000000000..989927515 --- /dev/null +++ b/apps/desktop/src/renderer/src/im-template/styles/location-share.css @@ -0,0 +1,574 @@ +/** + * 位置共享面板(发消息框内的浮层)。 + * + * 形态与其余 composer 面板一致:贴在输入区上沿、左对齐、绝对定位;移动端改成 + * 内联(grid-row 3)。配色全部走主题变量,深色用 `html[data-theme='dark']` 覆盖。 + */ + +.lbs-panel { + position: absolute; + bottom: calc(100% - 1px); + left: 12px; + z-index: 30; + display: grid; + width: min(460px, calc(100% - 28px)); + max-height: min(560px, calc(100dvh - 180px)); + grid-template-rows: auto minmax(0, 1fr); + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--im-color-line, #dedede) 82%, transparent); + border-radius: 12px; + background: var(--popover, #ffffff); + box-shadow: 0 14px 40px rgba(0, 0, 0, 0.16); + animation: lbs-in 150ms ease-out; +} + +@keyframes lbs-in { + from { + opacity: 0; + transform: translateY(6px); + } +} + +.lbs-panel.is-active { + grid-template-rows: auto minmax(0, 1fr) auto auto; +} + +/* ── 头部 ─────────────────────────────────────────────────────────────── */ + +.lbs-header { + display: flex; + align-items: center; + gap: 8px; + padding: 9px 10px 9px 8px; + border-bottom: 1px solid color-mix(in srgb, var(--im-color-line, #dedede) 74%, transparent); +} + +.lbs-back { + display: grid; + width: 26px; + height: 26px; + flex: none; + place-items: center; + padding: 0; + border: 0; + border-radius: 7px; + background: transparent; + color: var(--weq-fg-secondary, #666666); + cursor: pointer; +} + +.lbs-back:hover { + background: color-mix(in srgb, var(--weq-fg-primary, #111111) 7%, transparent); + color: var(--weq-fg-primary, #111111); +} + +.lbs-title { + display: flex; + min-width: 0; + align-items: center; + gap: 6px; + color: var(--weq-fg-primary, #111111); +} + +.lbs-title strong { + font-size: 13px; + font-weight: 600; +} + +.lbs-subtitle { + overflow: hidden; + min-width: 0; + color: var(--weq-fg-muted, #858585); + font-size: 11.5px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lbs-count { + display: inline-flex; + align-items: center; + gap: 3px; + margin-left: auto; + padding: 2px 7px; + border-radius: 999px; + background: color-mix(in srgb, var(--weq-accent-effective, #0099ff) 12%, transparent); + color: var(--weq-accent-effective, #0099ff); + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +/* ── 未开始 ───────────────────────────────────────────────────────────── */ + +.lbs-idle { + display: grid; + justify-items: center; + gap: 12px; + padding: 28px 22px 24px; +} + +.lbs-idle-art { + position: relative; + display: grid; + width: 66px; + height: 66px; + place-items: center; + border-radius: 50%; + background: color-mix(in srgb, var(--weq-accent-effective, #0099ff) 10%, transparent); + color: var(--weq-accent-effective, #0099ff); +} + +.lbs-idle-ping { + position: absolute; + inset: 0; + border: 1.5px solid color-mix(in srgb, var(--weq-accent-effective, #0099ff) 45%, transparent); + border-radius: 50%; + animation: lbs-ping 2.2s ease-out infinite; +} + +@keyframes lbs-ping { + 0% { + opacity: 0.75; + transform: scale(0.85); + } + + 70%, + 100% { + opacity: 0; + transform: scale(1.45); + } +} + +.lbs-idle-text { + margin: 0; + max-width: 260px; + color: var(--weq-fg-secondary, #666666); + font-size: 12.5px; + line-height: 1.5; + text-align: center; +} + +.lbs-primary { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 20px; + border: 0; + border-radius: 999px; + background: var(--weq-accent-solid, var(--weq-accent-effective, #0099ff)); + color: var(--weq-accent-solid-foreground, #ffffff); + font-family: inherit; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: filter 120ms ease; +} + +.lbs-primary:hover:not(:disabled) { + filter: brightness(1.06); +} + +.lbs-primary:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.lbs-hint, +.lbs-error { + max-width: 300px; + color: var(--weq-fg-muted, #858585); + font-size: 11.5px; + line-height: 1.45; + text-align: center; +} + +.lbs-error { + color: var(--weq-danger-solid-background, #a91727); +} + +/* ── 地图 ─────────────────────────────────────────────────────────────── */ + +.lbs-map-wrap { + position: relative; + min-height: 0; + padding: 8px 8px 0; +} + +.lbs-map { + position: relative; + height: 100%; + min-height: 190px; + overflow: hidden; + border-radius: 10px; + background: color-mix(in srgb, var(--weq-fg-muted, #858585) 8%, transparent); + cursor: grab; + touch-action: none; + user-select: none; +} + +.lbs-map:active { + cursor: grabbing; +} + +.lbs-map.is-pickable { + cursor: crosshair; + box-shadow: inset 0 0 0 2px var(--weq-accent-effective, #0099ff); +} + +.lbs-map-tiles { + position: absolute; + inset: 0; +} + +.lbs-map-tile { + position: absolute; + width: 256px; + height: 256px; + max-width: none; + pointer-events: none; +} + +/* 高德没有暗色栅格瓦片 —— 用滤镜把亮色底图翻成暗色(与选点地图同一套做法)。 */ +html[data-theme='dark'] .lbs-map-tile { + filter: invert(1) hue-rotate(180deg) brightness(0.86) contrast(0.92) saturate(0.72); +} + +html[data-theme='dark'] .lbs-map { + background: #1b1e22; +} + +/* ── 地图标记 ─────────────────────────────────────────────────────────── */ + +.lbs-map-member { + position: absolute; + z-index: 3; + display: grid; + place-items: center; + pointer-events: none; + transform: translate(-50%, -50%); +} + +.lbs-map-avatar { + display: grid; + width: 34px; + height: 34px; + overflow: hidden; + border: 2px solid var(--popover, #ffffff); + border-radius: 50%; + background: var(--weq-bg-surface, #ffffff); + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.28); +} + +.lbs-map-avatar .avatar { + width: 100%; + height: 100%; + font-size: 12px; +} + +.lbs-map-member.is-self .lbs-map-avatar { + border-color: var(--weq-accent-effective, #0099ff); +} + +.lbs-map-member.is-stale .lbs-map-avatar { + opacity: 0.55; + filter: grayscale(0.5); +} + +.lbs-map-self-ring { + position: absolute; + width: 52px; + height: 52px; + border: 1.5px solid color-mix(in srgb, var(--weq-accent-effective, #0099ff) 55%, transparent); + border-radius: 50%; + animation: lbs-ping 2.4s ease-out infinite; +} + +.lbs-map-actions { + position: absolute; + top: 16px; + right: 16px; + z-index: 5; + display: grid; + gap: 4px; + padding: 3px; + border-radius: 9px; + background: color-mix(in srgb, var(--popover, #ffffff) 92%, transparent); + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.18); +} + +.lbs-map-action { + display: grid; + width: 26px; + height: 26px; + place-items: center; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--weq-fg-secondary, #666666); + cursor: pointer; +} + +.lbs-map-action:hover { + color: var(--weq-fg-primary, #111111); +} + +.lbs-map-action.is-on { + background: color-mix(in srgb, var(--weq-accent-effective, #0099ff) 16%, transparent); + color: var(--weq-accent-effective, #0099ff); +} + +.lbs-map-zoom { + position: absolute; + top: 8px; + left: 8px; + z-index: 5; + display: grid; + gap: 1px; + overflow: hidden; + border-radius: 8px; + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.18); +} + +.lbs-map-zoom button { + display: grid; + width: 24px; + height: 24px; + place-items: center; + padding: 0; + border: 0; + background: color-mix(in srgb, var(--popover, #ffffff) 94%, transparent); + color: var(--weq-fg-secondary, #666666); + cursor: pointer; +} + +.lbs-map-zoom button:hover:not(:disabled) { + color: var(--weq-accent-effective, #0099ff); +} + +.lbs-map-zoom button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.lbs-map-credit { + position: absolute; + right: 0; + bottom: 0; + z-index: 4; + padding: 2px 6px; + border-top-left-radius: 6px; + background: color-mix(in srgb, var(--popover, #ffffff) 82%, transparent); + color: var(--weq-fg-muted, #858585); + font-size: 9.5px; + pointer-events: none; +} + +/* ── 摇杆 + 速度 ──────────────────────────────────────────────────────── */ + +.lbs-controls { + display: flex; + align-items: center; + gap: 14px; + padding: 10px 14px 12px; +} + +.lbs-joystick { + position: relative; + display: grid; + width: 84px; + height: 84px; + flex: none; + place-items: center; + border: 1px solid color-mix(in srgb, var(--im-color-line, #dedede) 80%, transparent); + border-radius: 50%; + background: color-mix(in srgb, var(--weq-fg-muted, #858585) 7%, transparent); + cursor: grab; + touch-action: none; + outline: none; +} + +.lbs-joystick:active { + cursor: grabbing; +} + +.lbs-joystick:focus-visible { + box-shadow: 0 0 0 2px color-mix(in srgb, var(--weq-accent-effective, #0099ff) 45%, transparent); +} + +.lbs-joystick.is-disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.lbs-joystick-ring { + position: absolute; + inset: 12px; + border: 1px dashed color-mix(in srgb, var(--weq-fg-muted, #858585) 32%, transparent); + border-radius: 50%; +} + +.lbs-joystick-thumb { + position: absolute; + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--weq-accent-solid, var(--weq-accent-effective, #0099ff)); + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.24); + transform: translate(-50%, -50%); + transition: + left 60ms linear, + top 60ms linear; +} + +.lbs-speed { + display: flex; + min-width: 0; + flex-wrap: wrap; + gap: 6px; +} + +.lbs-speed-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 5px 10px; + border: 1px solid color-mix(in srgb, var(--im-color-line, #dedede) 80%, transparent); + border-radius: 999px; + background: transparent; + color: var(--weq-fg-secondary, #666666); + font-family: inherit; + font-size: 11.5px; + cursor: pointer; + transition: + background 120ms ease, + color 120ms ease, + border-color 120ms ease; +} + +.lbs-speed-chip:hover { + color: var(--weq-fg-primary, #111111); +} + +.lbs-speed-chip.is-on { + border-color: transparent; + background: color-mix(in srgb, var(--weq-accent-effective, #0099ff) 14%, transparent); + color: var(--weq-accent-effective, #0099ff); +} + +/* ── 底部 ─────────────────────────────────────────────────────────────── */ + +.lbs-footer { + display: flex; + align-items: center; + gap: 10px; + padding: 0 14px 12px; +} + +.lbs-status { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--weq-fg-muted, #858585); + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.lbs-live-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #2fbf71; + box-shadow: 0 0 0 0 rgba(47, 191, 113, 0.55); + animation: lbs-live 1.8s ease-out infinite; +} + +@keyframes lbs-live { + 70% { + box-shadow: 0 0 0 5px rgba(47, 191, 113, 0); + } + + 100% { + box-shadow: 0 0 0 0 rgba(47, 191, 113, 0); + } +} + +.lbs-stop { + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: auto; + padding: 5px 12px; + border: 1px solid color-mix(in srgb, var(--weq-danger-solid-background, #a91727) 32%, transparent); + border-radius: 999px; + background: transparent; + color: var(--weq-danger-solid-background, #a91727); + font-family: inherit; + font-size: 11.5px; + cursor: pointer; +} + +.lbs-stop:hover { + background: color-mix(in srgb, var(--weq-danger-solid-background, #a91727) 9%, transparent); +} + +/* ── 深色 ─────────────────────────────────────────────────────────────── */ + +html[data-theme='dark'] .lbs-panel { + border-color: rgba(255, 255, 255, 0.1); + box-shadow: 0 18px 44px rgba(0, 0, 0, 0.5); +} + +html[data-theme='dark'] .lbs-header { + border-bottom-color: rgba(255, 255, 255, 0.08); +} + +html[data-theme='dark'] .lbs-map-avatar { + border-color: #1b1e22; +} + +html[data-theme='dark'] .lbs-joystick, +html[data-theme='dark'] .lbs-speed-chip { + border-color: rgba(255, 255, 255, 0.1); +} + +/* ── 移动端:内联进输入区(与其余面板同一套规则) ──────────────────────── */ + +@media (max-width: 760px) { + .lbs-panel { + position: static; + width: 100%; + max-height: none; + border: 0; + border-top: 1px solid color-mix(in srgb, var(--im-color-line, #dedede) 74%, transparent); + border-radius: 0; + background: #f8f8f9; + box-shadow: none; + animation: none; + } + + .lbs-panel.is-active { + grid-template-rows: auto minmax(0, 1fr) auto auto; + } + + .lbs-map-wrap { + padding: 6px 6px 0; + } + + .lbs-map { + min-height: 180px; + } + + .lbs-controls { + padding: 8px 12px 10px; + } + + .lbs-joystick { + width: 76px; + height: 76px; + } + + html[data-theme='dark'] .lbs-panel { + background: #1b1b1c; + border-top-color: rgba(255, 255, 255, 0.08); + } +} diff --git a/apps/desktop/src/renderer/src/im-template/template/chatPane.tsx b/apps/desktop/src/renderer/src/im-template/template/chatPane.tsx index 08d813efd..ca76c5eda 100644 --- a/apps/desktop/src/renderer/src/im-template/template/chatPane.tsx +++ b/apps/desktop/src/renderer/src/im-template/template/chatPane.tsx @@ -51,6 +51,7 @@ import { Bug, Image as ImageIcon, Link2, + MapPin, ClipboardCopy, MessageSquareText, Mic, @@ -150,6 +151,7 @@ import type { ArkContactSource, ArkLocationProvider, ArkPayload } from './arkCar import { AiVoicePanel, aiVoiceToken, type AiVoiceDraft } from './aiVoicePanel'; import { bounceEmojiToken, type BounceEmojiDraft } from './bounceEmojiPanel'; import { RedPacketPanel, type RedPacketDraft } from './redPacketPanel'; +import { LocationSharePanel } from './locationSharePanel'; import { pokeEmojiToken, randomFaceToken, @@ -167,7 +169,11 @@ import { PROJECT_GROUP_IDS } from '../../../../shared/project_groups'; import { chatHeaderTitle, isBotConversation, resolveMessageSender } from './conversationDisplay'; import { createEmojiToken, parseMessageParts } from './emojiPacks'; import type { EmojiItem } from './emojiPacks'; -import { elementToToken, elementsToComposerText, tokenToElement } from './draftElements'; +import { + elementToToken, + elementsToComposerText, + tokenToElement, +} from './draftElements'; import { planNeedsLocalMedia, type LocalMediaRef } from './composerSend'; import { prepareReeditDraft } from './composerReedit'; import { @@ -239,7 +245,7 @@ import { dockStopLabel, dockStopTone } from './unreadDock'; import { displayGroupName, displayUserName } from './user'; import { OnlineStatus } from '../../components/OnlineStatus'; import { MessageDecorationCard } from '../../components/MessageDecorationCard'; -import { trpc } from '../../trpc/client'; +import { client, trpc } from '../../trpc/client'; import { useToast } from '../../components/Toast'; const composerHeightStorageKey = 'chat-template.layout.composerHeight'; @@ -716,6 +722,9 @@ export function ChatPane({ // 「红包」面板:群聊三档(普通 / 拼手气 / 口令),私聊两档(普通 / 拼手气); // 下单出码后由应用层弹二维码灯箱。 const [redPacketOpen, setRedPacketOpen] = useState(false); + // 「位置共享」面板:发起 / 心跳上报 / 看所有人在地图上的位置。房间靠上报续命, + // 停上报约 60 秒后服务端自动结束 —— 没有单独的退出命令。 + const [locationShareOpen, setLocationShareOpen] = useState(false); // 闪传文件框占着输入框正文那一行:别的面板一打开就把它收起来,避免两套东西打架。 useEffect(() => { @@ -948,6 +957,8 @@ export function ChatPane({ const redPacketButtonRef = useRef(null); const flashPanelRef = useRef(null); const flashButtonRef = useRef(null); + const locationSharePanelRef = useRef(null); + const locationShareButtonRef = useRef(null); const imageInputRef = useRef(null); const fileInputRef = useRef(null); const mentionMenuRef = useRef(null); @@ -1531,6 +1542,8 @@ export function ChatPane({ useEffect(() => { setEmojiOpen(false); setToolsOpen(false); + // 切会话就关掉位置共享 —— 面板卸载会停心跳,等价于「退出上一场共享」。 + setLocationShareOpen(false); setContextMenu(null); setMentionMenu(null); setClearMessagesConfirmOpen(false); @@ -1922,6 +1935,40 @@ export function ChatPane({ }; }, [tabActive, redPacketOpen]); + // 位置共享面板:点外面 / Esc 关闭。模态(灯箱等)开着时不抢 Esc。 + useLayoutEffect(() => { + if (!tabActive || !locationShareOpen) { + return; + } + + function closeLocationShareFromOutside(event: globalThis.MouseEvent) { + const target = event.target; + if (!(target instanceof Node)) { + return; + } + if ( + locationSharePanelRef.current?.contains(target) || + locationShareButtonRef.current?.contains(target) + ) { + return; + } + setLocationShareOpen(false); + } + + function closeLocationShareOnEscape(event: globalThis.KeyboardEvent) { + if (event.key === 'Escape' && !hasActiveModal()) { + setLocationShareOpen(false); + } + } + + document.addEventListener('mousedown', closeLocationShareFromOutside); + document.addEventListener('keydown', closeLocationShareOnEscape); + return () => { + document.removeEventListener('mousedown', closeLocationShareFromOutside); + document.removeEventListener('keydown', closeLocationShareOnEscape); + }; + }, [tabActive, locationShareOpen]); + // 附件跟着会话走:切会话时清掉上一条会话的待发送素材(预览地址一并释放)。 const mediaAttachmentRef = useRef(null); mediaAttachmentRef.current = mediaAttachment; @@ -3093,6 +3140,29 @@ export function ChatPane({ setRedPacketOpen(false); } + /** + * 位置共享的「发起了位置共享」卡片。 + * + * 真实客户端由服务端推送这条消息,但 WeQ 的 SSO 是「发一包就断」,服务端判定 + * 发起方不在线就不投递 —— 所以自己发。元素由协议层构造(私聊只需灰条 + 文本; + * 群聊必须额外带 `generalFlags(37)`,否则能显示但点不了)。 + * + * 卡片是独立一条消息,不带输入框里的文字 / 引用。 + */ + async function sendLocationShareCard(): Promise { + if (!conversation || conversation.type === 'merged') return; + // 元素在主进程构造:渲染层不能 import `@weq/protocol`(远程 Web 端复用这份 + // 代码,而 protocol 里有 `node:crypto` 的红包签名,浏览器端打不出来)。 + await client.account.sendLocationShareCard.mutate({ + expectedAccountUin: user.identityValue, + scene: conversation.type === 'group' ? 'group' : 'c2c', + targetId: + conversation.type === 'group' + ? conversation.group.id + : conversation.otherUser.identityValue, + }); + } + /** * AI 声聊面板「合成并发送」:这条语音**只能单独发** —— 不带输入框里的文字、 * 也不带挂着的引用(跟视频 / 文件那套「单独发」同一套互斥)。 @@ -3913,6 +3983,7 @@ export function ChatPane({ if (voiceBusyRef.current) return; setComposerMenu(null); setContextMenu(null); + setLocationShareOpen(false); setToolsOpen(false); setVoiceOpen(false); setArkOpen(false); @@ -3922,6 +3993,7 @@ export function ChatPane({ function toggleToolsPanel() { setContextMenu(null); + setLocationShareOpen(false); setEmojiOpen(false); setVoiceOpen(false); setArkOpen(false); @@ -3933,6 +4005,7 @@ export function ChatPane({ if (voiceBusyRef.current) return; setComposerMenu(null); setContextMenu(null); + setLocationShareOpen(false); setEmojiOpen(false); setToolsOpen(false); setArkOpen(false); @@ -3942,6 +4015,7 @@ export function ChatPane({ function toggleArkPanel() { setContextMenu(null); + setLocationShareOpen(false); setEmojiOpen(false); setToolsOpen(false); setVoiceOpen(false); @@ -3955,6 +4029,7 @@ export function ChatPane({ */ function toggleFlashPanel() { setContextMenu(null); + setLocationShareOpen(false); setEmojiOpen(false); setToolsOpen(false); setVoiceOpen(false); @@ -3966,6 +4041,7 @@ export function ChatPane({ /** AI 声聊面板(仅群聊):和其余面板互斥,同一时刻只开一个。 */ function toggleAiVoicePanel() { setContextMenu(null); + setLocationShareOpen(false); setEmojiOpen(false); setToolsOpen(false); setVoiceOpen(false); @@ -3976,6 +4052,7 @@ export function ChatPane({ /** 红包面板:和其余面板互斥;下单出码在应用层,成功后弹二维码灯箱。 */ function toggleRedPacketPanel() { setContextMenu(null); + setLocationShareOpen(false); setEmojiOpen(false); setToolsOpen(false); setVoiceOpen(false); @@ -3984,6 +4061,23 @@ export function ChatPane({ setRedPacketOpen((open) => !open); } + /** + * 位置共享面板:和其余面板互斥。 + * + * 面板内部自己管心跳(打开才开始上报,关掉/切会话即停)—— 这里只负责显隐, + * 不把「是否在共享」提到 chatPane 层,避免关面板后还在后台偷偷上报位置。 + */ + function toggleLocationSharePanel() { + setContextMenu(null); + setEmojiOpen(false); + setToolsOpen(false); + setVoiceOpen(false); + setArkOpen(false); + setAiVoiceOpen(false); + setRedPacketOpen(false); + setLocationShareOpen((open) => !open); + } + const handleVoiceBusyChange = useCallback((busy: boolean) => { voiceBusyRef.current = busy; }, []); @@ -4417,6 +4511,17 @@ export function ChatPane({ const aiVoicePanelActive = canUseAiVoice && aiVoiceOpen && !mobileComposerExpanded; // 红包面板:群聊 / 私聊都能发,浮层形态(不占正文那一行)。 const redPacketPanelActive = redPacketOpen && !mobileComposerExpanded; + // 位置共享面板:私聊 / 群聊都能发起,浮层形态。 + const locationSharePanelActive = locationShareOpen && !mobileComposerExpanded; + /** + * 位置共享的会话键:私聊是对端 QQ 号,群聊是群号。 + * 临时会话(来源群号)不参与 —— 位置共享协议没有 grpTmp 路由,直接按 c2c 走。 + */ + const locationSharePeer = + conversation.type === 'group' + ? Number(conversation.group.id) + : Number(conversation.otherUser.identityValue); + const locationShareSelfUin = Number(user.identityValue); const mediaSendDisabled = Boolean(sendGateReason); const repeatMessage = repeatTarget(messages, atLatest); function messageCanRepeat(message: Message) { @@ -5330,6 +5435,16 @@ export function ChatPane({ onClick={runComposerAction} /> ))} + {/* 位置共享(手机端):与桌面端同一个面板,只是工具栏样式不同。 */} + + {/* 位置共享:发起 / 看所有人在哪 / 摇杆挪位置。房间靠上报续命,停上报即退出。 */} + {composerActionRegistry.desktopToolbar.map((action) => ( setRedPacketOpen(false)} /> ) : null} + {locationSharePanelActive ? ( + setLocationShareOpen(false)} + /> + ) : null} void; + disabled?: boolean; +}) { + const baseRef = useRef(null); + const pointerRef = useRef(null); + /** 手柄当前偏移(-1..1 的归一化坐标,y 向下为正,与屏幕一致)。 */ + const [offset, setOffset] = useState({ x: 0, y: 0 }); + + const emit = useCallback( + (x: number, y: number) => { + setOffset({ x, y }); + onMove(x, y); + }, + [onMove], + ); + + const compute = useCallback((clientX: number, clientY: number) => { + const base = baseRef.current; + if (!base) return { x: 0, y: 0 }; + const rect = base.getBoundingClientRect(); + const radius = rect.width / 2; + // 相对底座中心的位移,归一化到 [-1, 1]。 + let dx = (clientX - (rect.left + radius)) / radius; + let dy = (clientY - (rect.top + radius)) / radius; + const len = Math.hypot(dx, dy); + if (len > 1) { + dx /= len; + dy /= len; + } + return { x: dx, y: dy }; + }, []); + + function handlePointerDown(event: ReactPointerEvent): void { + if (disabled) return; + pointerRef.current = event.pointerId; + event.currentTarget.setPointerCapture(event.pointerId); + const next = compute(event.clientX, event.clientY); + emit(next.x, next.y); + } + + function handlePointerMove(event: ReactPointerEvent): void { + if (pointerRef.current !== event.pointerId) return; + const next = compute(event.clientX, event.clientY); + emit(next.x, next.y); + } + + function handlePointerUp(event: ReactPointerEvent): void { + if (pointerRef.current !== event.pointerId) return; + pointerRef.current = null; + emit(0, 0); + } + + // 键盘:方向键按住持续移动,松开归零。上下左右可组合(同时按 = 斜向)。 + const keysRef = useRef(new Set()); + + const applyKeys = useCallback(() => { + const keys = keysRef.current; + let x = 0; + let y = 0; + if (keys.has('ArrowLeft')) x -= 1; + if (keys.has('ArrowRight')) x += 1; + if (keys.has('ArrowUp')) y -= 1; + if (keys.has('ArrowDown')) y += 1; + const len = Math.hypot(x, y); + emit(len > 1 ? x / len : x, len > 1 ? y / len : y); + }, [emit]); + + function handleKeyDown(event: ReactKeyboardEvent): void { + if (disabled) return; + if (!event.key.startsWith('Arrow')) return; + event.preventDefault(); + keysRef.current.add(event.key); + applyKeys(); + } + + function handleKeyUp(event: ReactKeyboardEvent): void { + if (!event.key.startsWith('Arrow')) return; + keysRef.current.delete(event.key); + applyKeys(); + } + + // 失焦时清空按键,避免「切窗口后自己一直走」。 + useEffect(() => { + const reset = (): void => { + if (!keysRef.current.size) return; + keysRef.current.clear(); + emit(0, 0); + }; + window.addEventListener('blur', reset); + return () => window.removeEventListener('blur', reset); + }, [emit]); + + const thumbLeft = `${50 + offset.x * THUMB_TRAVEL * 50}%`; + const thumbTop = `${50 + offset.y * THUMB_TRAVEL * 50}%`; + + return ( +
+ + +
+ ); +} + +/** 摇杆速度档位(米/秒)。越靠后越快,用于模拟步行 / 骑行 / 开车。 */ +export const JOYSTICK_SPEEDS = [ + { id: 'walk', label: '步行', metersPerSecond: 1.4 }, + { id: 'run', label: '跑步', metersPerSecond: 4 }, + { id: 'bike', label: '骑行', metersPerSecond: 8 }, + { id: 'car', label: '驾车', metersPerSecond: 22 }, +] as const; + +export type JoystickSpeedId = (typeof JOYSTICK_SPEEDS)[number]['id']; + +/** + * 经纬度按「米」偏移。 + * + * 纬度每度约 111320 米;经度随纬度收缩(乘 cos)。这是**球面近似**,在几公里的 + * 尺度上与真实距离的误差可以忽略,且不需要引入大地测量库。 + */ +export function offsetLatLng( + latitude: number, + longitude: number, + northMeters: number, + eastMeters: number, +): { latitude: number; longitude: number } { + const latRad = (latitude * Math.PI) / 180; + const nextLat = latitude + northMeters / 111320; + const cos = Math.cos(latRad); + // 极点附近 cos 趋近 0,经度会爆炸 —— 夹一个下限(约 ±89.99°)。 + const nextLng = longitude + eastMeters / (111320 * Math.max(0.0001, Math.abs(cos))); + return { + latitude: Math.max(-85, Math.min(85, nextLat)), + longitude: ((((nextLng + 180) % 360) + 360) % 360) - 180, + }; +} diff --git a/apps/desktop/src/renderer/src/im-template/template/locationShareMap.tsx b/apps/desktop/src/renderer/src/im-template/template/locationShareMap.tsx new file mode 100644 index 000000000..d6c657266 --- /dev/null +++ b/apps/desktop/src/renderer/src/im-template/template/locationShareMap.tsx @@ -0,0 +1,334 @@ +// @ts-nocheck +/** + * 位置共享地图 —— 多成员头像标记 + 可交互的 slippy map。 + * + * 与 `locationPicker.tsx` 同源(同一套高德栅格瓦片与 Web Mercator 投影),但那个 + * 组件是「选一个点」的受控输入框(固定 150px 高、视口不可控、单图钉),位置共享 + * 要的是「看一屋子人在哪 + 自己随时能挪」: + * + * - 视口(中心 / 缩放)由外部控制,便于「跟随我」; + * - 每个成员一个头像标记,自己高亮; + * - 点击地图 = 把自己挪过去(可选); + * - 拖拽平移、滚轮/按钮缩放。 + * + * CSP:瓦片是纯 ``,`img-src` 已放行 `https://*.is.autonavi.com`。 + * 坐标全程 GCJ-02 —— 瓦片与 QQ 的共享协议是同一套,不需要火星坐标转换。 + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { PointerEvent as ReactPointerEvent } from 'react'; +import { Minus, Plus } from 'lucide-react'; +import { cn } from './classNames'; +import { Avatar } from './primitives'; +import { project, tileUrl, unproject } from './locationPicker'; + +const TILE_SIZE = 256; +const MIN_ZOOM = 3; +const MAX_ZOOM = 18; +/** Web Mercator 纬度上限;超出会让整块地图空掉。 */ +const MAX_LAT = 85.05112878; +/** 拖拽判定阈值(像素):超过就不算「点击落点」。 */ +const CLICK_SLOP = 5; + +export type MapView = { lng: number; lat: number; zoom: number }; + +/** 地图上的一个成员标记。 */ +export interface MapMember { + /** 稳定 key(uin 字符串)。 */ + id: string; + /** 显示名(昵称 / 群名片),用于头像兜底与 title。 */ + name: string; + avatarUrl?: string | null; + /** 头像兜底种子(通常给 uid 或 uin)。 */ + seed?: string; + longitude: number; + latitude: number; + /** 是不是自己 —— 自己用主题色描边 + 呼吸圈。 */ + isSelf?: boolean; + /** 最近一次上报距今多少秒;用于「位置陈旧」的视觉提示。 */ + ageSeconds?: number; +} + +function wrapLng(lng: number): number { + return ((((lng + 180) % 360) + 360) % 360) - 180; +} + +export function clampView(view: MapView): MapView { + const zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, view.zoom)); + const lat = Math.max(-MAX_LAT, Math.min(MAX_LAT, view.lat)); + const lng = wrapLng(view.lng); + // 三个分量都没变就原样返回,避免调用方拿到新对象触发多余重渲染。 + if (zoom === view.zoom && lat === view.lat && lng === view.lng) return view; + return { lng, lat, zoom }; +} + +/** + * 把一组坐标收进视野:算出让所有点都可见的中心与缩放。 + * + * `padding` 是给标记留的边距(像素),否则贴边的头像会被裁掉一半。 + */ +export function fitView( + points: Array<{ longitude: number; latitude: number }>, + size: { width: number; height: number }, + padding = 56, +): MapView | null { + if (!points.length || size.width <= 0 || size.height <= 0) return null; + if (points.length === 1) { + return clampView({ + lng: points[0].longitude, + lat: points[0].latitude, + zoom: 16, + }); + } + const usableW = Math.max(1, size.width - padding * 2); + const usableH = Math.max(1, size.height - padding * 2); + // 先在最细粒度上投影,再按「需要缩多少」反推 zoom。 + const baseZoom = 18; + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + for (const point of points) { + const px = project(point.longitude, point.latitude, baseZoom); + minX = Math.min(minX, px.x); + maxX = Math.max(maxX, px.x); + minY = Math.min(minY, px.y); + maxY = Math.max(maxY, px.y); + } + const spanX = Math.max(1, maxX - minX); + const spanY = Math.max(1, maxY - minY); + const zoomX = baseZoom + Math.log2(usableW / spanX); + const zoomY = baseZoom + Math.log2(usableH / spanY); + const zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, Math.min(zoomX, zoomY))); + const center = unproject((minX + maxX) / 2, (minY + maxY) / 2, baseZoom); + return clampView({ lng: center.lng, lat: center.lat, zoom }); +} + +export function LocationShareMap({ + view, + onViewChange, + members, + onPick, + pickEnabled = false, + className, +}: { + view: MapView; + onViewChange: (next: MapView) => void; + members: MapMember[]; + /** 点地图落点(把自己挪过去);`pickEnabled` 为假时不触发。 */ + onPick?: (longitude: number, latitude: number) => void; + pickEnabled?: boolean; + className?: string; +}) { + const hostRef = useRef(null); + const [size, setSize] = useState({ width: 0, height: 0 }); + // 原生滚轮监听里要读当前视野,但不想让 effect 跟着 view 重挂。 + const viewRef = useRef(view); + viewRef.current = view; + + useEffect(() => { + const host = hostRef.current; + if (!host) return; + const measure = (): void => setSize({ width: host.clientWidth, height: host.clientHeight }); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(host); + return () => observer.disconnect(); + }, []); + + /** 屏幕坐标 → 地理坐标。 */ + const geoAt = useCallback((clientX: number, clientY: number) => { + const host = hostRef.current; + const current = viewRef.current; + if (!host) return { lng: current.lng, lat: current.lat }; + const rect = host.getBoundingClientRect(); + const centerPx = project(current.lng, current.lat, current.zoom); + return unproject( + centerPx.x + (clientX - rect.left) - rect.width / 2, + centerPx.y + (clientY - rect.top) - rect.height / 2, + current.zoom, + ); + }, []); + + /** 缩放;给了锚点就让锚点下的地理坐标停在屏幕上的同一处。 */ + const zoomBy = useCallback( + (delta: number, anchor?: { lng: number; lat: number }) => { + const current = viewRef.current; + const zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, current.zoom + delta)); + if (zoom === current.zoom) return; + if (!anchor) { + onViewChange(clampView({ ...current, zoom })); + return; + } + const before = project(anchor.lng, anchor.lat, current.zoom); + const after = project(anchor.lng, anchor.lat, zoom); + const centerPx = project(current.lng, current.lat, current.zoom); + const next = unproject( + centerPx.x + (after.x - before.x), + centerPx.y + (after.y - before.y), + zoom, + ); + onViewChange(clampView({ lng: next.lng, lat: next.lat, zoom })); + }, + [onViewChange], + ); + + // 滚轮缩放要 preventDefault,React 的合成 wheel 是 passive 的 —— 挂原生监听。 + useEffect(() => { + const host = hostRef.current; + if (!host) return; + const onWheel = (event: WheelEvent): void => { + event.preventDefault(); + zoomBy(event.deltaY < 0 ? 1 : -1, geoAt(event.clientX, event.clientY)); + }; + host.addEventListener('wheel', onWheel, { passive: false }); + return () => host.removeEventListener('wheel', onWheel); + }, [zoomBy, geoAt]); + + const dragRef = useRef<{ + pointerId: number; + startX: number; + startY: number; + lng: number; + lat: number; + moved: number; + } | null>(null); + + function handlePointerDown(event: ReactPointerEvent): void { + if (event.button !== 0) return; + dragRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + lng: viewRef.current.lng, + lat: viewRef.current.lat, + moved: 0, + }; + event.currentTarget.setPointerCapture(event.pointerId); + } + + function handlePointerMove(event: ReactPointerEvent): void { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + const dx = event.clientX - drag.startX; + const dy = event.clientY - drag.startY; + drag.moved = Math.max(drag.moved, Math.hypot(dx, dy)); + const startPx = project(drag.lng, drag.lat, viewRef.current.zoom); + const next = unproject(startPx.x - dx, startPx.y - dy, viewRef.current.zoom); + onViewChange(clampView({ ...viewRef.current, lng: next.lng, lat: next.lat })); + } + + function handlePointerUp(event: ReactPointerEvent): void { + const drag = dragRef.current; + dragRef.current = null; + if (!drag || drag.pointerId !== event.pointerId) return; + // 拖动过就不是落点。 + if (drag.moved > CLICK_SLOP) return; + if (!pickEnabled || !onPick) return; + const geo = geoAt(event.clientX, event.clientY); + onPick(geo.lng, geo.lat); + } + + const centerPx = project(view.lng, view.lat, view.zoom); + const originX = centerPx.x - size.width / 2; + const originY = centerPx.y - size.height / 2; + const tileCount = 2 ** view.zoom; + + const tiles: Array<{ key: string; src: string; left: number; top: number }> = []; + if (size.width > 0 && size.height > 0) { + const startX = Math.floor(originX / TILE_SIZE); + const endX = Math.floor((originX + size.width) / TILE_SIZE); + const startY = Math.floor(originY / TILE_SIZE); + const endY = Math.floor((originY + size.height) / TILE_SIZE); + for (let tx = startX; tx <= endX; tx += 1) { + for (let ty = startY; ty <= endY; ty += 1) { + if (ty < 0 || ty >= tileCount) continue; + const wrapped = ((tx % tileCount) + tileCount) % tileCount; + tiles.push({ + key: `${view.zoom}/${tx}/${ty}`, + src: tileUrl(view.zoom, wrapped, ty), + left: tx * TILE_SIZE - originX, + top: ty * TILE_SIZE - originY, + }); + } + } + } + + return ( +
{ + dragRef.current = null; + }} + role="application" + aria-label="位置共享地图" + > +
+ {tiles.map((tile) => ( + + ))} +
+ + {members.map((member) => { + const px = project(member.longitude, member.latitude, view.zoom); + const left = px.x - originX; + const top = px.y - originY; + // 视野外的标记不渲染 —— 否则它们会以「贴在边缘」的假位置出现。 + if (left < -80 || top < -80 || left > size.width + 80 || top > size.height + 80) { + return null; + } + const stale = (member.ageSeconds ?? 0) > 60; + return ( + + {member.isSelf ? : null} + + + + + ); + })} + +
+ + +
+ + © 高德地图 +
+ ); +} diff --git a/apps/desktop/src/renderer/src/im-template/template/locationSharePanel.tsx b/apps/desktop/src/renderer/src/im-template/template/locationSharePanel.tsx new file mode 100644 index 000000000..1c32c2ae0 --- /dev/null +++ b/apps/desktop/src/renderer/src/im-template/template/locationSharePanel.tsx @@ -0,0 +1,541 @@ +// @ts-nocheck +/** + * 位置共享面板 —— 发起 / 维持 / 查看一场实时位置共享。 + * + * ## 生命周期 + * + * 房间靠**上报心跳**续命(服务端在每次 `report` 的响应里给出下次间隔,实测 2 秒); + * 停止上报约 60 秒后服务端把房间标记为已结束。所以「退出共享」= 停掉心跳, + * 没有单独的退出命令(真实客户端的结束动作走推送 ACK,第三方拿不到)。 + * + * ## 三条命令 + * + * locationShareStart 建房间(私聊 / 群聊同一套,只有会话键不同) + * locationShareReport 周期上报自己的坐标 + * locationShareQuery 拉所有成员坐标 —— **唯一**能拿到别人位置的途径 + * + * ## 卡片 + * + * 真实客户端发起后由服务端推一条「发起了位置共享」消息;WeQ 的 SSO 是「发一包 + * 就断」,服务端判定发起方不在线就不投递 —— 所以这里自己 `sendElements` 发卡片 + * (元素由 `buildShareLocationElements` 构造)。 + * + * ## 为什么心跳在渲染层 + * + * 主进程服务**不持有定时器**(见 `@weq/service` 的 `LocationShareService`):谁打开 + * 了面板谁负责生命周期。面板卸载 / 切会话 / 关窗口时定时器随之清掉,不会有 + * 「关了界面还在后台偷偷上报位置」这种事。 + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + ArrowLeft, + Crosshair, + Footprints, + Gauge, + MapPin, + Play, + Radio, + Square, + Users, +} from 'lucide-react'; +import { cn } from './classNames'; +import { client } from '../../trpc/client'; +import { LocationShareMap, fitView, type MapMember, type MapView } from './locationShareMap'; +import { + JOYSTICK_SPEEDS, + LocationShareJoystick, + offsetLatLng, + type JoystickSpeedId, +} from './locationShareJoystick'; +import { DEFAULT_CENTER, DEFAULT_ZOOM } from './locationPicker'; + +/** 心跳兜底间隔(服务端没给 interval 时)。 */ +const DEFAULT_INTERVAL_SECONDS = 2; +/** 查询成员坐标的间隔(心跳的倍数)。位置不会推送,只能主动拉。 */ +const QUERY_EVERY_TICKS = 3; +/** 摇杆驱动的重绘节拍(毫秒):越小越顺滑,但上报仍然按服务端间隔。 */ +const JOYSTICK_TICK_MS = 100; +/** 自己的坐标推送节流:摇杆移动时最多每这么多毫秒上报一次。 */ +const REPORT_THROTTLE_MS = 900; + +export type LocationSharePhase = 'idle' | 'starting' | 'active'; + +interface LocationSharePanelProps { + /** 浮层根节点(外部点击判定用,与其余 composer 面板同构)。 */ + panelRef?: React.RefObject; + /** c2c = 私聊,group = 群聊。 */ + scene: 'c2c' | 'group'; + /** 私聊:对端 QQ 号。群聊:群号。 */ + peer: number; + /** 自己的 QQ 号。 */ + selfUin: number; + /** 自己的显示名 / 头像(地图上自己的标记)。 */ + selfName: string; + selfAvatarUrl?: string | null; + selfUid?: string; + /** 会话标题(面板头部)。 */ + conversationName: string; + /** 群成员(用来把 uin 映射成昵称 / 头像)。私聊为空。 */ + members?: Array<{ + identityValue: string; + displayName: string; + avatarUrl: string | null; + id: string; + }>; + /** 发送「发起了位置共享」卡片;由宿主转交 MessageSendService。 */ + onSendCard?: () => void | Promise; + /** 关闭面板(返回按钮)。 */ + onClose?: () => void; + disabled?: boolean; + disabledHint?: string; +} + +/** 面板内的一次会话状态。 */ +interface ShareSession { + /** 我自己当前的坐标(摇杆 / 点地图会改它)。 */ + self: { longitude: number; latitude: number }; + /** 服务端要求的下次上报间隔(秒)。 */ + interval: number; + /** 服务端返回的房间状态(1 进行中 / 2 已结束)。 */ + status: number; + /** 最近一次拉到的成员(含自己)。 */ + members: Array<{ + uin: number; + longitude: number; + latitude: number; + joinedAt: number; + reportedAt: number; + }>; + /** 最近一次成功上报的时间(毫秒),用于「上次上报」提示。 */ + lastReportAt: number; + /** 最近一次错误(展示用,成功一次就清)。 */ + error: string | null; +} + +const EMPTY_SESSION: ShareSession = { + self: { longitude: DEFAULT_CENTER.lng, latitude: DEFAULT_CENTER.lat }, + interval: DEFAULT_INTERVAL_SECONDS, + status: 1, + members: [], + lastReportAt: 0, + error: null, +}; + +/** uin → 展示信息(昵称 / 头像)。 */ +type MemberInfo = { name: string; avatarUrl: string | null; uid?: string }; + +export function LocationSharePanel({ + panelRef, + scene, + peer, + selfUin, + selfName, + selfAvatarUrl, + selfUid, + conversationName, + members, + onSendCard, + onClose, + disabled, + disabledHint, +}: LocationSharePanelProps) { + const [phase, setPhase] = useState('idle'); + const [session, setSession] = useState(EMPTY_SESSION); + const [speed, setSpeed] = useState('walk'); + const [view, setView] = useState({ + lng: DEFAULT_CENTER.lng, + lat: DEFAULT_CENTER.lat, + zoom: DEFAULT_ZOOM, + }); + const [follow, setFollow] = useState(true); + const [pickEnabled, setPickEnabled] = useState(false); + /** 摇杆当前方向(-1..1),`null` = 没在移动。 */ + const moveRef = useRef<{ x: number; y: number } | null>(null); + /** 每轮上报后的「下一次该报」计时。 */ + const nextReportRef = useRef(0); + const lastReportSentRef = useRef(0); + const tickRef = useRef | null>(null); + const sessionRef = useRef(session); + sessionRef.current = session; + const phaseRef = useRef(phase); + phaseRef.current = phase; + + /** uin → 昵称 / 头像。群成员表优先,自己单独覆盖。 */ + const memberInfoByUin = useMemo(() => { + const map = new Map(); + for (const member of members ?? []) { + const uin = Number(member.identityValue); + if (Number.isSafeInteger(uin) && uin > 0) { + map.set(uin, { + name: member.displayName, + avatarUrl: member.avatarUrl, + uid: member.id, + }); + } + } + map.set(selfUin, { name: selfName, avatarUrl: selfAvatarUrl ?? null, uid: selfUid }); + return map; + }, [members, selfUin, selfName, selfAvatarUrl, selfUid]); + + // ─────────────────────────── 启动 / 停止 ─────────────────────────── + + const stopTicking = useCallback(() => { + if (tickRef.current !== null) { + clearInterval(tickRef.current); + tickRef.current = null; + } + }, []); + + /** 上报一次(内部使用;会更新 session)。 */ + const doReport = useCallback( + async (position: { longitude: number; latitude: number }) => { + try { + const reply = await client.account.locationShareReport.mutate({ + scene, + peer, + selfUin, + longitude: position.longitude, + latitude: position.latitude, + }); + lastReportSentRef.current = Date.now(); + setSession((current) => ({ + ...current, + interval: reply.interval || DEFAULT_INTERVAL_SECONDS, + lastReportAt: Date.now(), + error: reply.ok ? null : reply.message || `上报失败(${reply.code})`, + })); + } catch (error) { + setSession((current) => ({ + ...current, + error: error instanceof Error ? error.message : String(error), + })); + } + }, + [scene, peer, selfUin], + ); + + /** 拉一次成员坐标。 */ + const doQuery = useCallback(async () => { + try { + const room = await client.account.locationShareQuery.query({ + scene, + peer, + selfUin, + mode: 1, + }); + setSession((current) => ({ + ...current, + status: room.status, + members: room.members, + })); + // 房间已结束(停上报超时)→ 回到 idle,别让用户以为还在共享。 + if (room.status === 2 && phaseRef.current === 'active') { + stopTicking(); + setPhase('idle'); + } + } catch (error) { + setSession((current) => ({ + ...current, + error: error instanceof Error ? error.message : String(error), + })); + } + }, [scene, peer, selfUin, stopTicking]); + + /** + * 心跳循环。 + * + * 用单个 100ms 的 tick 同时驱动「摇杆移动」与「按服务端间隔上报 / 定期查询」: + * 两个独立定时器会让「摇杆停下但心跳还在跑」这类状态很难对齐。 + */ + const startTicking = useCallback(() => { + stopTicking(); + let queryCountdown = 0; + tickRef.current = setInterval(() => { + if (phaseRef.current !== 'active') return; + const now = Date.now(); + const current = sessionRef.current; + + // ① 摇杆位移:按速度档位把「米/秒」换成经纬度增量。 + const move = moveRef.current; + if (move && (move.x !== 0 || move.y !== 0)) { + const speedSpec = JOYSTICK_SPEEDS.find((item) => item.id === speed) ?? JOYSTICK_SPEEDS[0]; + const seconds = JOYSTICK_TICK_MS / 1000; + const meters = speedSpec.metersPerSecond * seconds; + // 屏幕 y 向下为正,地理上「向上」= 北。 + const next = offsetLatLng( + current.self.latitude, + current.self.longitude, + -move.y * meters, + move.x * meters, + ); + setSession((prev) => ({ ...prev, self: next })); + if (follow) setView((v) => ({ ...v, lng: next.longitude, lat: next.latitude })); + } + + // ② 上报:到点才发,且摇杆移动时也不高于 REPORT_THROTTLE_MS。 + const intervalMs = Math.max(1000, current.interval * 1000); + if (now >= nextReportRef.current && now - lastReportSentRef.current >= REPORT_THROTTLE_MS) { + nextReportRef.current = now + intervalMs; + const position = sessionRef.current.self; + void doReport(position); + } + + // ③ 查询:每 N 次上报拉一次成员坐标。 + queryCountdown -= 1; + if (queryCountdown <= 0) { + queryCountdown = QUERY_EVERY_TICKS; + void doQuery(); + } + }, JOYSTICK_TICK_MS); + }, [speed, follow, doReport, doQuery, stopTicking]); + + // 摇杆 / 跟随 / 速度变了要重建 tick(闭包捕获了它们)。 + useEffect(() => { + if (phase !== 'active') return; + startTicking(); + return stopTicking; + }, [phase, startTicking, stopTicking]); + + // 面板卸载时停心跳(等价于「退出共享」)。 + useEffect(() => stopTicking, [stopTicking]); + + const handleStart = useCallback(async () => { + if (disabled) return; + setPhase('starting'); + setSession((current) => ({ ...current, error: null })); + const start = sessionRef.current.self; + try { + const reply = await client.account.locationShareStart.mutate({ + scene, + peer, + selfUin, + longitude: start.longitude, + latitude: start.latitude, + }); + if (!reply.ok) { + setSession((current) => ({ + ...current, + error: reply.message || `发起失败(${reply.code})`, + })); + setPhase('idle'); + return; + } + setPhase('active'); + nextReportRef.current = 0; + lastReportSentRef.current = 0; + setView((v) => ({ ...v, lng: start.longitude, lat: start.latitude })); + // 发卡片:失败不影响共享本身(房间已经建好了)。 + try { + await onSendCard?.(); + } catch { + /* 卡片失败静默 —— 房间已建立,对方仍可通过其他方式加入 */ + } + } catch (error) { + setSession((current) => ({ + ...current, + error: error instanceof Error ? error.message : String(error), + })); + setPhase('idle'); + } + }, [disabled, scene, peer, selfUin, onSendCard]); + + const handleStop = useCallback(() => { + stopTicking(); + setPhase('idle'); + moveRef.current = null; + }, [stopTicking]); + + // ─────────────────────────── 交互 ─────────────────────────── + + const handleJoystickMove = useCallback((dx: number, dy: number) => { + moveRef.current = dx === 0 && dy === 0 ? null : { x: dx, y: dy }; + }, []); + + const handleMapPick = useCallback((longitude: number, latitude: number) => { + setSession((current) => ({ ...current, self: { longitude, latitude } })); + setPickEnabled(false); + // 立刻上报一次,别等下一个心跳。 + lastReportSentRef.current = 0; + }, []); + + /** 地图标记:房间成员 + 自己(自己以本地坐标为准,更实时)。 */ + const mapMembers: MapMember[] = useMemo(() => { + const now = Date.now() / 1000; + const result: MapMember[] = []; + const selfPos = session.self; + const seen = new Set(); + for (const member of session.members) { + if (member.uin === selfUin) continue; + // 没上报过(坐标 0)的成员不画 —— 会落在几内亚湾。 + if (!member.longitude && !member.latitude) continue; + const info = memberInfoByUin.get(member.uin); + seen.add(member.uin); + result.push({ + id: String(member.uin), + name: info?.name ?? String(member.uin), + avatarUrl: info?.avatarUrl ?? null, + seed: info?.uid ?? String(member.uin), + longitude: member.longitude, + latitude: member.latitude, + ageSeconds: member.reportedAt ? Math.max(0, now - member.reportedAt) : undefined, + }); + } + // 自己永远在(即使服务端还没回显)。 + result.push({ + id: 'self', + name: selfName, + avatarUrl: selfAvatarUrl ?? null, + seed: selfUid ?? String(selfUin), + longitude: selfPos.longitude, + latitude: selfPos.latitude, + isSelf: true, + }); + return result; + }, [session.members, session.self, selfUin, memberInfoByUin, selfName, selfAvatarUrl, selfUid]); + + /** 「看全部」:把所有标记收进视野。 */ + const handleFitAll = useCallback(() => { + const host = document.querySelector('.lbs-map') as HTMLElement | null; + const size = host + ? { width: host.clientWidth, height: host.clientHeight } + : { width: 360, height: 260 }; + const fitted = fitView( + mapMembers.map((m) => ({ longitude: m.longitude, latitude: m.latitude })), + size, + ); + if (fitted) setView(fitted); + }, [mapMembers]); + + /** 「回到我」。 */ + const handleRecenter = useCallback(() => { + setFollow(true); + setView((v) => ({ ...v, lng: session.self.longitude, lat: session.self.latitude })); + }, [session.self]); + + const othersCount = session.members.filter( + (m) => m.uin !== selfUin && (m.longitude || m.latitude), + ).length; + const active = phase === 'active'; + + return ( +
+
+ +
+ + 位置共享 + {conversationName} +
+ + + {active ? othersCount + 1 : 0} + +
+ + {!active ? ( +
+
+ + +
+

+ {scene === 'group' ? '和群成员互相看到彼此的位置' : '和对方互相看到彼此的位置'} +

+ + {session.error ? {session.error} : null} + {disabled && disabledHint ? {disabledHint} : null} +
+ ) : ( + <> +
+ { + setView(next); + // 手动平移后不再自动跟随,避免和用户抢镜头。 + setFollow(false); + }} + members={mapMembers} + onPick={handleMapPick} + pickEnabled={pickEnabled} + /> +
+ + + +
+
+ +
+ +
+ {JOYSTICK_SPEEDS.map((item) => ( + + ))} +
+
+ +
+ + + {session.status === 2 ? '已结束' : '共享中'} + {session.lastReportAt + ? ` · ${Math.max(0, Math.round((Date.now() - session.lastReportAt) / 1000))}s 前上报` + : ''} + + +
+ {session.error ? {session.error} : null} + + )} +
+ ); +} diff --git a/apps/desktop/src/renderer/src/lib/callVideo.ts b/apps/desktop/src/renderer/src/lib/callVideo.ts index 3d59ba4e2..282f8813a 100644 --- a/apps/desktop/src/renderer/src/lib/callVideo.ts +++ b/apps/desktop/src/renderer/src/lib/callVideo.ts @@ -138,6 +138,41 @@ export function toAccessUnit(input: unknown): HevcAccessUnit { }; } +/** + * 判断这一帧能否喂给解码器:帧序号必须与上一帧连续,或者本帧是关键帧。 + * + * ## 为什么必须做这个检查 + * + * 2026-10-26 Windows 真机复现:渲染层订阅比主进程收帧晚约 1.9 秒。订阅时补发 + * 缓存的关键帧(帧 0),但紧接着喂的是**实时帧**(帧 29),中间 1~28 全部缺失。 + * + * HEVC 的 P 帧依赖前序参考帧。Chromium 硬解在拒绝这些「无参考帧」的 delta 后 + * **内部状态被污染**,继续输出错乱的 YUV —— 实测整屏纯绿(中心 RGB `[0,88,0]`, + * R/B 恒为 0),直到下一个关键帧(帧 257,约 15 秒后)才重新同步。 + * + * 离线复现(`tmp/verify_fix.cjs`,同一份对端码流): + * + * | 喂入方式 | 绿帧数 | + * | --- | --- | + * | 连续喂 0,1,2,… | 0 | + * | 跳帧喂 0,29,30,…(修复前) | 228(#1..#228) | + * | 跳帧喂 + 本检查(修复后) | 0(丢弃 228 帧,等关键帧) | + * + * 代价是画面「冻」到下一个关键帧才动 —— 好过整屏绿 15 秒。 + * + * @param previous 上一个**喂进解码器**的帧序号;null 表示还没有(刚起解) + * @param current 本帧的 16 位帧序号 + * @param keyframe 本帧是否含 IRAP + */ +export function acceptsFrame(previous: number | null, current: number, keyframe: boolean): boolean { + // 关键帧永远可以起解 —— 它是参考链的起点。 + if (keyframe) return true; + // 还没有基准:交给调用方的「未配置」逻辑处理。 + if (previous === null) return true; + // 16 位序号会回绕(65535 → 0)。先按模取「前进了多少」,再判断是否为 1。 + return (current - previous + 65536) % 65536 === 1; +} + /** * 给「新建 / 重建的解码器」准备首个 chunk 的载荷。 * diff --git a/apps/desktop/src/renderer/src/styles/cache.css b/apps/desktop/src/renderer/src/styles/cache.css index c8234948b..dd1566bfb 100644 --- a/apps/desktop/src/renderer/src/styles/cache.css +++ b/apps/desktop/src/renderer/src/styles/cache.css @@ -11,6 +11,10 @@ grid-template-columns: 200px minmax(0, 1fr); gap: 14px; height: 100%; + /* 外层可能是 flex 容器(资源管理 / 数据与维护),不 grow 就只会按内容宽度 + * 收缩,右侧内容面板会留下大片空白。min-width 清零防止宽内容顶穿。 */ + flex: 1 1 auto; + min-width: 0; min-height: 0; padding: 14px; /* 透明底:跟随所在页面(维护工具)的界面背景。 */ diff --git a/apps/desktop/src/renderer/src/styles/settings-database.css b/apps/desktop/src/renderer/src/styles/settings-database.css index be4e2f24e..48b51f771 100644 --- a/apps/desktop/src/renderer/src/styles/settings-database.css +++ b/apps/desktop/src/renderer/src/styles/settings-database.css @@ -1,4 +1,8 @@ -.weq-settings-page.weq-settings-body[data-settings-page='database-management'] { +/* 本页内容(DbExplorer)自己撑满高度,不需要外层内边距;留白由页头负责, + * 与普通设置页的 20px 对齐。必须带 html[data-theme] 才压得过 + * page-chrome.css 里 `html[data-theme] .weq-workspace-page .weq-settings-body` + * 的 padding(那一条权重更高,否则这里会被打回 20px,顶部留白翻倍)。 */ +html[data-theme] .weq-settings-page.weq-settings-body[data-settings-page='database-management'] { display: flex; padding: 0; overflow: hidden; @@ -13,11 +17,12 @@ } .weq-settings-database > .weq-set-head { flex-shrink: 0; - padding: 20px 24px 12px; + /* 外层内边距已清零,留白全在这里给;与普通设置页的 20px 对齐。 */ + padding: 20px 20px 12px; } .weq-settings-database > .weq-settings-query-error { flex-shrink: 0; - margin: 0 24px 12px; + margin: 0 20px 12px; } .weq-settings-database > .weq-cache-db { min-width: 0; diff --git a/apps/desktop/src/shared/call_video_ipc.ts b/apps/desktop/src/shared/call_video_ipc.ts new file mode 100644 index 000000000..3de9ac67a --- /dev/null +++ b/apps/desktop/src/shared/call_video_ipc.ts @@ -0,0 +1,31 @@ +/** + * 通话视频帧的**二进制 IPC 通道**。 + * + * 为什么不用 tRPC:视频帧是 640×360 的 I420,一帧 345 KB,15 fps 就是 5 MB/s。 + * tRPC 走 superjson,`Uint8Array` 会被展开成十进制数字数组(JSON 文本 1.2 MB), + * 实测序列化 15 ms、结构化克隆 10 ms、反序列化 5 ms —— 一帧 30 ms,15 fps 下 + * 渲染层主线程根本跑不满,采集、解码、绘制全被拖慢(表现:两端画面都极度卡顿)。 + * + * 走 `ipcRenderer.send` 的原生通道时,`Uint8Array` 直接走结构化克隆(实测 + * 0.17 ms),比 tRPC 快两个数量级,而且不用等往返 —— 视频帧本来就该是 + * fire-and-forget。 + * + * 只用于 **Electron**。web 构建(`apps/web` 复用同一份 renderer)没有 + * `window.weq`,调用方会回退到 tRPC 的 `pushVideoFrame`。 + */ + +/** 渲染层 → 主进程:一帧 I420。 */ +export const CALL_VIDEO_FRAME_CHANNEL = 'call:video-frame'; + +/** 一帧 I420 的三个平面(Y 全尺寸、U/V 各四分之一)。 */ +export interface CallVideoFrame { + y: Uint8Array; + u: Uint8Array; + v: Uint8Array; +} + +/** preload 暴露给渲染层的视频帧桥。 */ +export interface CallVideoBridge { + /** 推一帧 I420(不等待主进程,失败静默)。 */ + pushFrame(frame: CallVideoFrame): void; +} diff --git a/apps/desktop/test/call_video_decode.test.ts b/apps/desktop/test/call_video_decode.test.ts index 5013e55d9..f7a6dfe11 100644 --- a/apps/desktop/test/call_video_decode.test.ts +++ b/apps/desktop/test/call_video_decode.test.ts @@ -19,6 +19,7 @@ import { describe, expect, it } from 'vitest'; import { + acceptsFrame, annexB, concatBytes, payloadForDecoder, @@ -165,3 +166,77 @@ describe('toBytes(tRPC 序列化形态)', () => { expect(toBytes({})).toBeNull(); }); }); + +/** + * 帧序号连续性检查 —— 「Windows 前 15 秒整屏纯绿」的修复。 + * + * 真实场景(2026-10-26 真机日志 + 码流 dump 对齐):订阅建立时补发缓存的关键帧 + * (帧 0),紧接着喂的是实时帧(帧 29),中间 1~28 缺失。Chromium 硬解在拒绝这些 + * 无参考帧的 delta 后内部状态被污染,输出错乱的 YUV(中心 RGB [0,88,0],R/B 恒 0), + * 直到下一个关键帧(帧 257)才恢复 —— 正好约 15 秒。 + * + * 离线验证(tmp/verify_fix.cjs,同一份对端码流):不检查时绿帧 228 帧, + * 加上本检查后 0 帧。 + */ +describe('acceptsFrame(帧序号连续性)', () => { + it('关键帧永远可以起解 —— 它是参考链的起点', () => { + expect(acceptsFrame(null, 0, true)).toBe(true); + expect(acceptsFrame(28, 257, true)).toBe(true); + expect(acceptsFrame(0, 100, true)).toBe(true); + }); + + it('还没有基准时放行,交给调用方的「未配置」逻辑判断', () => { + expect(acceptsFrame(null, 29, false)).toBe(true); + }); + + it('序号连续(+1)时接受', () => { + expect(acceptsFrame(0, 1, false)).toBe(true); + expect(acceptsFrame(28, 29, false)).toBe(true); + }); + + it('序号跳跃时拒绝 —— 这正是绿屏的成因', () => { + // 真实场景:关键帧 0 之后直接来帧 29 + expect(acceptsFrame(0, 29, false)).toBe(false); + expect(acceptsFrame(29, 33, false)).toBe(false); + // 乱序(倒退)同样拒绝 + expect(acceptsFrame(30, 10, false)).toBe(false); + }); + + it('16 位序号正常递增(255 → 256 → 257,不是回绕)', () => { + expect(acceptsFrame(254, 255, false)).toBe(true); + expect(acceptsFrame(255, 256, false)).toBe(true); + expect(acceptsFrame(256, 257, false)).toBe(true); + }); + + it('16 位序号真正回绕(65535 → 0)时仍然连续', () => { + expect(acceptsFrame(65534, 65535, false)).toBe(true); + expect(acceptsFrame(65535, 0, false)).toBe(true); + expect(acceptsFrame(0, 1, false)).toBe(true); + }); + + it('回绕边界上的跳跃照样拒绝', () => { + expect(acceptsFrame(65535, 2, false)).toBe(false); + // 255 → 0 是倒退,不是回绕 + expect(acceptsFrame(255, 0, false)).toBe(false); + }); + + it('真实绿屏序列:0 → 29 全部丢弃,直到关键帧 257 才恢复', () => { + // 复刻 tmp/verify_fix.cjs 的 feed:补发关键帧 0,然后从 29 开始 + const feed = [0, ...Array.from({ length: 271 }, (_, i) => 29 + i)]; + let last: number | null = null; + let fed = 0; + let dropped = 0; + for (const index of feed) { + const keyframe = index === 0 || index === 257; + if (!acceptsFrame(last, index, keyframe)) { + dropped++; + continue; + } + last = index; + fed++; + } + // 0(关键帧)→ 之后 29..256 全丢(228 帧)→ 257(关键帧)起恢复 + expect(dropped).toBe(228); + expect(fed).toBe(44); // 0 + 257..299 + }); +}); diff --git a/apps/desktop/test/location_share_geo.test.ts b/apps/desktop/test/location_share_geo.test.ts new file mode 100644 index 000000000..134453e9b --- /dev/null +++ b/apps/desktop/test/location_share_geo.test.ts @@ -0,0 +1,90 @@ +/** + * 位置共享的地理换算单测。 + * + * 只测纯函数(摇杆 → 经纬度增量、多人视野拟合),不碰 React / DOM —— + * 这两个函数出错的表现是「摇杆方向反了」「看全部时有人跑出画面」, + * 在界面上很难一眼看出来,值得钉住。 + */ + +import { describe, expect, it } from 'vitest'; +import { offsetLatLng } from '../src/renderer/src/im-template/template/locationShareJoystick'; +import { fitView } from '../src/renderer/src/im-template/template/locationShareMap'; + +describe('offsetLatLng', () => { + it('向北 111.32 米约等于 +0.001 度纬度', () => { + const next = offsetLatLng(30, 104, 111.32, 0); + expect(next.latitude).toBeCloseTo(30.001, 5); + expect(next.longitude).toBeCloseTo(104, 6); + }); + + it('向东同样距离时,经度增量随纬度放大(高纬更「窄」)', () => { + const low = offsetLatLng(0, 0, 0, 1000); + const high = offsetLatLng(60, 0, 0, 1000); + // 纬度越高,同样的东向距离对应的经度变化越大。 + expect(high.longitude).toBeGreaterThan(low.longitude); + // 赤道上 1000 米 ≈ 0.00898 度。 + expect(low.longitude).toBeCloseTo(1000 / 111320, 6); + }); + + it('纬度被夹在 ±85 度内(Web Mercator 可渲染范围)', () => { + const next = offsetLatLng(84, 0, 1_000_000, 0); + expect(next.latitude).toBeLessThanOrEqual(85); + }); + + it('经度跨 180 度时回绕到 [-180, 180)', () => { + const next = offsetLatLng(0, 179.999, 0, 10_000); + expect(next.longitude).toBeGreaterThanOrEqual(-180); + expect(next.longitude).toBeLessThan(180); + }); +}); + +describe('fitView', () => { + const SIZE = { width: 400, height: 300 }; + + it('空列表返回 null', () => { + expect(fitView([], SIZE)).toBeNull(); + }); + + it('单个点给一个较近的缩放级别', () => { + const view = fitView([{ longitude: 104, latitude: 30 }], SIZE); + expect(view).not.toBeNull(); + expect(view!.lng).toBeCloseTo(104, 4); + expect(view!.lat).toBeCloseTo(30, 4); + expect(view!.zoom).toBe(16); + }); + + it('两个点都被收进视野(中心在两点中间)', () => { + const points = [ + { longitude: 104.0, latitude: 30.0 }, + { longitude: 104.02, latitude: 30.02 }, + ]; + const view = fitView(points, SIZE)!; + expect(view.lng).toBeCloseTo(104.01, 3); + expect(view.lat).toBeCloseTo(30.01, 3); + // 缩放要够大才能同时看到这两点(约 2km 跨度)。 + expect(view.zoom).toBeGreaterThan(10); + expect(view.zoom).toBeLessThanOrEqual(18); + }); + + it('点越分散,缩放越小', () => { + const tight = fitView( + [ + { longitude: 104, latitude: 30 }, + { longitude: 104.001, latitude: 30.001 }, + ], + SIZE, + )!; + const wide = fitView( + [ + { longitude: 104, latitude: 30 }, + { longitude: 105, latitude: 31 }, + ], + SIZE, + )!; + expect(wide.zoom).toBeLessThan(tight.zoom); + }); + + it('尺寸为 0 时返回 null(还没测量出容器)', () => { + expect(fitView([{ longitude: 104, latitude: 30 }], { width: 0, height: 0 })).toBeNull(); + }); +}); diff --git a/apps/desktop/test/package_size.test.ts b/apps/desktop/test/package_size.test.ts index c9a9dc448..004605d27 100644 --- a/apps/desktop/test/package_size.test.ts +++ b/apps/desktop/test/package_size.test.ts @@ -11,7 +11,17 @@ import { } from 'node:fs'; import { join, resolve } from 'node:path'; import ts from 'typescript'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; + +// `trimDesktopPackage` 在签名前会校验包内的 remote-network 辅助程序(发布期 +// 门禁)。这个用例只关心 resvg Darwin 绑定的裁剪,临时目录里没有那个二进制, +// 所以把校验桩掉 —— 它自己的行为由 `scripts/package-remote-network.test.mjs` +// 和 `authorization_package_hooks.test.ts` 覆盖。 +vi.mock('../../../scripts/package-remote-network.mjs', async (importOriginal) => ({ + ...(await importOriginal()), + verifyPackagedRemoteNetwork: vi.fn(), +})); + import { trimDesktopPackage } from '../../../scripts/trim-desktop-package.mjs'; const desktop = resolve(__dirname, '..'); diff --git a/apps/web/package.json b/apps/web/package.json index 854afe93d..51f1225f4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -37,11 +37,11 @@ "@types/archiver": "^5.3.4", "@types/node": "^22.10.6", "@types/ws": "^8.5.13", - "@vitejs/plugin-react": "^4.3.4", + "@vitejs/plugin-react": "^6.1.2", "esbuild": "^0.28.1", "tailwindcss": "^4.0.0", "tsx": "^4.22.4", "typescript": "^5.7.3", - "vite": "^6.0.0" + "vite": "^8.3.4" } } diff --git a/docs/develop/video-call-20261025.md b/docs/develop/video-call-20261025.md index 2feafc842..092196eab 100644 --- a/docs/develop/video-call-20261025.md +++ b/docs/develop/video-call-20261025.md @@ -61,11 +61,40 @@ H.265 Annex B 以 00 00 00 01 开头,**明文** **分片规则**(2026-10-25 21 秒真实通话,451 帧全量统计): - 整帧**等分**,末片自然短。单片 payload 最大 1277 B,UDP 包最大 1303 B; -- `total_fec` 恒 0,`fidx` 恒 1(c2c 不启用增强层); -- `pkg_idx` 帧内从 0 递增,`total_pkg` 实测 1–14; +- `total_fec` 恒 0;`pkg_idx` 帧内从 0 递增,`total_pkg` 实测 1–14; - **`seq` 每片 +1**,整通连续(实测 0…717),不按帧重置; - **外层 `tick` 帧内恒定**,每帧换一次。 +### 3.2.0 `gidx` / `fidx`:16 位帧序号(2026-10-26 修正) + +**`fidx` 不是 SVC 层号,是帧序号的高字节。** `gidx` 是低字节,每帧 +1 并在 +256 处回绕;`fidx` 从 1 起,跟着回绕 +1: + +``` +frameIndex = ((fidx - 1) << 8) | gidx +``` + +真机日志(连续 600 帧,2026-10-25)逐段取样: + +| 日志 gidx / fidx | 拼出的帧序号 | +| --- | --- | +| g=0, f=1 | 0 | +| g=249, f=1 | 249 | +| g=43, f=2 | 299 | +| g=243, f=2 | 499 | +| g=37, f=3 | 549 | +| g=87, f=3 | 599 | + +每 50 帧一档,全程连续;中途关 / 开摄像头也不重置。同一时刻只有一个 `fidx` +值在发,**不存在「基础层 + 增强层」并行**。 + +> 早期结论「fidx=1 基础层 / fidx>=2 时域增强层」是错的,代价是一个真 bug:主进程 +> 据此丢弃 `fidx>=2` 的帧,而 `lastFrameWasBase` 停在 true 后不再更新 —— 通话到 +> 第 256 帧(10 fps 下约 25 秒)之后画面**永久冻结**。发送侧同样踩了这个坑: +> `gidx = frameIndex & 0xff` 且 `fidx` 恒 1,第 256 帧包头从 `g=255/f=1` 跳回 +> `g=0/f=1`,帧序号倒退。两侧都已改成 16 位帧序号(`videoFrameIndex` / +> `videoFrameHeader`)。 + **sub-header 20 字节**(`pkg_idx=0` 的那片才有,后续分片是裸 Annex B 续): | 偏移 | 含义 | @@ -75,18 +104,34 @@ H.265 Annex B 以 00 00 00 01 开头,**明文** | `[2]` | **方向标识**:c2s=`0x00`、s2c=`0x03` | | `[3]` | 恒 `0x06`(**首帧也是**,早期「首帧 03」是误判) | | `[4:6]` | 恒 0 | -| `[6:8]` | **未还原**(每帧变化,对不上 CRC16 / 校验和 / 长度) | +| `[6:8]` | 码率档 / 帧质量(见下) | | `[8:12]` | 时间戳,布局随 `[2]` 变(见下表) | -| `[12:16]` | 会话基准 tick(c2s)/ 0(s2c) | -| `[16:20]` | **未还原**(实测小整数 0–5) | +| `[12:16]` | **对端视频流的时间基准**(见下)/ 0(s2c) | +| `[16:20]` | 未还原,实测是 `0..totalPkg-1` 里的一个值 | 时间戳布局: | 方向 | `[2]` | `[8:12]` | `[12:16]` | | --- | --- | --- | --- | -| c2s(QQ Linux 发) | `0x00` | 帧 PTS(0,100,200… ms,10 fps) | 会话基准 tick,整通恒定 | +| c2s(QQ Linux 发) | `0x00` | 帧 PTS(0,100,200… ms,10 fps) | 对端视频基准,见下 | | s2c(QQ Windows 发) | `0x03` | 发送方 tick(每帧变) | 0 | +### `[12:16]` 是对端视频流的时间基准(2026-10-26 修正) + +2026-10-26 QQ 本体抓包逐帧统计(239 帧):**对端还没开始发视频时,这个字段恒为 +0(前 128 帧)**;收到对端首个视频帧后(该帧 `[8:12]` = `1952894717`),从第 128 +帧起改填这个值,**之后整通不变**。 + +语义是「我跟对端的哪个时间基准对齐」,不是本端接通的 tick。早期实现从第一帧就填 +本端的 `mediaBaseTick`,与真实客户端不符 —— 这很可能是对端(尤其手机端)不认我们 +视频流的原因之一。 + +### `[6:8]` 是码率档 / 帧质量(2026-10-26 修正) + +来自 `GetVideoFrameQuality()`:`[6]` 是估算码率档、`[7]` 是帧质量。实测值随分辨率 +变化 —— 480×270 时 28–33 / 36–41,640×360 时 36–44 / 40–48。对端只拿它做统计, +不参与解码,按真实分布抽样即可。 + ⚠️ 这个差异踩过坑:早期实现把 `[2]` 恒写成 `0x03`,等于用对端方向的格式发自己的 上行数据。构造侧必须按方向分支。 @@ -152,13 +197,58 @@ body:`[0]=0x02`,`[4:8]` 是 tick 的小端副本,其余为 0。 所以单看事件号分不出「开」还是「关」,得配合 `videoReceiving` 的当前值。实现 里没有依赖这个事件,只用来记日志。 -视频开始前还会出现两条未完全还原的控制面消息: +### 4.1 视频流状态握手(`bt=31`) + +视频流建立依赖 `bt=31` 的四步状态交换,**不能当成普通未知推送只回 ACK**: + +```text +event=4(振铃) + C→S bt31 body=0803 (带房间 authKey) + S→C bt32 body=0803 + S→C bt31 body=0806 + C→S bt32 body= -- `bt=31`:body.f1=4,一次性; -- `bt=77`:head.f5 带 88 字节密钥材料,body.f3 是流索引(0/1/2/3 递增)。 - 视频载荷是明文,这份材料没有用在载荷上。 +S→C bt31 body=0804 + C→S bt32 body= 普通 ACK + C→S bt31 body=0805 状态应答,同样带 authKey + S→C bt32 body=0805 +``` + +QQ 电脑→电脑和电脑→手机的原生抓包中都能观察到这组状态交换。WeQ 旧实现把 +`bt=31` 当成普通未知推送,只回 ACK,缺少 `0803/0805` 这两个有效的状态值。 + +### 4.2 视频参数是服务端推送(`bt=77`) + +对照抓包(QQ 电脑→电脑、QQ 电脑→手机、WeQ→手机)确认: + +- `bt=77` **只由服务端下发**,两个原生客户端都是 client→server 0 条; +- head.f5 带 88 字节密钥材料; +- body 有两种形态:13 字节(流索引)和 52 字节(完整视频参数); +- body.f3 是流索引(0/1/2/3…),服务端会随会话推进持续更新; +- `f14/f15` 与目标端相关:电脑对端样本是 1920×1200,手机对端是 640×480; +- 收到 `bt=77` 后回普通空 `bt=78` ACK 即可,视频载荷仍是明文。 + +服务端在 `event=10` 接通前就会下发第一条 `bt=77`,说明它不是客户端摄像头 +开关公告。客户端不应伪造这条包。 + +### 4.3 接听前如何知道对端设备 + +`bt=1` 认证 body 在三份抓包里完全相同,不能用来判断对端。服务端在 +`bt=3/event=4` 的 `f2` 子消息里携带了对端媒体描述。实测值: -两者都只需 ACK,现有代码的「未知推送一律 ACK」已经覆盖。 +| 字段 | 对端电脑 | 对端手机 | +| --- | ---: | ---: | +| `info.f6` | 5 | 4 | +| `info.f13` | 700 | 5000 | +| `info.f15` | 10063 | 10064 | +| `info.f16` | 94 | 62 | +| `info.f19` | 0 | 1 | + +这些值在接通事件前已经到达;随后服务端再按目标端下发对应的 `bt=77` +(尤其 `f14/f15`)。当前实现不根据本机平台猜测对端,直接按统一状态机应答。 + +`bt=15` 的 `f133/f134/f135` 在通话阶段会随视频状态变化,当前尚未把所有转移 +都还原完整;没有逐字段抓包证据时不要用本地 endpoint 或编码参数现场拼这些字段。 ## 5. 实现位置 @@ -168,6 +258,7 @@ body:`[0]=0x02`,`[4:8]` 是 tick 的小端副本,其余为 0。 | 信令档位 | `packages/protocol/src/call/signaling.ts`(`MEDIA_PROFILE`) | | 会话收发与状态 | `packages/service/src/account/call_session.ts` | | 本端采集与 t265 编码 | `apps/desktop/src/main/call_video_encoder.ts` | +| 上行帧的二进制 IPC | `apps/desktop/src/shared/call_video_ipc.ts` + `main/call_video_ipc.ts` | | 渲染层解码(WebCodecs) | `apps/desktop/src/renderer/src/hooks/useCallVideo.ts` | | 解码的纯逻辑(可单测) | `apps/desktop/src/renderer/src/lib/callVideo.ts` | @@ -177,6 +268,18 @@ body:`[0]=0x02`,`[4:8]` 是 tick 的小端副本,其余为 0。 - `CallSession.setCamera(enabled)` —— 开 / 关本端摄像头(发控制包); - `CallVideoEncoder` —— 主进程注入的编码器,`next()` 吐一帧裸流。 +### 5.1 上行帧走二进制 IPC,不走 tRPC(2026-10-26) + +采集在渲染层、编码在主进程,中间要传一帧 640×360 的 I420(345 KB)。**不要用 +tRPC 传它**:tRPC 的 superjson 会把 `Uint8Array` 展开成十进制数字数组,实测一帧 +序列化 15 ms + 结构化克隆 10 ms + 反序列化 5 ms = 30 ms,15 fps 下渲染层主线程被 +吃满,采集、解码、绘制一起卡(两端画面都卡)。 + +现在走 `window.weq.callVideo.pushFrame()` → `ipcRenderer.send` 的原生通道, +`Uint8Array` 直接结构化克隆(实测 0.17 ms,快两个数量级),且天然 fire-and-forget。 +web 构建(`apps/web` 复用同一份 renderer)没有这个桥,调用方回退到 tRPC 的 +`pushVideoFrame`。 + ## 6. 渲染层解码:参数集必须内联 Chromium 的 WebCodecs HEVC 解码器对 `VideoDecoderConfig.description` 的要求很挑 @@ -229,14 +332,55 @@ Chromium 的 WebCodecs HEVC 解码器对 `VideoDecoderConfig.description` 的要 ## 7. 未完成 -### 7.1 sub-header 两个未解字段 +### 7.1 sub-header 未解字段:`[16:20]` -| 字段 | 实测值 | 排查过的假设 | -| --- | --- | --- | -| `[6:8]` | 每帧变化(6948 / 8746 / 8233…) | CRC16-CCITT / CRC16-IBM / 字节和 / 16 位字和 / 帧长 / 分片数,**全部对不上** | -| `[16:20]` | 小整数 0–5(偶尔更大) | 疑似分片数相关(`total_pkg-1` 只对上一部分),未确认 | +2026-10-26 抓包逐帧统计(双向 335 帧): -当前实现两个字段都填 0。若对端仍不认我们发的画面,优先回头试这两个。 +| `totalPkg` | `[16:20]` 取值 | +| --- | --- | +| 1 | 恒 0 | +| 2 | 0–1 | +| 3 | 0–2 | +| 4 | 0–3 | +| 5 | 0–4 | +| 6–8 | 0–total-1 | + +也就是说它是 `0..totalPkg-1` 里的一个值,**不是** `totalPkg - 1` 定值(早先这么 +填是错的),也不是帧内分片计数。看起来像「帧内第几个分片携带了某个标记」,具体 +规则未确认。当前实现用随机值对齐分布。 + +`[6:8]` 已确认来自 `GetVideoFrameQuality()`(`[6]`=码率档、`[7]`=帧质量,关键帧 +整体低一档),对端只拿它做统计,不参与解码,按真实分布抽样即可。 + +### 7.1.1 关键帧间隔:WeQ 比真实客户端密得多 + +实测 QQ 本体**一整通 19.8 秒只发 1 个关键帧**(首帧),对端同样只有 1 个。 +WeQ 的 `t265` 配置是 `keyint=30`,15 fps 下**每 2 秒一个关键帧**,码流里 I 帧 +占比高一个量级。 + +这是「对端看 WeQ 画面卡」的可疑原因之一(I 帧大、瞬时码率尖峰大,弱网下更容易 +丢片 → 整帧丢弃 → 等下一个关键帧)。真实客户端显然用了很大的 keyint 或纯按需 +插帧,待抓 WeQ 包对比后确认要不要调大。 + +### 7.1.2 三个让媒体退化成「全程中继」的坑(2026-10-26 实测) + +对照 QQ 本体抓包(同样直接拨视频,走 P2P `...:64927`,1919 包)与 WeQ 抓包 +(全程中继 `113.250.57.28:8800`,10842 包),挖出三处: + +1. **GCS 选到了中继端口。** 服务端下发的 `type` 字段不可信:16 通里 5 通把 + `type=1` 的候选选到了 8800(中继)。认证、心跳、`bt=9` 全发给中继节点, + 而中继**从不给对端的 `f7`(IPv6)**。判据改成按端口:8000/8001 是真 GCS。 +2. **服务器回包被学成「对端直连地址」。** socket 是 udp6 双栈,IPv4 来源在 + `rinfo.address` 里是 `::ffff:a.b.c.d`,服务器表里是 `a.b.c.d` —— 直接字符串 + 比较永远不等。于是服务器自己的回包把 `peerDirect` 改写成服务器地址,媒体路径 + 切到 `direct` 后其实仍发往同一台服务器(同 IP 同端口),「以为直连了,其实 + 全程中继」。必须做 IPv4-mapped 归一化再比。 +3. **`bt=9` 的形态随服务器角色变化。** 真 GCS 下发的是 `f7`(16 字节 IPv6)+ + `f8`(端口);中继下发的是 `f1`(fixed32 IPv4 + 端口)且**没有 f7**。所以 + 「拿不到 f7」本身就是「连错服务器」的症状,不是另一个独立问题。 + +三个坑叠加的结果:媒体包全部挤在一条服务端通道上(实测 2 万+ 包),丢包与排队 +严重 —— 表现为两端画面都极卡、前 20 秒花屏。 ### 7.2 P2P 端口协商 diff --git a/native/pinned.json b/native/pinned.json index 8960c94d0..6397f65c5 100644 --- a/native/pinned.json +++ b/native/pinned.json @@ -1,8 +1,8 @@ { - "tag": "nt-helper-20261026-bb174b2", - "commit": "bb174b25b88b5de0c572a88e6a83c9d451f4688d", - "builtAt": "2026-10-26T09:33:51.000Z", - "expiresAt": "2026-11-25T09:33:51.000Z", + "tag": "nt-helper-20261026-8e7cf66", + "commit": "8e7cf669a5a59e6fbe99cddf35948efd558a6580", + "builtAt": "2026-10-26T20:13:18.000Z", + "expiresAt": "2026-11-25T20:13:18.000Z", "pinnedAt": "2026-10-26", "note": "WeQ 认这一份 native 构建(pnpm native:fetch 默认取它)。升级:pnpm native:fetch --latest --verify 先验证新构建,再 pnpm native:fetch --latest --write-pin 写回这里并提交。" } diff --git a/packages/protocol/src/call/gcs.ts b/packages/protocol/src/call/gcs.ts index 4b39e3025..1405356c9 100644 --- a/packages/protocol/src/call/gcs.ts +++ b/packages/protocol/src/call/gcs.ts @@ -198,10 +198,34 @@ export const GcsBodyType = { statusReportAck: 16, heartbeat: 23, heartbeatAck: 24, + /** + * 本端 P2P 端点公告(client→server,24 字节)。见 {@link buildPeerJoinReport}。 + * + * **这是 P2P 能建立的开关。** 不发这条,服务端不知道我们的 P2P 端口,对端也就 + * 永远拿不到我们的端点 —— 表现就是「对端很久才收到我的画面」+「全程中继」。 + */ + peerJoinReport: 25, + peerJoinReportAck: 26, + /** 对端 P2P 端点推送(server→client)。见 {@link parsePeerJoin}。 */ peerJoin: 27, peerJoinAck: 28, + /** + * 视频流状态推送(server→client)。 + * + * 真机上 `f1=4` 表示服务端请求本端确认视频流;客户端还需另发一条同类型的 + * `f1=5`,并保留普通空 ACK(`bt=32`)。见 {@link buildVideoState}。 + */ + videoStatePush: 31, + videoStateAck: 32, mediaCtrlPush: 75, mediaCtrlAck: 76, + /** + * 视频流参数推送(仅 server→client)。 + * + * 52 字节 body 包含分辨率 / 码率、目标端尺寸和流索引。真机客户端不会主动 + * 发送这条;收到后只需普通 ACK(`bt=78`)。见 {@link buildVideoParamPush}。 + */ + videoParamPush: 77, logout: 1003, } as const; @@ -276,13 +300,28 @@ export function parseGcsPacket(data: Uint8Array): GcsPacket | null { /** * 认证能力集。 * - * 一段固定的媒体能力协商描述:10 组编码能力 + 音频 / 视频 / 网络配置,描述 + * 一段固定的媒体能力协商描述:18 组编码能力 + 音频 / 视频 / 网络配置,描述 * 「本端能解哪些编码、带宽多少、要不要回声消除」。服务端拿它给房间选路, * 内容不必逐字段构造 —— 但**必须存在且能解析**,空 body 会被直接断开。 + * + * 2026-10-26 逐字节替换为 QQ 本体(Linux 桌面版)实际发送的原文。旧模板是从 + * 语音通话抓的,与视频通话有实质差异,服务端据此可能不把本端当视频/P2P 候选: + * + * | 字段 | QQ 本体(语音/视频一致) | 旧模板 | + * | --- | --- | --- | + * | `f3` | 15 | 5 | + * | `f6` | 10063 | 10058 | + * | `f11` | 1812048064 | 26847424 | + * | 内层 `f5`/`f6` | 0 / 0(由协商决定) | 2560 / 1440(硬编码) | + * | 能力条目 | 前 2 条为空 | 前 2 条是 `f1=2`(H.264 480×640@20) | + * | `f21` | 2 | 1 | + * + * 注意 `f3=15` / `f6=10063` 恰好等于 `MEDIA_PROFILE.video` 的 + * `inviteF3` / `inviteF4` —— 能力集与信令档位是配套的。 */ export const DEFAULT_CAPABILITIES: Uint8Array = Uint8Array.from( atob( - 'CtoDChkIAhABGOADIIAFKBQwgAg4wLsBQAFIAFABChkIAhACGOADIIAFKBQwgAg4wLsBQAFIAFABChkIAxABGLAJIIAPKA8wgCg42J4IQAFIAFABChkIAxACGLAJIIAPKB4wgCg4sL0QQAFIAFABChkIBBABGLAJIIAPKA8wgCg42J4IQAFIAFABChkIBBACGLAJIIAPKB4wgCg4sL0QQAFIAFABChkIBRABGLAJIIAPKA8wgCg42J4IQAFIAFABChkIBRACGLAJIIAPKB4wgCg4sL0QQAFIAFABChsIBhABGOADIIAFKA8wgCg40L2roAtAAUgAUAEKGwgGEAIY4AMggAUoDzCAKDi4vqugC0ABSABQAQoACgAKAAoACgAKAAoACgASXQgAEAAYACJSh2VDIQACACkDIA+gC7gAfgDAACsCWA+gBdwAfgDAAEAJYAyACWAAfgDAAEEJYBkACWAAfgDAAEIJYBkACWAAfgDAAFADIA+gC7gAfgDAEjRWeCoBABogCghTaGFycENhbRIICOADEOgCGB4SCAiABRDgAxgeGAEiFQi4FxABGAYgACgAMABKAnBjWgJwYyiAFDCgC0ABSAFQAFgCaAFwAXgBgAEBiAEHmAECqAEBEAEYBShkMMpOOAFAAUoAWMDR5gxqBAgDEAJyBggAEAAYAIIBVwoiMTAwMTNfYXVkaW9fY29uZmlnXzEwMDExX3Blbmd1aW5zXxIVMjA0X3ZpZGVvX2NvbmZpZ18yMDRfGhoxMDA1MV9jb21tb25fY29uZmlnXzEwMDUxX4oBDggAEgAiBGlvc18qADIAkAEAmAEA', + 'CtMDCgAKAAoZCAMQARiwCSCADygPMIAoONieCEABSABQAQoZCAMQAhiwCSCADygeMIAoOLC9EEABSABQAQoZCAQQARiwCSCADygPMIAoONieCEABSABQAQoZCAQQAhiwCSCADygeMIAoOLC9EEABSABQAQoZCAUQARiwCSCADygPMIAoONieCEABSABQAQoZCAUQAhiwCSCADygeMIAoOLC9EEABSABQAQoXCAYQARjgAyCABSgPMIAoOABAAUgAUAEKFwgGEAIY4AMggAUoDzCAKDgAQAFIAFABCgAKAAoACgAKAAoACgAKABJpCAAQABgAIl6HZUMhAAIAKQMgD6ALuAB+AMAAKwJYD6AF3AB+AMAALgMgD6AJxAB+AMAAQAlgDIAJYAB+AMAAQQlgGQAJYAB+AMAAQglgGQAJYAB+AMAAUAMgD6ALuAB+AMASNFZ4KgEAGgwKCFNoYXJwQ2FtGAEiDQi4FxABGAYgACgAMAAoADAAQAFIAVAAWAJiCwgGEPABGMACIJBOYgwIBhDoAhjgAyDwkwliDAgGEOADGIAFILCuFWIMCAYQ0AUYgAoggOowYgwIBhC4CBiADyCg4WdoAXABeAGAAQGIAQeYAQKoAQIQARgPKGQwz044AUABSgBYwNGG4AZqBAgDEAJyBggAEAAYAIIBVwoiMTAwMTNfYXVkaW9fY29uZmlnXzEwMDExX3Blbmd1aW5zXxIVMjA0X3ZpZGVvX2NvbmZpZ18yMDRfGhoxMDA1MV9jb21tb25fY29uZmlnXzEwMDUxX4oBDggAEgAiBGlvc18qADIAkAEAmAEA', ) .split('') .map((c) => c.charCodeAt(0)), @@ -338,11 +377,8 @@ export function buildGcsAck(params: { authKey?: Uint8Array; }): Uint8Array { const isMediaCtrl = params.pushBodyType === GcsBodyType.mediaCtrlPush; - const errorCode = params.pushBodyType === GcsBodyType.ctrlPush ? 983041 : 0; let body: Uint8Array = new Uint8Array(0); - if (params.pushBodyType === GcsBodyType.ctrlPush) { - body = Uint8Array.from([0x08, 0x02]); - } else if (isMediaCtrl && params.pushBody) { + if (isMediaCtrl && params.pushBody) { const name = pbGetBytes(params.pushBody, 1); const type = pbGetVarint(params.pushBody, 2) ?? 1; if (name && name.length > 0) { @@ -356,7 +392,6 @@ export function buildGcsAck(params: { typeId: params.typeId, authKey: isMediaCtrl ? params.authKey : undefined, bodyType: params.pushBodyType + 1, - errorCode, }); return buildGcsPacket(head, body); } @@ -386,6 +421,159 @@ export function buildGcsLogout(params: { return buildGcsPacket(head, pbVarint(1, params.reason ?? 2)); } +/** + * 视频流参数推送(`bt=77`,52 字节 body,**server→client**)。 + * + * 2026-10-26 三通对照抓包(QQ 电脑→电脑、QQ 电脑→手机、WeQ→手机)确认: + * 两侧原生客户端都不发这条,只由服务端在接听前下发并按流索引持续更新。 + * 它描述目标端准备接收的视频规格,不是「本端摄像头已开」的公告。 + * + * 载荷结构(逐字节还原,总长 52 字节): + * + * ```text + * 12 25 f2,37 字节 + * 10 a2 02 .f2 = 290 + * 18 01 .f3 = 1 + * 22 1e .f4,30 字节 + * 08 0f ..f1 = 15 fps + * 10 01 ..f2 = 1 + * 18 80 05 ..f3 = 640 宽 + * 20 e0 03 ..f4 = 480 高 + * 28 80 28 ..f5 = 5120 码率 kbps + * 30 00 ..f6 = 0 + * 38 88 27 ..f7 = 5000 + * 50 80 05 ..f10 = 640 + * 58 e0 03 ..f11 = 480 + * 70 80 05 ..f14 = 640 目标端宽 + * 78 e0 03 ..f15 = 480 目标端高 + * 18 00 f3 = 流索引(0/1/2/3…) + * 3a 09 f7 = 流元数据 + * 08 01 .f1 = 1 + * 10 91 83 bd 03 .f2 = 7291281 + * 20 01 .f4 = 1 + * ``` + * + * 电脑对端样本的 `f14/f15` 是 1920×1200;手机对端是 640×480。 + * 该函数保留用于黄金样本与测试,**不要在客户端开摄像头时发送**。 + */ +export function buildVideoParamPush(params: { + roomId: bigint; + selfUin: number; + sessionTs: number; + typeId: number; + width: number; + height: number; + fps: number; + /** 码率(kbps)。 */ + bitrateKbps: number; + /** 目标端展示尺寸;缺省与编码尺寸一致。 */ + displayWidth?: number; + displayHeight?: number; + /** 服务端流索引;缺省 0。 */ + streamIndex?: number; +}): Uint8Array { + const head = buildGcsHead({ + roomId: params.roomId, + selfUin: params.selfUin, + sessionTs: params.sessionTs, + typeId: params.typeId, + bodyType: GcsBodyType.videoParamPush, + }); + const videoParams = concat([ + pbVarint(1, params.fps), + pbVarint(2, 1), + pbVarint(3, params.width), + pbVarint(4, params.height), + pbVarint(5, params.bitrateKbps), + pbVarint(6, 0), + pbVarint(7, 5000), + pbVarint(10, params.width), + pbVarint(11, params.height), + pbVarint(14, params.displayWidth ?? params.width), + pbVarint(15, params.displayHeight ?? params.height), + ]); + const inner = concat([pbVarint(2, 290), pbVarint(3, 1), pbBytes(4, videoParams)]); + const streamInfo = concat([pbVarint(1, 1), pbVarint(2, 7291281), pbVarint(4, 1)]); + const body = concat([ + pbBytes(2, inner), + pbVarint(3, params.streamIndex ?? 0), + pbBytes(7, streamInfo), + ]); + return buildGcsPacket(head, body); +} + +/** + * 视频流索引推送(bt=77,13 字节形态)。 + * + * ```text + * 18 00 f3 = 流索引(0/1/2/3/4/5 递增) + * 3a 09 f7 bytes(9) + * 08 01 .f1 = 1 + * 10 91 83 bd 03 .f2 = 7291281 + * 20 01 .f4 = 1 + * ``` + * + * 实测在视频开始前/中随流建立推送,`f3` 每次 +1。与 52 字节那条是同一 bt 的 + * 两种 body 形态,靠 body 长度与字段区分。 + */ +export function buildVideoStreamPush(params: { + roomId: bigint; + selfUin: number; + sessionTs: number; + typeId: number; + /** 流索引,从 0 起递增。 */ + streamIndex: number; +}): Uint8Array { + const head = buildGcsHead({ + roomId: params.roomId, + selfUin: params.selfUin, + sessionTs: params.sessionTs, + typeId: params.typeId, + bodyType: GcsBodyType.videoParamPush, + }); + const streamInfo = concat([pbVarint(1, 1), pbVarint(2, 7291281), pbVarint(4, 1)]); + const body = concat([pbVarint(3, params.streamIndex), pbBytes(7, streamInfo)]); + return buildGcsPacket(head, body); +} + +/** + * 视频流状态消息(`bt=31`,client↔server)。 + * + * 三通原生抓包中,视频通话的 caller 在收到 `event=4` 后发送 `f1=3`;服务端 + * 下发 `f1=4` 时,客户端除普通空 ACK 外还必须回一条 `f1=5`。客户端发出的 + * 两条都带 88 字节房间 authKey。当前只实现主动发起,因此只构造 3 / 5。 + */ +export function buildVideoState(params: { + roomId: bigint; + selfUin: number; + sessionTs: number; + typeId: number; + authKey: Uint8Array; + state: number; +}): Uint8Array { + const head = buildGcsHead({ + roomId: params.roomId, + selfUin: params.selfUin, + sessionTs: params.sessionTs, + typeId: params.typeId, + authKey: params.authKey, + bodyType: GcsBodyType.videoStatePush, + }); + return buildGcsPacket(head, pbVarint(1, params.state)); +} + +/** `bt=31` 的线上状态值。 */ +export const VideoStateCode = { + /** C→S:客户端在振铃阶段发起视频流状态协商。 */ + start: 3, + /** S→C:服务端请求本端确认视频流。 */ + offer: 4, + /** C→S:确认服务端的 offer。 */ + accept: 5, + /** S→C:服务端确认客户端的 start。 */ + ready: 6, +} as const; + /** 房间事件推送(bt=3)里的事件类型。 */ export const CallEvent = { invited: 1, @@ -446,7 +634,10 @@ export function buildStatusReport(params: { sendBytes: number; /** 本次通话内的第几次上报,从 1 起。 */ seq: number; + /** 当前是否已在发视频;只切换抓包中已确认会变化的字段。 */ + media?: 'audio' | 'video'; }): Uint8Array { + const videoCall = params.media === 'video'; const tick = Date.now() >>> 0; const parts: Uint8Array[] = []; parts.push(pbBytes(1, concat([encodeVarint(tick), encodeVarint((tick + 2000) >>> 0)]))); @@ -462,9 +653,10 @@ export function buildStatusReport(params: { parts.push(pbVarint(13, params.seq === 1 ? 1 : 0)); parts.push(pbVarint(14, params.sendBytes)); parts.push(pbVarint(15, 0)); - parts.push(pbVarint(16, 3)); // 网络类型:3 = 有线 + // 原生视频样本固定为 1;旧语音模板的 3 会让服务端把它当成另一种终端。 + parts.push(pbVarint(16, videoCall ? 1 : 3)); parts.push(pbVarint(17, 1)); - // f18:网络描述(34 字节),运营商 / 链路类型 / 带宽,照抄真实客户端。 + // f18:网络描述,沿用已通过真机校验的固定样本。 parts.push( pbBytes( 18, @@ -481,8 +673,9 @@ export function buildStatusReport(params: { parts.push(pbVarint(22, 100)); // 质量分 for (let i = 23; i <= 33; i++) parts.push(pbVarint(i, 0)); parts.push(pbVarint(36, 0)); - for (let i = 38; i <= 40; i++) parts.push(pbVarint(i, 0)); - parts.push(pbVarint(42, 26280)); // 发送码率 + for (let i = 38; i <= 39; i++) parts.push(pbVarint(i, 0)); + parts.push(pbVarint(40, videoCall ? 2 : 0)); + parts.push(pbVarint(42, videoCall ? 45000 : 0)); // 收发带宽预算 for (let i = 43; i <= 45; i++) parts.push(pbVarint(i, 0)); for (let i = 46; i <= 53; i++) parts.push(pbVarint(i, 0xffff)); parts.push(pbVarint(56, 0)); @@ -501,8 +694,13 @@ export function buildStatusReport(params: { parts.push(pbVarint(105, 0)); parts.push(pbVarint(110, 0)); parts.push(pbVarint(111, params.seq)); - for (let i = 112; i <= 115; i++) parts.push(pbVarint(i, 0)); - parts.push(pbVarint(120, 26847424)); + parts.push(pbVarint(112, params.seq >= 3 ? 1 : 0)); + parts.push(pbVarint(113, 0)); + parts.push(pbVarint(114, params.seq >= 3 ? (videoCall ? 6553600 : 2621440) : 0)); + parts.push(pbVarint(115, 0)); + // 与 DEFAULT_CAPABILITIES.f11 一致。原生语音通话也声明视频能力, + // 否则之后动态打开摄像头时服务端不会补发完整的 bt=77 视频流参数。 + parts.push(pbVarint(120, 1812048064)); parts.push(pbVarint(123, 0)); for (let i = 125; i <= 130; i++) parts.push(pbVarint(i, 0)); parts.push(pbVarint(132, 0)); @@ -543,77 +741,320 @@ export function buildStatusReport(params: { // ═══════════════════════════ Relay (0x02) ═══════════════════════════ export const RELAY_STX = 0x02; -export const RELAY_HEADER_LEN = 37; + +/** + * `0x02` 的固定头长度(body 从 39 开始,最后一字节恒 `0x03`)。 + * + * 2026-10-26 QQ 本体抓包逐字节核对:`[35:39]` 是 4 字节 token(= 邀请回包的 + * authToken),它跨在「头 / 体」的老边界上。旧实现把头长写成 37,于是 token + * 只写进去 2 字节、剩 2 字节被当成 body 的开头 —— 中继因此从不回 GET_IN 应答。 + */ +export const RELAY_HEADER_LEN = 39; + +/** + * `0x02` 的两条通道。 + * + * - `server`(0):中继注册 / 保活 / 对端路径下发; + * - `peer`(1):P2P 打洞握手(见 {@link buildPeerProbe})。 + */ +export const RELAY_CHANNEL = { server: 0, peer: 1 } as const; export const RelayCmd = { getIn: 1, getInAck: 2, - peerPathInfo: 5, + peerPathInfo: 3, + /** + * `cmd=3`(对端路径下发)的应答。 + * + * 2026-10-26 QQ 本体抓包:收到 `cmd=3` 立刻回一条 `cmd=4`(56 字节, + * body 16 字节全 0)。WeQ 之前从不回,中继就把 `cmd=3` 每秒重发一遍, + * 一直到挂断 —— 表现为「中继协商永远没完成」。 + */ + peerPathAck: 4, keepalive: 0x0b, } as const; -/** 中继通道号的起点;每个中继服务器一个通道,依次 +1。 */ -export const RELAY_CHANNEL_BASE = 5000; +/** + * 中继 GET_IN 尾部的能力摘要:`02 | capabilities.f3 | capabilities.f6(BE)`。 + * + * 实测 QQ 本体(视频档):`02 0f 27 4f` —— `0x0f`=15、`0x274f`=10063,正好是 + * {@link DEFAULT_CAPABILITIES} 的 `f3` / `f6`。旧实现照抄的是语音档的旧模板 + * `02 05 27 4a`(5 / 10058),与当前发出去的能力集自相矛盾。 + */ +export const RELAY_CAPABILITY_TRIPLE = Uint8Array.from([0x02, 0x0f, 0x27, 0x4f]); -function buildRelayPacket(params: { +export function buildRelayPacket(params: { roomId: bigint; selfUin: number; - channelId: number; - authToken: number; + /** 本端的控制包序号(中继通道内恒定,P2P 探测从它往后递增)。 */ + seq: number; + /** 对端 uin;真实客户端显式写出。 */ + peerUin: number; + /** 邀请回包里的 authToken,放在 `[35:39]`。 */ + token: number; + channel: number; cmd: number; body: Uint8Array; }): Uint8Array { - const out = new Uint8Array(RELAY_HEADER_LEN + params.body.length); + // 末尾固定一个 `0x03` 结束符,所以多留 1 字节(QQ 的 GET_IN 是 64 = 39 + 24 + 1)。 + const out = new Uint8Array(RELAY_HEADER_LEN + params.body.length + 1); out[0] = RELAY_STX; writeU16be(out, 1, out.length); - writeU16be(out, 3, 0); + out[3] = 0; + out[4] = params.channel; writeU16be(out, 5, params.cmd); writeU64be(out, 7, params.roomId); writeU32be(out, 19, params.selfUin); - writeU16be(out, 25, params.channelId); - // [31:35] 是保留的对端 uin,真实客户端也写 0。 - out[35] = (params.authToken >>> 24) & 0xff; - out[36] = (params.authToken >>> 16) & 0xff; + writeU32be(out, 23, params.seq); + writeU32be(out, 31, params.peerUin); + writeU32be(out, 35, params.token); out.set(params.body, RELAY_HEADER_LEN); + out[out.length - 1] = 0x03; return out; } /** - * 注册到中继(cmd=1)。 + * 注册到中继(ch=0, cmd=1)。 * - * `authToken` 来自邀请回包;`nonce` 是通道级的随机数,服务端原样带回。 - * 中继只做转发,同一个房间的两个客户端各注册一条通道,服务端把两边的音频 - * 互相投递。 + * 2026-10-26 抓包逐字段对齐(QQ 本体,P2P 正常): + * + * ```text + * [23:27] 本端序号 QQ 是 0x70ed 这样的递增计数,旧实现写死 5000 + * [31:35] 对端 uin QQ 显式填,旧实现写 0 + * [35:39] token QQ 填 authToken,旧实现只写 2 字节 + * [53:57] 02 0f 27 4f 能力摘要,见 RELAY_CAPABILITY_TRIPLE + * ``` + * + * 这四处不对,中继就不回 GET_IN 应答(cmd=2),也就不会下发对端路径(cmd=3)。 */ export function buildRelayGetIn(params: { roomId: bigint; selfUin: number; - channelId: number; + peerUin: number; + seq: number; authToken: number; nonce?: number; }): Uint8Array { - const body = new Uint8Array(27); - body[0] = (params.authToken >>> 8) & 0xff; - body[1] = params.authToken & 0xff; - writeU16be(body, 8, params.nonce ?? 0xfe8c); - // [10:18] 是固定的链路描述(IPv4 / UDP / 端口族),照抄。 - body[16] = 0x02; - body[17] = 0x05; - body[18] = 0x27; - body[19] = 0x4a; - body[26] = 0x03; - return buildRelayPacket({ ...params, cmd: RelayCmd.getIn, body }); -} - -/** 中继保活(cmd=0x0b),5 秒一次。 */ + // body(相对 39):[6:8] 通道随机数,[14:18] 能力摘要,其余 0。 + const body = new Uint8Array(24); + writeU16be(body, 6, params.nonce ?? 0xae4e); + body.set(RELAY_CAPABILITY_TRIPLE, 14); + return buildRelayPacket({ + roomId: params.roomId, + selfUin: params.selfUin, + peerUin: params.peerUin, + seq: params.seq, + token: params.authToken, + channel: RELAY_CHANNEL.server, + cmd: RelayCmd.getIn, + body, + }); +} + +/** 中继保活(ch=0, cmd=0x0b),5 秒一次;序号与 GET_IN 相同。 */ export function buildRelayKeepalive(params: { roomId: bigint; selfUin: number; - channelId: number; + peerUin: number; + seq: number; authToken: number; }): Uint8Array { - const body = Uint8Array.from([(params.authToken >>> 8) & 0xff, params.authToken & 0xff, 0x03]); - return buildRelayPacket({ ...params, cmd: RelayCmd.keepalive, body }); + return buildRelayPacket({ + roomId: params.roomId, + selfUin: params.selfUin, + peerUin: params.peerUin, + seq: params.seq, + token: params.authToken, + channel: RELAY_CHANNEL.server, + cmd: RelayCmd.keepalive, + body: new Uint8Array(0), + }); +} + +// ═══════════════════════════ P2P 端点(bt=25 / bt=27) ═══════════════════════════ + +/** + * `bt=25` / `bt=27` 里端点描述的固定前 6 字节。 + * + * 两个方向、两通不同的通话都是 `d1 07 00 00 00 00`,含义未还原(看起来像 + * 「IPv6 / UDP 族」之类的链路标识),照抄即可;**后两字节是大端端口**。 + */ +const PEER_ENDPOINT_PREFIX = Uint8Array.from([0xd1, 0x07, 0x00, 0x00, 0x00, 0x00]); + +/** 本端 P2P 端点公告(`bt=25`)。 */ +export function buildPeerJoinReport(params: { + roomId: bigint; + selfUin: number; + peerUin: number; + sessionTs: number; + typeId: number; + /** 本端 UDP 套接字的本地端口(就是 P2P 要用的端口)。 */ + port: number; +}): Uint8Array { + const endpoint = new Uint8Array(8); + endpoint.set(PEER_ENDPOINT_PREFIX, 0); + writeU16be(endpoint, 6, params.port); + const body = concat([ + pbVarint(1, params.selfUin), + pbVarint(2, params.peerUin), + // f3 恒 2(两通实测都是 2,语义未还原)。 + pbVarint(3, 2), + pbBytes(4, endpoint), + ]); + const head = buildGcsHead({ + roomId: params.roomId, + selfUin: params.selfUin, + sessionTs: params.sessionTs, + typeId: params.typeId, + bodyType: GcsBodyType.peerJoinReport, + }); + return buildGcsPacket(head, body); +} + +/** `bt=27` 解析结果。 */ +export interface PeerJoinPush { + /** 对端 uin。 */ + peerUin: number; + /** 对端真实 P2P 端口(`bt=9` 的 f8 只是近似值,这个才是权威)。 */ + port: number; +} + +/** + * 解析 `bt=27`(peerJoin)里对端的 P2P 端口。 + * + * 2026-10-26 抓包:`08 <对端uin> 10 <自己uin> 18 02 22 08 d10700000000 db19` —— + * `0xdb19` = 56089,正是对端发媒体用的端口(`bt=9` 的 f8 给的是 56082)。 + */ +export function parsePeerJoin(body: Uint8Array): PeerJoinPush | null { + const peerUin = Number(pbGetVarint(body, 1) ?? 0); + const endpoint = pbGetBytes(body, 4); + if (!endpoint || endpoint.length < 8) return null; + const port = u16be(endpoint, 6); + if (port === 0) return null; + return { peerUin, port }; +} + +// ═══════════════════════════ P2P 握手(0x02 ch=1) ═══════════════════════════ + +/** P2P 握手包定长 72 字节。 */ +export const PEER_PACKET_LEN = 72; + +/** + * P2P 通道的 `0x02` 子命令。 + * + * 实序是「四步握手 + 2 秒保活」(2026-10-26 QQ 本体抓包,23.883s 起): + * + * ```text + * A→B probe(0) B→A probeReply(1) ← 互相确认可达 + * B→A probe(0) A→B probeReply(1) + * A→B probeAck(2) B→A probeAckReply(3) + * 之后每 2 秒:probeAck(2) + probeAckReply(3) + * ``` + * + * 只有收到 1/3 才能确认「双向都通」,媒体才该切到直连。 + */ +export const PeerProbeCmd = { + probe: 0, + probeReply: 1, + probeAck: 2, + probeAckReply: 3, +} as const; + +/** 一条 P2P 握手包解析后的字段。 */ +export interface PeerProbe { + cmd: number; + /** 这条包「关于」哪个 uin:探测时是自己,应答时是发起方。 */ + uinAbout: number; + seq: number; + otherUin: number; + token: number; + /** `uinAbout` 的时钟(应答时原样回抄)。 */ + clock: number; + /** 发这条包的一方自己的时钟(探测时为 0)。 */ + peerClock: number; +} + +/** + * 构造 P2P 握手包(`0x02` ch=1)。 + * + * 逐字节布局(72 字节,2026-10-26 抓包核对): + * + * ```text + * [0:4] 02 00 48 00 magic + 总长(BE) + * [4] 01 通道:1 = P2P + * [5:7] cmd BE + * [7:15] roomId u64 BE + * [19:23] uinAbout 这条包「关于」的 uin + * [23:27] seq + * [31:35] otherUin + * [35:39] token + * [39:43] 00 00 00 02 常量 + * [47:51] clock(uinAbout 的) + * [51:55] peerClock(发信方自己的) + * [71] 03 结束符 + * ``` + */ +export function buildPeerProbe(params: { + roomId: bigint; + cmd: number; + uinAbout: number; + seq: number; + otherUin: number; + token: number; + clock: number; + peerClock: number; +}): Uint8Array { + const out = new Uint8Array(PEER_PACKET_LEN); + out[0] = RELAY_STX; + writeU16be(out, 1, out.length); + out[3] = 0; + out[4] = RELAY_CHANNEL.peer; + writeU16be(out, 5, params.cmd); + writeU64be(out, 7, params.roomId); + writeU32be(out, 19, params.uinAbout); + writeU32be(out, 23, params.seq); + writeU32be(out, 31, params.otherUin); + writeU32be(out, 35, params.token); + writeU32be(out, 39, 2); + writeU32be(out, 47, params.clock); + writeU32be(out, 51, params.peerClock); + out[out.length - 1] = 0x03; + return out; +} + +/** 解析 P2P 握手包;不是 P2P 通道的 `0x02` 返回 null。 */ +export function parsePeerProbe(data: Uint8Array): PeerProbe | null { + if (data.length < PEER_PACKET_LEN || data[0] !== RELAY_STX) return null; + if (data[4] !== RELAY_CHANNEL.peer) return null; + if (u16be(data, 1) !== data.length) return null; + return { + cmd: u16be(data, 5), + uinAbout: u32be(data, 19), + seq: u32be(data, 23), + otherUin: u32be(data, 31), + token: u32be(data, 35), + clock: u32be(data, 47), + peerClock: u32be(data, 51), + }; +} + +/** + * 构造应答(cmd+1)。 + * + * 实测应答包是「把收到的 [7:51] 原样回抄,再填上自己的时钟」:`uinAbout` / + * `seq` / `otherUin` / `token` / `clock` 全部照抄,只把 `peerClock` 换成自己的。 + */ +export function buildPeerProbeReply(probe: PeerProbe, params: { roomId: bigint; clock: number }) { + return buildPeerProbe({ + roomId: params.roomId, + cmd: probe.cmd + 1, + uinAbout: probe.uinAbout, + seq: probe.seq, + otherUin: probe.otherUin, + token: probe.token, + clock: probe.clock, + peerClock: params.clock, + }); } // ═══════════════════════════ Audio (0x5b) ═══════════════════════════ diff --git a/packages/protocol/src/call/index.ts b/packages/protocol/src/call/index.ts index 61ee41bf9..1c45e134c 100644 --- a/packages/protocol/src/call/index.ts +++ b/packages/protocol/src/call/index.ts @@ -68,6 +68,8 @@ export { buildVideoSubHeader, sampleVideoFrameQuality, videoPacketCount, + videoFrameIndex, + videoFrameHeader, } from './video'; export type { VideoFrameQuality, @@ -91,15 +93,28 @@ export { buildGcsHeartbeat, buildGcsAck, buildGcsLogout, + buildVideoState, + VideoStateCode, + buildVideoParamPush, + buildVideoStreamPush, CallEvent, parseCallEvent, buildStatusReport, RELAY_STX, RELAY_HEADER_LEN, - RELAY_CHANNEL_BASE, + RELAY_CHANNEL, + RELAY_CAPABILITY_TRIPLE, RelayCmd, buildRelayGetIn, + buildRelayPacket, buildRelayKeepalive, + PEER_PACKET_LEN, + PeerProbeCmd, + buildPeerJoinReport, + parsePeerJoin, + buildPeerProbe, + buildPeerProbeReply, + parsePeerProbe, AUDIO_STX, AUDIO_HEADER_LEN, FRAME_SAMPLES, @@ -126,6 +141,8 @@ export type { AudioPacketHeader, AudioFrame, PbField, + PeerJoinPush, + PeerProbe, } from './gcs'; export { diff --git a/packages/protocol/src/call/signaling.ts b/packages/protocol/src/call/signaling.ts index 20ddb8f49..b0dc23ed7 100644 --- a/packages/protocol/src/call/signaling.ts +++ b/packages/protocol/src/call/signaling.ts @@ -150,7 +150,8 @@ export const USER_WRAPPER: ProtoMessage = message([f('user', 1, USER_INFO)]); * 拿到回包后原样带回,服务端靠它把同一次通话的若干请求串起来。 */ export const C2C_MODE_COMMON: ProtoMessage = message([ - f('roomId', 1, 'uint64'), + // `force`:开房时 roomId=0 也要显式写出来(`08 00`),与真实客户端一致。 + f('roomId', 1, 'uint64', { force: true }), f('sessionId', 2, 'uint64'), f('relationId', 3, 'uint32', { force: true }), f('scene', 4, 'uint32', { force: true }), @@ -216,12 +217,34 @@ export const C2C_LOGOUT_REQ: ProtoMessage = message([ f('reason', 7, message([f('code', 1, 'uint32', { force: true })])), ]); -/** 中继服务器。`ip` / `altIp` 是 fixed32,按小端存 IPv4 网络序。 */ +/** + * 中继 / GCS 服务器。 + * + * - `ip` / `altIp`:fixed32,按小端存 IPv4 网络序; + * - `ipv6`(字段 5):**16 字节裸 IPv6**,wire type 2。 + * + * 2026-10-26 同一次通话的 OIDB + UDP 抓包对照(QQ 本体,P2P 正常): + * + * ```text + * f1(ipv4) | port | type | f4(altIp) | f5(ipv6) + * - | 8000 | 2 | 29.237.232.17 | 240e:930:c200:88::17 ← QQ 实际连这条 + * - | 8000 | 2 | 29.237.230.22 | 240e:930:c200:86::1c + * - | 8800 | 2 | 29.237.193.13 | 240e:930:c200:81::13 + * 113.250.57.133 | 8000 | 2 | 29.237.232.17 | - + * ... + * ``` + * + * 服务端下发的是**同一台机器的 IPv6 + IPv4 两种形态**,靠字段 5 区分。旧 schema + * 没有这个字段,IPv6 条目就退化成 `altIp` 的 IPv4 地址(`29.237.232.17`), + * 而 `29.237.*` 根本连不通 —— 表现为 GCS 握手靠后续 IPv4 条目兜底、`bt=9` + * 拿不到对端 `f7`、媒体全程走中继。 + */ export const RELAY_SERVER: ProtoMessage = message([ f('ip', 1, 'uint32'), f('port', 2, 'uint32'), f('type', 3, 'uint32'), f('altIp', 4, 'uint32'), + f('ipv6', 5, 'bytes'), ]); export const RSP_MEDIA_CONFIG: ProtoMessage = message([ @@ -300,10 +323,20 @@ export function nextRelationId(): number { return 15000 + Math.floor(Math.random() * 1000); } +/** + * 构造 `common`。 + * + * 2026-10-26 同一次通话的 OIDB 抓包逐字节比对(QQ 本体,P2P 正常): + * + * - **开房**:`common` 里 `f1=0` 是**显式写出**的(`08 00`),不是省略; + * - **邀请**:`common` 里**没有 `f2`(sessionId)** —— 回包虽然带了 + * `common.f2`(值等于 selfUin),但邀请时不能回写。 + * + * 这两处都是「语义等价、字节不同」的差异,服务端可能据此区分实现来源。 + */ function baseCommon(roomId: bigint, relationId: number, scene: number, selfUid: string) { return { roomId, - sessionId: 0n, relationId, scene, selfInfo: { user: { uid: selfUid, uidType: 2 } }, @@ -343,7 +376,7 @@ export interface MediaProfile { export const MEDIA_PROFILE: Record<'audio' | 'video', MediaProfile> = { audio: { f3: 1, ipStack: CALL_IP_STACK, f13: 1, inviteF3: 5, inviteF4: 10056 }, - video: { f3: 0, ipStack: 12656, f13: 3, inviteF3: 15, inviteF4: 10063 }, + video: { f3: 0, ipStack: 21547, f13: 3, inviteF3: 15, inviteF4: 10063 }, }; /** 开房 / 邀请请求里的媒体类型。 */ @@ -421,10 +454,13 @@ export const InviteUser: TrpcSpec< cmd: C2C_SSO_CMD.inviteUser, reqSchema: C2C_INVITE_USER_REQ, respSchema: INVITE_USER_RSP, - serialize: ({ selfUid, roomId, sessionId, relationId, media: kind = 'audio' }) => { + // 注意:`sessionId` 入参**不写进 common**。实测 QQ 的邀请请求里没有 + // `common.f2`(开房回包虽带了该字段,但不回写)。早期多写一个 `10 ` + // 是字节级差异。 + serialize: ({ selfUid, roomId, relationId, media: kind = 'audio' }) => { const profile = MEDIA_PROFILE[kind]; return { - common: { ...baseCommon(roomId, relationId, CALL_SCENE.invite, selfUid), sessionId }, + common: baseCommon(roomId, relationId, CALL_SCENE.invite, selfUid), params: { f3: profile.inviteF3, f4: profile.inviteF4, f5: 0, f12: 0, f13: 0 }, }; }, @@ -536,11 +572,15 @@ export function decodeCreateRoomRspBytes(bytes: Uint8Array): CallRoom { if (field.field !== 6 || field.wire !== 2 || !field.bytes) continue; const s = parsePb(field.bytes); const fixed = (n: number) => s.find((x) => x.field === n && x.wire === 5)?.fixed32; - // 只有 altIp 的记录是同一台机器的 IPv4 备用地址,ip 缺省时用它。 - const ip = ipv4FromFixed32(fixed(1)) ?? ipv4FromFixed32(fixed(4)); const port = Number(s.find((x) => x.field === 2 && x.wire === 0)?.value ?? 0n); const type = Number(s.find((x) => x.field === 3 && x.wire === 0)?.value ?? 0n); - if (!ip || !port) continue; + if (!port) continue; + // 字段 5 是 16 字节裸 IPv6;有它就说明这条是 IPv6 形态。 + const v6 = s.find((x) => x.field === 5 && x.wire === 2)?.bytes; + const ipv6 = v6 && v6.length === 16 ? formatIpv6Text(v6) : null; + // 只有 altIp 的记录是同一台机器的 IPv4 备用地址,ip 缺省时用它。 + const ip = ipv6 ?? ipv4FromFixed32(fixed(1)) ?? ipv4FromFixed32(fixed(4)); + if (!ip) continue; servers.push({ ip, port, type }); } } @@ -552,3 +592,44 @@ export function decodeCreateRoomRspBytes(bytes: Uint8Array): CallRoom { servers, }; } + +/** + * 16 字节裸 IPv6 → 标准文本(RFC 5952:最长连续零段压缩成 `::`)。 + * + * 服务器表字段 5 就是这种裸字节。用 `::` 压缩而不是逐段展开,是为了与抓包 / + * 日志里的形态一致,排查时不会误以为地址不同。 + */ +function formatIpv6Text(raw: Uint8Array): string { + const groups: number[] = []; + for (let i = 0; i < 16; i += 2) groups.push((raw[i]! << 8) | raw[i + 1]!); + + let bestStart = -1; + let bestLen = 0; + let runStart = -1; + let runLen = 0; + for (let i = 0; i < 8; i++) { + if (groups[i] === 0) { + if (runStart < 0) { + runStart = i; + runLen = 0; + } + runLen++; + if (runLen > bestLen) { + bestStart = runStart; + bestLen = runLen; + } + } else { + runStart = -1; + runLen = 0; + } + } + + const hex = (n: number) => n.toString(16); + if (bestLen < 2) return groups.map(hex).join(':'); + const head = groups.slice(0, bestStart).map(hex).join(':'); + const tail = groups + .slice(bestStart + bestLen) + .map(hex) + .join(':'); + return `${head}::${tail}`; +} diff --git a/packages/protocol/src/call/video.ts b/packages/protocol/src/call/video.ts index 09ae2716b..5c0e72748 100644 --- a/packages/protocol/src/call/video.ts +++ b/packages/protocol/src/call/video.ts @@ -26,8 +26,15 @@ * 3. **sub-header `[2]` 是方向标识**(c2s=0x00 / s2c=0x03),且时间戳布局随之 * 变化。恒写 0x03 等于把自己伪装成对端方向。 * - * 帧的归属靠 `(gidx, fidx)`:`fidx=1` 是基础层(可独立解码),`fidx>=2` 是时域 - * 增强层(依赖基础层,跳过可避免花屏)。同一个 GOP 内 `gidx` 递增。 + * 帧的归属靠 `(gidx, fidx)`:它俩拼起来是**一个 16 位帧序号** —— + * `frameIndex = ((fidx - 1) << 8) | gidx`。`gidx` 是低字节,每帧 +1 并在 256 处 + * 回绕;`fidx` 从 1 起,跟着 `gidx` 的回绕 +1(实测连续 600 帧:g=249/f=1 → + * g=43/f=2 → g=87/f=3,拼出来的序号 249→299→343 全程连续)。 + * + * ⚠️ 早期把 `fidx` 当成「时域增强层」(fidx=1 基础层、fidx>=2 增强层),据此在 + * 主进程过滤掉 `fidx>=2` 的帧。真机日志证明那是错的:第 256 帧之后 fidx 变成 2, + * 于是**从第 256 帧起所有画面都被丢掉**,表现为「视频播二十来秒就永久冻结」。 + * 它也不是 SVC 的层号 —— 同一时刻只有一个 fidx 值在发。 * * `[9:13]` 两侧都是**每方向恒定的会话时钟**(实测 c2s/s2c 各差 1),不是每包 * 变化的时间戳。真正的帧时间戳在 sub-header 里。 @@ -94,7 +101,12 @@ export interface VideoRtpHeader { multiFrameFec: number; /** GOP 索引。 */ gidx: number; - /** GOP 内的帧索引:1 = 基础层,>=2 = 增强层。 */ + /** + * 帧序号高字节(`fidx`)。 + * + * **不是 SVC 层号**:`((fidx - 1) << 8) | gidx` 才是真正的 16 位帧序号, + * 见 {@link VideoFrame.frameIndex}。接收侧只把它当帧身份的一部分用。 + */ fidx: number; /** 扩展段解析结果。 */ extensions: VideoExtension[]; @@ -202,11 +214,16 @@ export function parseVideoRtpHeader(payload: Uint8Array): VideoRtpHeader | null export interface VideoFrame { gidx: number; fidx: number; + /** + * 16 位帧序号:`((fidx - 1) << 8) | gidx`。 + * + * 这是**帧的真正身份**(接收侧判连续性、丢弃迟到帧都用它),`gidx` 单独用会在 + * 每 256 帧回绕一次。见文件头关于 `fidx` 语义的说明。 + */ + frameIndex: number; /** 同一帧的各个分片共用(视频里就是 ssrc 的位置)。 */ tick: number; subType: number; - /** 基础层(`fidx === 1`)才能独立解码。 */ - isBaseLayer: boolean; /** * 是否为关键帧(IRAP:IDR / CRA / BLA)。 * @@ -312,6 +329,23 @@ export class VideoFrameAssembler { } } +/** + * `(gidx, fidx)` → 16 位帧序号。 + * + * 实测 2026-10-25 真机抓包 + 日志(连续 600 帧):`gidx` 是低字节、`fidx` 从 1 起 + * 是**高字节 + 1**,即 `((fidx - 1) << 8) | gidx`。拼接后帧序号全程连续, + * 包含中途关 / 开摄像头也不重置。 + */ +export function videoFrameIndex(gidx: number, fidx: number): number { + return (((fidx - 1) & 0xff) << 8) | (gidx & 0xff); +} + +/** 16 位帧序号 → 包头里的 `{ gidx, fidx }`。 */ +export function videoFrameHeader(index: number): { gidx: number; fidx: number } { + const wrapped = index & 0xffff; + return { gidx: wrapped & 0xff, fidx: ((wrapped >> 8) & 0xff) + 1 }; +} + function assemble(slot: PendingFrame): VideoFrame { let total = 0; for (let i = 0; i < slot.totalPkg; i++) total += slot.parts.get(i)?.length ?? 0; @@ -333,9 +367,9 @@ function assemble(slot: PendingFrame): VideoFrame { return { gidx: slot.gidx, fidx: slot.fidx, + frameIndex: videoFrameIndex(slot.gidx, slot.fidx), tick: slot.tick, subType: slot.subType, - isBaseLayer: slot.fidx === 1, // 关键帧按 NAL 类型判,不能按 gidx —— 见 `VideoFrame.isKeyframe` 的注释。 isKeyframe: containsIrap(frameData, subHeaderSize), subHeaderSize, @@ -542,11 +576,15 @@ export function videoPacketCount(frameBytes: number, maxPayload = 1200): number * **已含 sub-header** 的完整帧数据,与 `VideoFrame.frameData` 同形,这样收发 * 两侧可以共用组帧逻辑做回归比对。 * - * `fidx` 固定 1(基础层):实测 c2c 通话不启用时域增强层。 + * `frameIndex` 是 16 位帧序号,包头里的 `(gidx, fidx)` 由它拆出来 + * ({@link videoFrameHeader})—— 只写 `gidx` 会在第 256 帧回绕、帧序号倒退。 + * 调用方可以直接给 `gidx` / `fidx` 覆盖,供黄金样本比对用。 */ export function buildVideoPackets(params: { frameData: Uint8Array; - gidx: number; + /** 16 位帧序号;给了 `gidx`/`fidx` 时以它们为准。 */ + frameIndex?: number; + gidx?: number; fidx?: number; sessionTs: number; /** 本帧所有分片共用的 tick。 */ @@ -558,7 +596,9 @@ export function buildVideoPackets(params: { /** 覆盖单片的 tick(调试用;默认所有分片相同)。 */ tickForPacket?: (index: number) => number; }): Uint8Array[] { - const fidx = params.fidx ?? 1; + const header = videoFrameHeader(params.frameIndex ?? 0); + const gidx = params.gidx ?? header.gidx; + const fidx = params.fidx ?? header.fidx; const subType = params.subType ?? VideoSubType.max; const totalPkg = videoPacketCount(params.frameData.length, params.maxPayload); // 等分:末片自然短。真实客户端的末片不带 0 填充(填充语义未经证实,不补)。 @@ -584,7 +624,7 @@ export function buildVideoPackets(params: { out[AUDIO_HEADER_LEN + 1] = totalPkg; out[AUDIO_HEADER_LEN + 2] = 0; out[AUDIO_HEADER_LEN + 3] = 0x0b; - out[AUDIO_HEADER_LEN + 4] = params.gidx & 0xff; + out[AUDIO_HEADER_LEN + 4] = gidx & 0xff; out[AUDIO_HEADER_LEN + 5] = fidx & 0xff; // 那条扩展 `03 03 00`(len=3 含自身、type=3、data 1 字节)——必须完整写出来, @@ -630,9 +670,14 @@ export const VIDEO_SUBHEADER_LEN = 20; * [6:8] 帧质量 + 码率档 = GetVideoFrameQuality() 的 16 位返回值 * [8:12] 帧时间戳(见上表,随方向变) * [12:16] 会话基准 tick(c2s)/ 0(s2c) - * [16:20] 帧内分片序号相关(实测 0–4 的小整数,不随发送方变化) + * [16:20] 未还原(实测 0–8 的小整数,**与分片数无关**) * ``` * + * `[16:20]` 逐帧实测(2026-10-25 抓包,52 帧全量):`totalPkg` 分别是 + * 4/4/4/3/6/5/6/6/7/9/8/10 时,该字段是 2/0/3/0/0/1/4/0/4/0/3/8 —— 既不是 + * `totalPkg - 1`,也不是帧内分片计数。目前填 `totalPkg - 1` 只是占位,未证实 + * 对端会读它。真要排查「对端不认我们的画面」时,这里是第一顺位可疑字段。 + * * `[6:8]` 来自 `VideoPacketWriter::PackRTPEx`: * * ```c @@ -650,11 +695,26 @@ export function buildVideoSubHeader(params: { frameIndex: number; /** `[8:12]`:c2s 填帧 PTS(ms),s2c 填发送时的 tick。 */ timestampMs: number; - /** `[12:16]`:c2s 填会话基准 tick;s2c 恒 0。 */ - baseTick?: number; + /** + * `[12:16]`:**对端视频流的时间基准**(s2c 恒 0)。 + * + * 实测(2026-10-26 抓包):对端还没开始发视频时填 0;收到对端首个视频帧后, + * 填**该帧 `[8:12]` 的值**(本例 `1952894717`),之后整通不变。 + * + * 语义是「我这边在跟对端的哪个时间基准对齐」,不是本端的会话 tick —— 早期 + * 填 `mediaBaseTick`(本端接通的 tick)是错的。 + */ + peerVideoBase?: number; /** `[6:8]`:`{ hi, lo }` 分别写进 `[6]` 和 `[7]`。缺省时填 0。 */ quality?: VideoFrameQuality; - /** `[16:20]`:帧内分片序号(实测上限很小),缺省 0。 */ + /** + * `[16:20]`:语义未还原,实测是 `0 .. totalPkg-1` 里的一个值。 + * + * 逐帧统计(2026-10-26 抓包,双向 335 帧):`totalPkg=1` 时恒 0; + * `totalPkg=2/3/4/5` 时取值覆盖 `0..totalPkg-1`,且**不是** `totalPkg-1` + * (早先这么填是错的)。看起来像「帧内第几个分片携带了某个标记」,具体 + * 规则未确认。这里用随机值对齐分布,缺省 0。 + */ fragment?: number; }): Uint8Array { const out = new Uint8Array(VIDEO_SUBHEADER_LEN); @@ -666,7 +726,7 @@ export function buildVideoSubHeader(params: { out[6] = (params.quality?.rateTier ?? 0) & 0xff; out[7] = (params.quality?.frameQuality ?? 0) & 0xff; writeU32be(out, 8, params.timestampMs >>> 0); - if (params.direction === 'c2s') writeU32be(out, 12, (params.baseTick ?? 0) >>> 0); + if (params.direction === 'c2s') writeU32be(out, 12, (params.peerVideoBase ?? 0) >>> 0); // [16:20] 小端。 const frag = (params.fragment ?? 0) >>> 0; out[16] = frag & 0xff; diff --git a/packages/protocol/src/msg/index.ts b/packages/protocol/src/msg/index.ts index 7cffda9ec..65ca31104 100644 --- a/packages/protocol/src/msg/index.ts +++ b/packages/protocol/src/msg/index.ts @@ -134,6 +134,28 @@ export { export { RecallGroup, RecallPrivate } from './recall'; export type { RecallGroupParams, RecallPrivateParams } from './recall'; +export { + StartLocationShare, + ReportLocation, + QueryLocation, + buildShareLocationElements, + LBS_SCENE_GROUP, + LBS_SCENE_C2C, + LbsRoomStatus, + SHARE_LOCATION_TEXT, +} from './lbs-share'; +export type { + LbsScene, + LbsSession, + StartLocationShareParams, + LocationShareStartResult, + ReportLocationParams, + LocationReportResult, + QueryLocationParams, + LocationRoom, + LocationRoomMember, +} from './lbs-share'; + export { dumpProto, walkProto, extractPath, protoToJson } from './dump'; export type { DumpOptions, diff --git a/packages/protocol/src/msg/lbs-share.ts b/packages/protocol/src/msg/lbs-share.ts new file mode 100644 index 000000000..e4d804ea3 --- /dev/null +++ b/packages/protocol/src/msg/lbs-share.ts @@ -0,0 +1,499 @@ +/** + * 位置共享(`QQLBSShareSvc`)—— 私聊 / 群聊两条场景共用一套命令。 + * + * 三条命令都是 **raw SSO**(不套 OIDB 信封),`needSign = false`: + * + * QQLBSShareSvc.room_operation 发起 / 进入共享 + * QQLBSShareSvc.report_location 周期上报自己的坐标 + * QQLBSShareSvc.query_location 拉取房间里所有成员的最新坐标(命令名见下) + * + * ## 会话键(三条命令的 field 1) + * + * `room_operation` 与查询用**场景键**: + * + * 私聊 `{ 1: 2(scene C2C), 2: 小号, 3: 大号 }` ← 两个参与者按 uin 升序,不是「对端/自己」 + * 群聊 `{ 1: 1(scene 群), 2: groupCode, 3: 0 }` + * + * ⚠️ 私聊这一对**必须按规范顺序**(见 {@link canonicalPair}):服务端不认语义 + * 位置,同一对号无论谁发包,`1.2` / `1.3` 都得是小号在前、大号在后。写反了 + * 服务端直接回 `10001 param error`(实测 1707889225↔2863253201 双向验证)。 + * 早期实现按「peer / selfUin」写,只有在「自己恰好是大号」时才碰巧正确。 + * + * `report_location` 的 `1.1` 恒为 `2`;私聊同样按规范顺序,群聊 `1.2` 换成群号。 + * 所以它单独用 {@link reportKey}。 + * + * ## 房间不是服务端分配的 + * + * 服务端**不返回房间号**:私聊的房间就是「这两个人」,群聊的就是「这个群」。 + * `room_operation` 的响应只有 `{ code, message }`,后续所有命令靠调用方自己拼 + * 同一个会话键来定位。 + * + * ## 生命周期 + * + * 房间靠 `report_location` 心跳续命(响应里的 `interval` 告诉下次多久再报, + * 实测 2 秒)。停止上报约 60 秒后服务端把房间标记为已结束(查询响应 `status` + * 从 1 变 2),所以**「退出共享」不需要单独的命令** —— 不报即可。 + * + * ## 坐标 + * + * **GCJ-02**,**经度在前**,double。`report_location` 的 field 4 是每次调用都 + * 变的 double(实测 70~255),服务端原样存下并在查询响应的成员条目 field 5 回显; + * 语义未确认,疑似定位精度半径。给个稳定值即可,服务端不校验。 + * + * ## 消息卡片不在这里 + * + * 真实客户端发起后,服务端会用 `MsgPush` 投递一条「发起了位置共享」消息。但 + * WeQ 的 SSO 是「发一包就断」,服务端判定发起方不在线就不投递 —— 所以**卡片 + * 由调用方自己 `sendMessage` 发**,元素构造见 {@link buildShareLocationElements}。 + */ +import { message } from '../protobuf'; +import { invokeTrpc, type TrpcSpec } from '../oidb/invoke'; +import type { TrpcNative } from '../transport'; +import type { SendElement } from './send-elements'; + +// ───────────────────────── 公共 schema ───────────────────────── + +/** + * 会话键(`room_operation` / 查询用)。 + * + * ⚠️ 私聊的 tag 2 / tag 3 **不是「对端 / 自己」**,而是两个参与者的**规范顺序** + * (小号在前,见 {@link canonicalPair})。群聊的 tag 2 是群号、tag 3 恒 0。 + */ +const SESSION_KEY = message([ + { name: 'scene', tag: 1, type: 'uint32' }, + { name: 'uinA', tag: 2, type: 'uint64' }, + { name: 'uinB', tag: 3, type: 'uint64' }, +]); + +/** 上报用的会话键(`1.1` 恒为 2;私聊同样按规范顺序,群聊 `1.2` 是群号)。 */ +const REPORT_KEY = message([ + { name: 'scene', tag: 1, type: 'uint32' }, + { name: 'uinA', tag: 2, type: 'uint64' }, + { name: 'uinB', tag: 3, type: 'uint64' }, +]); + +/** 统一的 `{ code, message }` 结果块。 */ +const RESULT = message([ + { name: 'code', tag: 1, type: 'uint32' }, + { name: 'message', tag: 2, type: 'string' }, +]); + +/** `scene` 取值:群聊。 */ +export const LBS_SCENE_GROUP = 1; +/** `scene` 取值:私聊。 */ +export const LBS_SCENE_C2C = 2; + +/** 房间状态(查询响应 field 5)。 */ +export const LbsRoomStatus = { + /** 进行中。 */ + ACTIVE: 1, + /** 已结束(停止上报约 60 秒后服务端置为此值)。 */ + ENDED: 2, +} as const; + +/** 共享场景。 */ +export type LbsScene = 'c2c' | 'group'; + +/** + * 会话键参数。私聊给 `peerUin` + `selfUin`;群聊给 `groupId`(`selfUin` 不参与)。 + */ +export interface LbsSession { + scene: LbsScene; + /** 私聊:对端 QQ 号。群聊:群号。 */ + peer: number; + /** 私聊必填(自己 QQ 号);群聊忽略。 */ + selfUin?: number; +} + +/** + * 私聊会话键的两个参与者:**小号在前**。 + * + * 服务端不认「对端 / 自己」的语义顺序,只认这个规范顺序 —— 同一对号的两个方向 + * 发出的包必须逐字节相同。实测(两账号分别发包 + 安卓抓包三方互证): + * + * field2 = 1707889225, field3 = 2863253201 → ok + * field2 = 2863253201, field3 = 1707889225 → `10001 param error` + * + * 与「谁发包」无关,所以绝不能按 `peer` / `selfUin` 的位置直接写 wire。 + */ +function canonicalPair(a: number, b: number): { uinA: number; uinB: number } { + return a < b ? { uinA: a, uinB: b } : { uinA: b, uinB: a }; +} + +/** 校验并收窄 `selfUin`(`LbsSession.selfUin` 是 optional,`Number.isSafeInteger` 不收窄)。 */ +function requireSelfUin(s: LbsSession, what: string): number { + const selfUin = s.selfUin; + if (!Number.isSafeInteger(selfUin) || (selfUin ?? 0) <= 0) { + throw new Error(`${what} 必须给 selfUin(自己的 QQ 号)`); + } + return selfUin as number; +} + +/** 把 {@link LbsSession} 归一成 wire 上的会话键。 */ +function sessionKey(s: LbsSession): Record { + if (!Number.isSafeInteger(s.peer) || s.peer <= 0) { + throw new Error(`peer 必须是正整数,收到 ${String(s.peer)}`); + } + if (s.scene === 'group') { + return { scene: LBS_SCENE_GROUP, uinA: s.peer, uinB: 0 }; + } + const selfUin = requireSelfUin(s, '私聊位置共享'); + return { scene: LBS_SCENE_C2C, ...canonicalPair(selfUin, s.peer) }; +} + +/** 上报用的会话键:`1.1` 恒为 2;私聊同样按规范顺序,群聊 `1.2` 是群号。 */ +function reportKey(s: LbsSession): Record { + const selfUin = requireSelfUin(s, 'report_location'); + if (s.scene === 'group') { + return { scene: LBS_SCENE_C2C, uinA: s.peer, uinB: selfUin }; + } + return { scene: LBS_SCENE_C2C, ...canonicalPair(selfUin, s.peer) }; +} + +// ───────────────────────── 发起 ───────────────────────── + +const ROOM_OPERATION_REQ = message([ + { name: 'session', tag: 1, type: SESSION_KEY }, + { name: 'op', tag: 2, type: 'uint32' }, + { name: 'interval', tag: 3, type: 'uint32' }, + { name: 'longitude', tag: 4, type: 'double' }, + { name: 'latitude', tag: 5, type: 'double' }, +]); +const ROOM_OPERATION_RESP = message([{ name: 'result', tag: 1, type: RESULT }]); + +export interface StartLocationShareParams extends LbsSession { + /** 经度(GCJ-02)。 */ + longitude: number; + /** 纬度(GCJ-02)。 */ + latitude: number; + /** 期望的上报间隔(秒)。抓包里固定 2。 */ + interval?: number; +} + +export interface LocationShareStartResult { + ok: boolean; + code: number; + message: string; +} + +/** + * 发起(或加入)一次位置共享。 + * + * 响应只有结果码 —— 房间不是服务端分配的,后续靠同一个会话键定位。 + */ +export namespace StartLocationShare { + export const cmd = 'QQLBSShareSvc.room_operation'; + export const needSign = false; + export const reqSchema = ROOM_OPERATION_REQ; + export const respSchema = ROOM_OPERATION_RESP; + + export type Params = StartLocationShareParams; + + export const serialize = (p: Params): Record => ({ + session: sessionKey(p), + op: 1, + interval: p.interval ?? 2, + longitude: requireCoord(p.longitude, 'longitude', 180), + latitude: requireCoord(p.latitude, 'latitude', 90), + }); + + export const deserialize = (body: Record): LocationShareStartResult => { + const result = (body.result ?? {}) as Record; + const code = Number(result.code ?? 0); + return { + ok: code === 0, + code, + message: typeof result.message === 'string' ? result.message : '', + }; + }; + + export const invoke = ( + nt: TrpcNative, + pid: number, + params: Params, + ): Promise => + invokeTrpc(nt, pid, StartLocationShare as TrpcSpec, params); +} + +// ───────────────────────── 上报 ───────────────────────── + +const REPORT_LOCATION_REQ = message([ + { name: 'session', tag: 1, type: REPORT_KEY }, + { name: 'longitude', tag: 2, type: 'double' }, + { name: 'latitude', tag: 3, type: 'double' }, + { name: 'extra', tag: 4, type: 'double' }, +]); +const REPORT_LOCATION_RESP = message([ + { name: 'result', tag: 1, type: RESULT }, + { name: 'interval', tag: 2, type: 'uint32' }, +]); + +export interface ReportLocationParams extends LbsSession { + /** 经度(GCJ-02)。 */ + longitude: number; + /** 纬度(GCJ-02)。 */ + latitude: number; + /** + * 语义未确认的 double(抓包实测每次调用都变,值域 70~255)。服务端原样存下 + * 并在查询响应的成员条目里回显,不参与校验。缺省给 0。 + */ + extra?: number; +} + +export interface LocationReportResult { + ok: boolean; + code: number; + message: string; + /** 服务端要求的下次上报间隔(秒);缺省 2。 */ + interval: number; +} + +/** + * 上报一次自己的坐标。**必须按服务端给的 `interval` 持续调用**,否则房间会超时结束。 + */ +export namespace ReportLocation { + export const cmd = 'QQLBSShareSvc.report_location'; + export const needSign = false; + export const reqSchema = REPORT_LOCATION_REQ; + export const respSchema = REPORT_LOCATION_RESP; + + export type Params = ReportLocationParams; + + export const serialize = (p: Params): Record => ({ + session: reportKey(p), + longitude: requireCoord(p.longitude, 'longitude', 180), + latitude: requireCoord(p.latitude, 'latitude', 90), + extra: p.extra ?? 0, + }); + + export const deserialize = (body: Record): LocationReportResult => { + const result = (body.result ?? {}) as Record; + const code = Number(result.code ?? 0); + return { + ok: code === 0, + code, + message: typeof result.message === 'string' ? result.message : '', + interval: Number(body.interval ?? 2) || 2, + }; + }; + + export const invoke = ( + nt: TrpcNative, + pid: number, + params: Params, + ): Promise => + invokeTrpc(nt, pid, ReportLocation as TrpcSpec, params); +} + +// ───────────────────────── 查询 ───────────────────────── + +/** 房间里的一个成员(查询响应 field 2,repeated)。 */ +const ROOM_MEMBER = message([ + { name: 'uin', tag: 1, type: 'uint64' }, + { name: 'joinedAt', tag: 2, type: 'uint32' }, + { name: 'longitude', tag: 3, type: 'double' }, + { name: 'latitude', tag: 4, type: 'double' }, + /** 回显该成员最近一次上报的 `extra`。 */ + { name: 'extra', tag: 5, type: 'double' }, + { name: 'reportedAt', tag: 6, type: 'uint32' }, +]); + +const QUERY_LOCATION_REQ = message([ + { name: 'session', tag: 1, type: SESSION_KEY }, + { name: 'mode', tag: 2, type: 'uint32' }, +]); +const QUERY_LOCATION_RESP = message([ + { name: 'result', tag: 1, type: RESULT }, + { name: 'members', tag: 2, type: ROOM_MEMBER, repeated: true }, + { name: 'selfUin', tag: 3, type: 'uint64' }, + { name: 'startedAt', tag: 4, type: 'uint32' }, + { name: 'status', tag: 5, type: 'uint32' }, + { name: 'field7', tag: 7, type: 'uint32' }, +]); + +export interface QueryLocationParams extends LbsSession { + /** + * 查询模式。`0` = 首次(还没上报过时成员条目坐标为 0),`1` = 轮询。 + * 抓包里客户端在发起后会并发发多条,这里按需调用即可。 + */ + mode?: 0 | 1; +} + +/** 房间成员。 */ +export interface LocationRoomMember { + /** 成员 QQ 号。 */ + uin: number; + /** 进入共享的时间(unix 秒)。 */ + joinedAt: number; + /** 经度(GCJ-02);没上报过时为 0。 */ + longitude: number; + /** 纬度(GCJ-02);没上报过时为 0。 */ + latitude: number; + /** 该成员最近一次上报的 `extra`(语义未确认)。 */ + extra: number; + /** 最近一次上报时间(unix 秒)。 */ + reportedAt: number; +} + +export interface LocationRoom { + ok: boolean; + code: number; + message: string; + /** 房间成员(含自己)。 */ + members: LocationRoomMember[]; + /** 自己的 QQ 号。 */ + selfUin: number; + /** 共享开始时间(unix 秒)。 */ + startedAt: number; + /** 房间状态,见 {@link LbsRoomStatus}。 */ + status: number; + /** 未知字段,抓包里恒为 5。 */ + field7: number; +} + +/** + * 查询房间里所有成员的最新坐标。这是**唯一**能拿到别人位置的方式 —— + * 位置不会通过消息推送过来。 + */ +export namespace QueryLocation { + export const cmd = 'QQLBSShareSvc.room_query'; + export const needSign = false; + export const reqSchema = QUERY_LOCATION_REQ; + export const respSchema = QUERY_LOCATION_RESP; + + export type Params = QueryLocationParams; + + export const serialize = (p: Params): Record => ({ + session: sessionKey(p), + mode: p.mode ?? 1, + }); + + export const deserialize = (body: Record): LocationRoom => { + const result = (body.result ?? {}) as Record; + const code = Number(result.code ?? 0); + const rawMembers = Array.isArray(body.members) ? body.members : []; + return { + ok: code === 0, + code, + message: typeof result.message === 'string' ? result.message : '', + members: rawMembers.map((raw) => { + const m = (raw ?? {}) as Record; + return { + uin: Number(m.uin ?? 0), + joinedAt: Number(m.joinedAt ?? 0), + longitude: Number(m.longitude ?? 0), + latitude: Number(m.latitude ?? 0), + extra: Number(m.extra ?? 0), + reportedAt: Number(m.reportedAt ?? 0), + }; + }), + selfUin: Number(body.selfUin ?? 0), + startedAt: Number(body.startedAt ?? 0), + status: Number(body.status ?? 0), + field7: Number(body.field7 ?? 0), + }; + }; + + export const invoke = (nt: TrpcNative, pid: number, params: Params): Promise => + invokeTrpc(nt, pid, QueryLocation as TrpcSpec, params); +} + +// ───────────────────────── 消息卡片元素 ───────────────────────── + +/** + * 「发起了位置共享」灰条的 `pbElem` 字节 = `{ 1: "发起了位置共享" }`。 + * + * 灰条本身不是独立协议,就是 `commonElem serviceType=31` —— 客户端按文案 + + * 当前会话的共享状态渲染成位置共享卡片。 + */ +const SHARE_LOCATION_TIP_PB = Uint8Array.from([ + 0x0a, 0x15, 0xe5, 0x8f, 0x91, 0xe8, 0xb5, 0xb7, 0xe4, 0xba, 0x86, 0xe4, 0xbd, 0x8d, 0xe7, 0xbd, + 0xae, 0xe5, 0x85, 0xb1, 0xe4, 0xba, 0xab, +]); + +/** 灰条文案。 */ +export const SHARE_LOCATION_TEXT = '发起了位置共享'; + +/** + * 构造「发起了位置共享」消息的元素。 + * + * **私聊**只要灰条 + 文本兜底就够(客户端会渲染成可点的卡片)。 + * + * **群聊必须额外带 `generalFlags(37)`**,否则能显示但**点不了** —— 实测差异就在 + * 这一块。里面 `font.token116` 对应消息头的 `1.2.32`(服务端下发时回填的同一个 + * 8 字节值),客户端编不出来;这里给 0 由服务端重新生成。 + * + * 注意 `encode()` 只按 schema 的 `field.name` 取值,`FONT_INFO` / `GENERAL_FLAGS` + * 里没声明的 tag 会被**静默丢弃** —— 所以这两个 schema 必须保留位置共享用到的那 + * 一批 tag(见 `./schemas`)。 + */ +export function buildShareLocationElements(scene: LbsScene): SendElement[] { + const elements: SendElement[] = [ + { + kind: 'raw', + elem: { + commonElem: { + serviceType: 31, + pbElem: SHARE_LOCATION_TIP_PB, + businessType: 1, + }, + }, + }, + ]; + + if (scene === 'group') { + elements.push({ + kind: 'raw', + elem: { + generalFlags: { + bubbleDiyTextId: 0, + groupFlagNew: 0, + flag10: 0, + flag12: 0, + flag13: 0, + widgetId: 104228, + font: { + flag1: 0, + flag4: 10315, + fontId2: 65536, + flag20: 36097, + flag25: 0, + flag30: 0, + flag31: 0, + flag34: 0, + flag51: 337, + flag52: 4, + flag54: 0, + fontId1: 0, + flag58: 0, + flag61: 0, + nested65: { flag2: 14 }, + flag66: 100664448, + flag71: 2, + flag72: 0, + nested73: { flag2: 0, flag6: 4 }, + flag79: 163904, + flag81: 16, + nested102: { inner: { a: 0, b: 0, c: 0, d: 0 } }, + flag107: 1100, + // 用 number 而不是 bigint:这组元素要经 IPC 从渲染层传到主进程, + // 而 IPC 的还原函数(`elementsFromEditable`)只还原 Buffer、不认 bigint。 + token116: 0, + }, + }, + }, + }); + } + + elements.push({ kind: 'raw', elem: { text: { str: SHARE_LOCATION_TEXT } } }); + return elements; +} + +// ───────────────────────── 校验 ───────────────────────── + +function requireCoord(value: number, what: string, limit: number): number { + if (!Number.isFinite(value) || Math.abs(value) > limit) { + throw new Error(`${what} 必须是 ±${limit} 内的有限数,收到 ${String(value)}`); + } + return value; +} diff --git a/packages/protocol/src/msg/schemas.ts b/packages/protocol/src/msg/schemas.ts index f81dacdc2..1553eaec5 100644 --- a/packages/protocol/src/msg/schemas.ts +++ b/packages/protocol/src/msg/schemas.ts @@ -568,9 +568,55 @@ export const INLINE_KEYBOARD_GROUP: ProtoMessage = message([ /** commonElem(serviceType=46).pbElem 顶层。 */ export const INLINE_KEYBOARD_PB: ProtoMessage = message([f('group', 1, INLINE_KEYBOARD_GROUP)]); +/** + * generalFlags(37).font —— 名字沿用最早观测到的字体语义(15/56 确实是字体 id), + * 但**这一格实际还承载别的东西**:位置共享消息的 generalFlags.19 里塞的是一组 + * 业务参数(19.4 / 19.51 / 19.66 / 19.79 / 19.107 …),跟字体无关。 + * + * 这里把观测到的 tag 全部声明出来(加法,不影响既有解码路径)—— 发送侧 + * `encode()` 只按 `field.name` 取值,**没声明的 tag 会被静默丢弃**,群聊的 + * 位置共享卡片因此发不出去(实测:缺这些字段时卡片不可点)。 + */ export const FONT_INFO: ProtoMessage = message([ + f('flag1', 1, 'uint32'), + f('flag4', 4, 'uint32'), f('fontId2', 15, 'uint32'), + f('flag20', 20, 'uint32'), + f('flag25', 25, 'uint32'), + f('flag30', 30, 'uint32'), + f('flag31', 31, 'uint32'), + f('flag34', 34, 'uint32'), + f('flag51', 51, 'uint32'), + f('flag52', 52, 'uint32'), + f('flag54', 54, 'uint32'), f('fontId1', 56, 'uint32'), + f('flag58', 58, 'uint32'), + f('flag61', 61, 'uint32'), + f('nested65', 65, message([f('flag2', 2, 'uint32')])), + f('flag66', 66, 'uint64'), + f('flag71', 71, 'uint32'), + f('flag72', 72, 'uint32'), + f('nested73', 73, message([f('flag2', 2, 'uint32'), f('flag6', 6, 'uint32')])), + f('flag79', 79, 'uint32'), + f('flag81', 81, 'uint32'), + f( + 'nested102', + 102, + message([ + f( + 'inner', + 1, + message([ + f('a', 1, 'uint32'), + f('b', 2, 'uint32'), + f('c', 3, 'uint32'), + f('d', 4, 'uint32'), + ]), + ), + ]), + ), + f('flag107', 107, 'uint32'), + f('token116', 116, 'uint64'), ]); export const GENERAL_FLAGS: ProtoMessage = message([ @@ -579,6 +625,9 @@ export const GENERAL_FLAGS: ProtoMessage = message([ f('uin', 3, 'uint64'), f('longTextFlag', 6, 'int32'), f('longTextResId', 7, 'string'), + f('flag10', 10, 'int32'), + f('flag12', 12, 'int32'), + f('flag13', 13, 'int32'), f('widgetId', 17, 'uint32'), f('font', 19, FONT_INFO), ]); diff --git a/packages/protocol/src/protobuf.ts b/packages/protocol/src/protobuf.ts index c40cbbf75..57fedba43 100644 --- a/packages/protocol/src/protobuf.ts +++ b/packages/protocol/src/protobuf.ts @@ -28,7 +28,11 @@ export type ScalarType = | 'sint64' | 'bool' | 'string' - | 'bytes'; + | 'bytes' + /** IEEE-754 双精度,wire type 1(小端 8 字节)。位置共享的经纬度用它。 */ + | 'double' + /** IEEE-754 单精度,wire type 5(小端 4 字节)。 */ + | 'float'; /** A nested-message field type. Use {@link message} to build one. */ export interface ProtoMessage { @@ -58,11 +62,15 @@ function isMessage(type: ScalarType | ProtoMessage): type is ProtoMessage { } const WIRE_VARINT = 0; +const WIRE_64BIT = 1; const WIRE_LEN = 2; +const WIRE_32BIT = 5; /** Wire type a scalar/message encodes to. */ function wireOf(type: ScalarType | ProtoMessage): number { if (isMessage(type)) return WIRE_LEN; + if (type === 'double') return WIRE_64BIT; + if (type === 'float') return WIRE_32BIT; return type === 'string' || type === 'bytes' ? WIRE_LEN : WIRE_VARINT; } @@ -89,6 +97,16 @@ class Writer { for (let i = 0; i < bytes.length; i++) this.buf.push(bytes[i]!); } + /** 固定 8 字节,小端(wire type 1,protobuf 的 double/fixed64)。 */ + fixed64(value: bigint): void { + for (let i = 0n; i < 8n; i++) this.buf.push(Number((value >> (i * 8n)) & 0xffn)); + } + + /** 固定 4 字节,小端(wire type 5,protobuf 的 float/fixed32)。 */ + fixed32(value: number): void { + for (let i = 0; i < 4; i++) this.buf.push((value >>> (i * 8)) & 0xff); + } + finish(): Uint8Array { return Uint8Array.from(this.buf); } @@ -101,6 +119,34 @@ function toBigInt(v: number | bigint | string): bigint { return typeof v === 'bigint' ? v : BigInt(v); } +/** IEEE-754 double → 64 位无符号整数(按位,不做数值转换)。 */ +function doubleBits(d: number): bigint { + const view = new DataView(new ArrayBuffer(8)); + view.setFloat64(0, d, true); + return view.getBigUint64(0, true); +} + +/** 64 位无符号整数 → IEEE-754 double。 */ +function bitsToDouble(bits: bigint): number { + const view = new DataView(new ArrayBuffer(8)); + view.setBigUint64(0, BigInt.asUintN(64, bits), true); + return view.getFloat64(0, true); +} + +/** IEEE-754 float → 32 位无符号整数。 */ +function floatBits(f: number): number { + const view = new DataView(new ArrayBuffer(4)); + view.setFloat32(0, f, true); + return view.getUint32(0, true); +} + +/** 32 位无符号整数 → IEEE-754 float。 */ +function bitsToFloat(bits: number): number { + const view = new DataView(new ArrayBuffer(4)); + view.setUint32(0, bits >>> 0, true); + return view.getFloat32(0, true); +} + /** zigzag-encode a signed bigint into its unsigned varint form. */ function zigzag(n: bigint): bigint { return BigInt.asUintN(64, (n << 1n) ^ (n >> 63n)); @@ -147,6 +193,24 @@ function writeScalar( } return; } + case 'double': { + const d = Number(value); + // proto3 默认省略 0;用 +0 比较让 -0 也算 0(IEEE 的 -0 在 wire 上有区别, + // 但业务上没人靠它区分,省一字节)。 + if (force || d !== 0) { + w.tag(tag, WIRE_64BIT); + w.fixed64(doubleBits(d)); + } + return; + } + case 'float': { + const f = Math.fround(Number(value)); + if (force || f !== 0) { + w.tag(tag, WIRE_32BIT); + w.fixed32(floatBits(f)); + } + return; + } default: { // int32 / uint32 / int64 / uint64 — two's-complement to 64 bits so that // negatives are sign-extended exactly like protobuf expects. @@ -222,6 +286,20 @@ class Reader { return out; } + /** 读固定 8 字节小端(wire type 1)。 */ + fixed64(): bigint { + const view = new DataView(this.data.buffer, this.data.byteOffset + this.pos, 8); + this.pos += 8; + return view.getBigUint64(0, true); + } + + /** 读固定 4 字节小端(wire type 5)。 */ + fixed32(): number { + const view = new DataView(this.data.buffer, this.data.byteOffset + this.pos, 4); + this.pos += 4; + return view.getUint32(0, true); + } + skip(wire: number): void { switch (wire) { case 0: @@ -267,6 +345,10 @@ function readScalar(r: Reader, type: ScalarType): unknown { return r.varint(); case 'sint64': return BigInt.asIntN(64, unzigzag(r.varint())); + case 'double': + return bitsToDouble(r.fixed64()); + case 'float': + return bitsToFloat(r.fixed32()); } } diff --git a/packages/protocol/test/call.test.ts b/packages/protocol/test/call.test.ts index 621d70970..215b78907 100644 --- a/packages/protocol/test/call.test.ts +++ b/packages/protocol/test/call.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { AUDIO_HEADER_LEN, CallEvent, + DEFAULT_CAPABILITIES, FEC_DATA_FRAMES, FEC_PARITY_ROWS, GcsBodyType, @@ -13,17 +14,30 @@ import { buildGcsAck, buildGcsAuth, buildGcsLogout, + buildVideoState, + buildVideoParamPush, + buildVideoStreamPush, + buildPeerJoinReport, + buildPeerProbe, + buildPeerProbeReply, buildQfbRequest, buildRelayGetIn, + buildRelayKeepalive, buildStatusReport, + parsePeerJoin, + parsePeerProbe, + PeerProbeCmd, decodeCreateRoomRsp, encodeCreateRoomReq, parseAudioFrame, parseCallEvent, parseFecContainer, parseGcsPacket, + parsePb, parseQfb, + pbGetBytes, pbGetVarint, + VideoStateCode, rsDecode, rsEncode, } from '../src/call'; @@ -51,29 +65,38 @@ describe('通话信令', () => { expect(Buffer.from(bytes).toString('latin1')).toContain('u_self'); }); - it('开房回包解析出房间、密钥与服务器(ip 缺失时用 altIp 兜底)', () => { - // 真实回包(发往 2863253201 那次),原样粘过来当黄金样本。 + /** + * 黄金样本:2026-10-26 同一次通话的 OIDB 回包(QQ 本体,P2P 正常)。 + * + * 服务器表前三条带**字段 5(16 字节裸 IPv6)**,服务端下发的原始顺序即优先级; + * QQ 实际连的就是第一条 `240e:930:c200:88::17:8000`(UDP 抓包证实)。 + * + * 旧 schema 没有字段 5,这三条会退化成 `altIp` 的 IPv4(`29.237.*`,不可达), + * 导致 GCS 握手只能退到后面的 IPv4 条目、`bt=9` 拿不到对端 `f7`、媒体全程走 + * 中继 —— 这就是「两端都卡」的根因。 + */ + it('开房回包解析出房间、密钥与服务器(IPv6 优先)', () => { const hex = readFileSync( new URL('./fixtures/call-create-room-rsp.hex', import.meta.url), 'utf8', ).trim(); const room = decodeCreateRoomRsp(new Uint8Array(Buffer.from(hex, 'hex'))); - expect(room.roomId).toBe(6748173487929717840n); + expect(room.roomId).toBe(5697919981127284148n); expect(room.sessionId).toBe(1707889225n); - expect(room.relationId).toBe(15000); + expect(room.relationId).toBe(26136); expect(room.authKey).toHaveLength(80); - // 9 台:3 台只有 altIp(IPv6 记录的 IPv4 备用地址)+ 3 台 relay + 3 台 GCS + // 9 台:3 台 IPv6(字段 5)+ 3 台 IPv4 relay + 3 台 IPv4 GCS,顺序原样保留。 expect(room.servers).toHaveLength(9); expect(room.servers.map((s) => `${s.ip}:${s.port}/t${s.type}`)).toEqual([ - '29.237.193.13:8800/t2', - '29.237.234.22:8800/t2', - '29.237.209.13:8000/t2', + '240e:930:c200:88::17:8000/t2', + '240e:930:c200:86::1c:8000/t2', + '240e:930:c200:81::13:8800/t2', + '113.250.57.133:8000/t2', + '113.250.57.110:8000/t2', '113.250.57.28:8800/t2', - '113.250.57.166:8800/t2', - '113.250.58.106:8000/t2', - '113.250.57.28:8000/t1', - '113.250.57.166:8001/t1', - '113.250.58.106:8000/t1', + '113.250.57.133:8001/t1', + '113.250.57.110:8000/t1', + '113.250.57.28:8001/t1', ]); }); }); @@ -92,6 +115,27 @@ describe('GCS 控制面', () => { expect(parsed.body.length).toBeGreaterThan(100); }); + /** + * 回归:能力集必须与 QQ 本体逐字节一致。 + * + * 旧模板是从语音通话抓的,`f3=5 / f6=10058 / f11=26847424`,内层分辨率硬编码 + * 2560×1440、前两条能力是 `f1=2`。视频通话实测是 `f3=15 / f6=10063 / + * f11=1812048064`、内层 0/0、前两条为空 —— 两者差异可能让服务端不把本端当 + * 视频/P2P 候选(拿不到 `bt=9` 的 `f7`,媒体只能全程中继)。 + * + * 黄金样本:2026-10-26 QQ 本体直接视频抓包的认证 body(618 字节)。 + */ + it('能力集与 QQ 本体逐字节一致(618 字节)', () => { + expect(DEFAULT_CAPABILITIES).toHaveLength(618); + // 顶层 f3 = 15(视频档位)、f6 = 10063(与 MEDIA_PROFILE.video.inviteF4 一致)。 + expect(pbGetVarint(DEFAULT_CAPABILITIES, 3)).toBe(15); + expect(pbGetVarint(DEFAULT_CAPABILITIES, 6)).toBe(10063); + // 开头是 f1 能力列表:`0a d3 03`(field 1, len 467),之后两条空能力 + // `0a 00 0a 00` —— 不是旧模板的 `f1=2`(`08 02 10 01`)。 + expect([...DEFAULT_CAPABILITIES.subarray(0, 3)]).toEqual([0x0a, 0xd3, 0x03]); + expect([...DEFAULT_CAPABILITIES.subarray(3, 7)]).toEqual([0x0a, 0x00, 0x0a, 0x00]); + }); + it('登出包的 body 是 reason=2', () => { const pkt = buildGcsLogout({ ...base, authKey: new Uint8Array([1]) }); const parsed = parseGcsPacket(pkt)!; @@ -168,20 +212,196 @@ describe('GCS 控制面', () => { // ─────────────── Relay ─────────────── describe('中继', () => { - it('GET_IN 是 37 字节头 + 27 字节体', () => { + /** + * 黄金样本:2026-10-26 QQ 本体抓包里的 GET_IN(发往对端中继 `183.47.105.240:8000`, + * 64 字节)。 + * + * 旧实现与它逐字节相比有四处差异,导致中继从不回 GET_IN 应答(cmd=2): + * + * ```text + * [23:27] QQ 是递增序号 0x70ed,旧实现写死 channelId=5000 + * [31:35] QQ 填对端 uin,旧实现写 0 + * [35:39] QQ 填 4 字节 token,旧实现只写 2 字节(头长写成 37) + * [53:57] QQ 是 02 0f 27 4f(能力摘要),旧实现照抄语音档的 02 05 27 4a + * ``` + */ + it('GET_IN 与 QQ 本体逐字节一致', () => { const pkt = buildRelayGetIn({ + roomId: 5697919981127284148n, + selfUin: 1707889225, + peerUin: 2863253201, + seq: 0x70ed, + authToken: 0x6d7bd793, + nonce: 0xae4e, + }); + expect(Buffer.from(pkt).toString('hex')).toBe( + '02004000000001' + // [0:7] 长度 / 通道 0 / cmd=1 + '4f131408b1b135b4' + // [7:15] roomId + '00000000' + // [15:19] + '65cc5249' + // [19:23] selfUin + '000070ed' + // [23:27] seq + '00000000' + // [27:31] + 'aaa9c6d1' + // [31:35] peerUin + '6d7bd793' + // [35:39] token + '00'.repeat(6) + // [39:45] + 'ae4e' + // [45:47] 通道随机数 + '00'.repeat(6) + // [47:53] + '020f274f' + // [53:57] 能力摘要 + '00'.repeat(6) + // [57:63] + '03', + ); + }); + + it('保活是 39 字节头 + 结束符,序号与 GET_IN 相同', () => { + const pkt = buildRelayKeepalive({ + roomId: 5697919981127284148n, + selfUin: 1707889225, + peerUin: 2863253201, + seq: 0x70ed, + authToken: 0x6d7bd793, + }); + expect(Buffer.from(pkt).toString('hex')).toBe( + '02002800' + + '00000b' + + '4f131408b1b135b4' + + '00000000' + + '65cc5249' + + '000070ed' + + '00000000' + + 'aaa9c6d1' + + '6d7bd793' + + '03', + ); + }); +}); + +// ─────────────── P2P 端点 / 握手 ─────────────── + +describe('P2P 端点交换', () => { + /** + * `bt=25`:QQ 本体抓包 24 字节 —— 把自己真正的 UDP 端口(0xc894 = 51348)公告 + * 出去。不发这条,对端永远不知道往哪打洞,媒体只能全程中继。 + */ + it('bt=25 公告本端端口,与抓包逐字节一致', () => { + const pkt = buildPeerJoinReport({ roomId: 1n, - selfUin: 2, - channelId: 5000, - authToken: 0x11223344, + selfUin: 1707889225, + peerUin: 2863253201, + sessionTs: 0, + typeId: 1, + port: 51348, + }); + const parsed = parseGcsPacket(pkt)!; + expect(parsed.bodyType).toBe(GcsBodyType.peerJoinReport); + expect(Buffer.from(parsed.body).toString('hex')).toBe( + '08c9a4b1ae0610d18da7d50a18022208d10700000000c894', + ); + }); + + /** + * `bt=27`:服务端下发对端真实端口。抓包里 `0xdb19` = 56089,正是对端发媒体 + * 用的端口;`bt=9` 的 `f8` 给的是 56082(近似值)。 + */ + it('bt=27 解析出对端端口', () => { + const body = Uint8Array.from( + Buffer.from('08d18da7d50a10c9a4b1ae0618022208d10700000000db19', 'hex'), + ); + expect(parsePeerJoin(body)).toEqual({ peerUin: 2863253201, port: 56089 }); + expect(parsePeerJoin(new Uint8Array([0x08, 0x01]))).toBeNull(); + }); +}); + +describe('P2P 握手(0x02 ch=1)', () => { + /** + * 黄金样本:抓包 23.883s 的 `probe`。对端回包后 P2P 建立,媒体硬切直连 —— + * 这是「对端收不到 / 极卡」的最后一环。 + */ + it('probe 与抓包逐字节一致', () => { + const pkt = buildPeerProbe({ + roomId: 5697919981127284148n, + cmd: PeerProbeCmd.probe, + uinAbout: 1707889225, + seq: 0x70ee, + otherUin: 2863253201, + token: 0x6d7bd793, + clock: 0x74d7e43a, + peerClock: 0, + }); + expect(Buffer.from(pkt).toString('hex')).toBe( + '02004800010000' + // [0:7] 长度 / 通道 1 / cmd=0 + '4f131408b1b135b4' + // [7:15] roomId + '00000000' + // [15:19] + '65cc5249' + // [19:23] 本条「关于」的 uin(自己) + '000070ee' + // [23:27] seq + '00000000' + // [27:31] + 'aaa9c6d1' + // [31:35] 对端 uin + '6d7bd793' + // [35:39] token + '00000002' + // [39:43] 常量 + '00000000' + // [43:47] + '74d7e43a' + // [47:51] 自己的时钟 + '00'.repeat(20) + // [51:71] 还没收到对端的时钟 + '03', + ); + }); + + it('解析出的字段与构造一致,且只认 P2P 通道', () => { + const pkt = buildPeerProbe({ + roomId: 7n, + cmd: PeerProbeCmd.probeAck, + uinAbout: 1, + seq: 9, + otherUin: 2, + token: 3, + clock: 10, + peerClock: 11, + }); + expect(parsePeerProbe(pkt)).toEqual({ + cmd: PeerProbeCmd.probeAck, + uinAbout: 1, + seq: 9, + otherUin: 2, + token: 3, + clock: 10, + peerClock: 11, + }); + // 服务端通道([4]=0)不是握手包。 + const server = buildRelayKeepalive({ + roomId: 7n, + selfUin: 1, + peerUin: 2, + seq: 1, + authToken: 3, + }); + expect(parsePeerProbe(server)).toBeNull(); + }); + + /** + * 应答是「把收到的 [7:51] 原样回抄 + 自己的时钟」。抓包里 B 的 cmd1: + * uinAbout/seq/clock 全是 A 的,`peerClock` 才是 B 自己的。 + */ + it('应答回抄探测包的字段,只换上自己的时钟', () => { + const probe = parsePeerProbe( + buildPeerProbe({ + roomId: 5n, + cmd: PeerProbeCmd.probe, + uinAbout: 11, + seq: 22, + otherUin: 33, + token: 44, + clock: 0x74d7e43a, + peerClock: 0, + }), + )!; + const reply = parsePeerProbe(buildPeerProbeReply(probe, { roomId: 5n, clock: 0x5df445 }))!; + expect(reply).toEqual({ + cmd: PeerProbeCmd.probeReply, + uinAbout: 11, + seq: 22, + otherUin: 33, + token: 44, + clock: 0x74d7e43a, + peerClock: 0x5df445, }); - expect(pkt[0]).toBe(0x02); - expect(pkt.length).toBe(64); - expect((pkt[5]! << 8) | pkt[6]!).toBe(1); - // 通道号写在 [25:27] - expect(((pkt[25]! << 8) | pkt[26]!) >>> 0).toBe(5000); - // auth_token 的高 2 字节在尾部 - expect([pkt[35], pkt[36]]).toEqual([0x11, 0x22]); }); }); @@ -332,6 +552,119 @@ describe('质量反馈', () => { }); }); +// ─────────────── 视频流状态(bt=31) ─────────────── + +describe('视频流状态协商', () => { + it('客户端状态包带 authKey,body 是 f1=', () => { + const authKey = new Uint8Array(88).fill(0x5a); + const packet = buildVideoState({ + roomId: 1n, + selfUin: 10001, + sessionTs: 2, + typeId: 3, + authKey, + state: VideoStateCode.start, + }); + const parsed = parseGcsPacket(packet)!; + expect(parsed.bodyType).toBe(GcsBodyType.videoStatePush); + expect([...parsed.body]).toEqual([0x08, VideoStateCode.start]); + expect(pbGetBytes(parsed.head, 5)).toEqual(authKey); + }); +}); + +// ─────────────── 服务端视频参数推送(bt=77) ─────────────── + +describe('视频参数推送', () => { + /** + * 黄金样本来自 2026-10-26 QQ 电脑→手机抓包: + * 服务端在接通前推的 52 字节 body,分辨率 640×480、15 fps、5120 kbps。 + */ + const REAL_PARAM_BODY = Uint8Array.from( + Buffer.from( + '122510a2021801221e080f100118800520e003288028300038882750800558e00370800578e003' + + '18003a090801109183bd032001', + 'hex', + ), + ); + + it('逐字节还原真实抓包的 52 字节 body', () => { + const packet = buildVideoParamPush({ + roomId: 1n, + selfUin: 10001, + sessionTs: 1, + typeId: 1, + width: 640, + height: 480, + fps: 15, + bitrateKbps: 5120, + }); + const parsed = parseGcsPacket(packet)!; + expect(parsed.bodyType).toBe(GcsBodyType.videoParamPush); + expect([...parsed.body]).toEqual([...REAL_PARAM_BODY]); + }); + + it('分辨率、码率与目标端尺寸跟着参数走', () => { + const build = (width: number, height: number, fps: number, bitrateKbps: number) => { + const packet = buildVideoParamPush({ + roomId: 1n, + selfUin: 1, + sessionTs: 1, + typeId: 1, + width, + height, + fps, + bitrateKbps, + }); + return parseGcsPacket(packet)!.body; + }; + const a = build(640, 480, 15, 5120); + const b = build(480, 270, 10, 400); + expect([...a]).not.toEqual([...b]); + // 480 / 270 的 varint 分别是 0xe0 0x03 / 0x8e 0x02,必须出现在 body 里。 + expect([...b]).toContain(0x8e); + + const pcPacket = buildVideoParamPush({ + roomId: 1n, + selfUin: 1, + sessionTs: 1, + typeId: 1, + width: 640, + height: 480, + fps: 15, + bitrateKbps: 5120, + displayWidth: 1920, + displayHeight: 1200, + }); + const outer = parsePb(parseGcsPacket(pcPacket)!.body); + const inner = parsePb(outer.find((field) => field.field === 2)!.bytes!); + const video = parsePb(inner.find((field) => field.field === 4)!.bytes!); + expect(video.find((field) => field.field === 14)?.value).toBe(1920n); + expect(video.find((field) => field.field === 15)?.value).toBe(1200n); + }); + + /** + * 13 字节形态:流索引递增。抓包里 f3 依次是 0/1/2/3/4/5,f7 恒定。 + */ + it('流索引形态逐字节还原真实抓包', () => { + const expected = [ + '18003a090801109183bd032001', + '18013a090801109183bd032001', + '18023a090801109183bd032001', + ]; + for (let i = 0; i < expected.length; i++) { + const packet = buildVideoStreamPush({ + roomId: 1n, + selfUin: 1, + sessionTs: 1, + typeId: 1, + streamIndex: i, + }); + const parsed = parseGcsPacket(packet)!; + expect(Buffer.from(parsed.body).toString('hex')).toBe(expected[i]); + } + }); +}); + // ─────────────── 协议层的公开契约 ─────────────── describe('不破坏既有机制', () => { diff --git a/packages/protocol/test/call_video.test.ts b/packages/protocol/test/call_video.test.ts index 05e94d657..099b06a7b 100644 --- a/packages/protocol/test/call_video.test.ts +++ b/packages/protocol/test/call_video.test.ts @@ -28,6 +28,8 @@ import { parseVideoPacket, parseVideoRtpHeader, splitHevcNals, + videoFrameHeader, + videoFrameIndex, } from '../src/call'; function hex(s: string): Uint8Array { @@ -109,7 +111,7 @@ describe('真实 c2s 包(2026-10-25 抓包黄金样本)', () => { direction: 'c2s', frameIndex: 0, timestampMs: 0, - baseTick: 0, + peerVideoBase: 0, }); const actual = REAL_C2S_PACKET.subarray(26, 46); // 真实的 [12:16] 不是 0(是会话基准 tick),其余字段应一致。 @@ -241,7 +243,7 @@ describe('VideoFrameAssembler', () => { expect(frame).not.toBeNull(); expect(frame!.gidx).toBe(0); expect(frame!.fidx).toBe(1); - expect(frame!.isBaseLayer).toBe(true); + expect(frame!.frameIndex).toBe(0); expect(frame!.isKeyframe).toBe(true); expect(frame!.subHeaderSize).toBe(0x14); }); @@ -280,11 +282,36 @@ describe('VideoFrameAssembler', () => { expect(assembler.pendingCount).toBe(1); }); - it('增强层(fidx=2)标记为非基础层', () => { + /** + * 回归:`fidx` 是**帧序号高字节**,不是 SVC 层号。 + * + * 2026-10-25 真机日志(连续 600 帧)实测:g=249/f=1 → g=43/f=2 → g=87/f=3, + * 拼出来的帧序号 249→299→599 全程连续(每 50 帧取样一次)。早先按 + * `fidx === 1` 判「基础层」,主进程据此丢掉 fidx>=2 的帧 —— 第 256 帧 + * (10 fps 下约 25 秒)之后画面永久冻结。 + */ + it('(gidx, fidx) 拼成 16 位帧序号,跨 256 帧连续', () => { + expect(videoFrameIndex(0, 1)).toBe(0); + expect(videoFrameIndex(249, 1)).toBe(249); + expect(videoFrameIndex(43, 2)).toBe(299); + expect(videoFrameIndex(37, 3)).toBe(549); + expect(videoFrameIndex(87, 3)).toBe(599); + // 回绕点前后必须连续,不能倒退。 + expect(videoFrameIndex(255, 1) + 1).toBe(videoFrameIndex(0, 2)); + }); + + it('videoFrameHeader 是 videoFrameIndex 的逆运算', () => { + for (const index of [0, 1, 249, 255, 256, 299, 65535]) { + const header = videoFrameHeader(index); + expect(videoFrameIndex(header.gidx, header.fidx)).toBe(index); + } + }); + + it('fidx=2 的帧照样是普通视频帧(不是增强层,不该被丢)', () => { const assembler = new VideoFrameAssembler(); const packets = packetsFor(3, 2, ['1400']); const frame = assembler.push(parseVideoPacket(packets[0]!)!, 1000); - expect(frame!.isBaseLayer).toBe(false); + expect(frame!.frameIndex).toBe(259); expect(frame!.isKeyframe).toBe(false); }); @@ -402,25 +429,50 @@ describe('buildVideoSubHeader', () => { direction: 'c2s', frameIndex: 0, timestampMs: 0, - baseTick: 0x70568b61, + peerVideoBase: 0x70568b61, }); expect(first[0]).toBe(20); expect(first[2]).toBe(0x00); expect(first[3]).toBe(0x06); - // [12:16] 会话基准 tick。 + // [12:16] 对端视频流的时间基准(对端还没发视频时是 0)。 expect([...first.subarray(12, 16)]).toEqual([0x70, 0x56, 0x8b, 0x61]); const next = buildVideoSubHeader({ direction: 'c2s', frameIndex: 1, timestampMs: 100, - baseTick: 0x70568b61, + peerVideoBase: 0x70568b61, }); expect(next[1]).toBe(1); expect(next[2]).toBe(0x00); expect(next[3]).toBe(0x06); }); + /** + * 回归:`[12:16]` 是**对端视频流的时间基准**,不是本端接通的 tick。 + * + * 2026-10-26 QQ 本体抓包实测:对端还没发视频时 128 帧全是 0;收到对端首个 + * 视频帧后(该帧 `[8:12]` = 1952894717),从第 128 帧起填这个值,整通不变。 + * 早期实现从第一帧就填本端 `mediaBaseTick`,与真实客户端不符。 + */ + it('c2s 的 [12:16] 跟着对端视频基准走(未收到时是 0)', () => { + const before = buildVideoSubHeader({ + direction: 'c2s', + frameIndex: 0, + timestampMs: 0, + }); + expect([...before.subarray(12, 16)]).toEqual([0, 0, 0, 0]); + + // 1952894717 = 0x7466cefd + const after = buildVideoSubHeader({ + direction: 'c2s', + frameIndex: 128, + timestampMs: 12800, + peerVideoBase: 1952894717, + }); + expect([...after.subarray(12, 16)]).toEqual([0x74, 0x66, 0xce, 0xfd]); + }); + it('s2c 方向:[2]=0x03,[12:16] 为 0', () => { const header = buildVideoSubHeader({ direction: 's2c', @@ -437,7 +489,7 @@ describe('buildVideoSubHeader', () => { direction: 'c2s', frameIndex: 0, timestampMs: 0x6f2f1bee, - baseTick: 1, + peerVideoBase: 1, }); expect([...header.subarray(8, 12)]).toEqual([0x6f, 0x2f, 0x1b, 0xee]); }); @@ -447,7 +499,7 @@ describe('buildVideoSubHeader', () => { direction: 'c2s', frameIndex: 3, timestampMs: 300, - baseTick: 9, + peerVideoBase: 9, quality: { rateTier: 0x19, frameQuality: 0x26 }, }); expect([...header.subarray(6, 8)]).toEqual([0x19, 0x26]); @@ -458,7 +510,7 @@ describe('buildVideoSubHeader', () => { direction: 'c2s', frameIndex: 3, timestampMs: 300, - baseTick: 9, + peerVideoBase: 9, fragment: 3, }); expect([...header.subarray(16, 20)]).toEqual([3, 0, 0, 0]); @@ -469,7 +521,7 @@ describe('buildVideoSubHeader', () => { direction: 'c2s', frameIndex: 3, timestampMs: 300, - baseTick: 9, + peerVideoBase: 9, }); expect([...header.subarray(6, 8)]).toEqual([0, 0]); expect([...header.subarray(16, 20)]).toEqual([0, 0, 0, 0]); @@ -529,6 +581,35 @@ describe('buildVideoPackets', () => { expect(parsed.every((p) => p.tick === 100)).toBe(true); }); + /** + * 回归:帧序号必须用 16 位,`fidx` 要跟着 `gidx` 回绕 +1。 + * + * 只写 `gidx`(fidx 恒 1)时,第 256 帧的包头会从 `g=255/f=1` 跳回 `g=0/f=1`, + * 对端按 `(fidx<<8)|gidx` 判连续性会看到序号从 255 倒退到 0。 + */ + it('第 256 帧起 fidx 递增,帧序号不回退', () => { + const send = (frameIndex: number) => { + const packets = buildVideoPackets({ + frameData: new Uint8Array(8), + frameIndex, + sessionTs: 1, + tick: 100, + seq: 0, + maxPayload: 1000, + }); + return parseVideoPacket(packets[0]!)!.rtp; + }; + + expect(send(255)).toMatchObject({ gidx: 255, fidx: 1 }); + expect(send(256)).toMatchObject({ gidx: 0, fidx: 2 }); + expect(send(299)).toMatchObject({ gidx: 43, fidx: 2 }); + // 发出去的包头能被自己的解析器还原成同一个帧序号。 + for (const index of [0, 255, 256, 299, 1000]) { + const rtp = send(index); + expect(videoFrameIndex(rtp.gidx, rtp.fidx)).toBe(index); + } + }); + it('发出的包能被自己的解析器还原(收发同构)', () => { const original = hex('00000001 40010c01 ffffff'); const packets = buildVideoPackets({ @@ -592,7 +673,9 @@ describe('MEDIA_PROFILE', () => { }); expect(MEDIA_PROFILE.video).toMatchObject({ f3: 0, - ipStack: 12656, + // 2026-10-26 同一次通话的 OIDB 抓包逐字节比对:视频档是 0x542B。 + // 旧值 12656 来自更早的一通(抓包不同,不是同一档位)。 + ipStack: 21547, f13: 3, inviteF3: 15, inviteF4: 10063, diff --git a/packages/protocol/test/fixtures/call-create-room-rsp.hex b/packages/protocol/test/fixtures/call-create-room-rsp.hex index ca67649a0..effbf5106 100644 --- a/packages/protocol/test/fixtures/call-create-room-rsp.hex +++ b/packages/protocol/test/fixtures/call-create-room-rsp.hex @@ -1 +1 @@ -0a3b08d0e889f68a8195d35d10c9a4b1ae061898752002ca011e0a1c1218755f6d47494254425737674634576f6377387a61706336771802e201020a001afc02080012001a507aee8e2baa3853ed710888c417219ff17ff5a4439610929d889f0e47b17340b90ef5ec555270632f5eedb1d3e2fdda77b1f9540b7088b934f5cedbfd34505a90403147cd1de2a36d330c3a64bd82b29a2000321c10e0441802251dedc10d2a10240e0930c20000810000000000000013321c10e0441802251dedea162a10240e0930c200008a000000000000001c321c10c03e1802251dedd10d2a10240e0930c20000910000000000000013320f0d71fa391c10e0441802251dedc10d320f0d71fa39a610e0441802251dedea16320f0d71fa3a6a10c03e1802251dedd10d320f0d71fa391c10c03e1801251dedc10d320f0d71fa39a610c13e1801251dedea16320f0d71fa3a6a10c03e1801251dedd10d42004a08080110a81418e807500162220a2008c9a4b1ae061218755f6d47494254425737674634576f6377387a61706336778201220a2008d18da7d50a1218755f4c4b7433416441494d502d4355666e3679647a447a778a01071802250b9371d0900101 +0a3c08b4ebc48d8b81c5894f10c9a4b1ae061898cc012002ca011e0a1c1218755f6d47494254425737674634576f6377387a61706336771802e201020a001afc02080012001a503606441801f5ec24a1d9c1682cfd7c3fca7d32e63488862c9f407f9f6336570e56b5c2d480b533203cb6aaa71c66475b38da0d8bb918704d0b92c9f09057e227a28099714c8be555f528e8bc5c11a27c2000321c10c03e1802251dede8112a10240e0930c20000880000000000000017321c10c03e1802251dede6162a10240e0930c2000086000000000000001c321c10e0441802251dedc10d2a10240e0930c20000810000000000000013320f0d71fa398510c03e1802251dede811320f0d71fa396e10c03e1802251dede616320f0d71fa391c10e0441802251dedc10d320f0d71fa398510c13e1801251dede811320f0d71fa396e10c03e1801251dede616320f0d71fa391c10c13e1801251dedc10d42004a08080610a81418f403500262220a2008c9a4b1ae061218755f6d47494254425737674634576f6377387a61706336778201220a2008d18da7d50a1218755f4c4b7433416441494d502d4355666e3679647a447a778a01071802251eae864b900101 \ No newline at end of file diff --git a/packages/service/src/account/call_session.ts b/packages/service/src/account/call_session.ts index 8e488d76f..2b6431b8f 100644 --- a/packages/service/src/account/call_session.ts +++ b/packages/service/src/account/call_session.ts @@ -22,6 +22,7 @@ import { AUDIO_STX, AudioSubType, + RelayCmd, type AudioFrame, type CallRoom, CallEvent, @@ -32,8 +33,10 @@ import { GCS_TYPE_ID_BASE, GcsBodyType, OPUS_SILENCE, - RELAY_CHANNEL_BASE, + RELAY_CHANNEL, + RELAY_HEADER_LEN, RELAY_STX, + PeerProbeCmd, buildAudioFrame, buildCodecInitFrame, buildFecContainer, @@ -41,14 +44,19 @@ import { buildGcsAuth, buildGcsHeartbeat, buildGcsLogout, + buildPeerJoinReport, + buildPeerProbe, + buildPeerProbeReply, buildQfbRequest, buildQfbResponse, buildRelayGetIn, buildRelayKeepalive, + buildRelayPacket, buildStatusReport, buildVideoControl, buildVideoKeepalive, buildVideoPackets, + buildVideoState, buildVideoSubHeader, extractHevc, parseAudioFrame, @@ -56,8 +64,11 @@ import { parseCallEvent, parseFecContainer, parseGcsPacket, + parsePb, + parsePeerJoin, + parsePeerProbe, parseQfb, - pbGetBytes, + pbGetVarint, parseVideoPacket, rsEncode, sampleVideoFrameQuality, @@ -65,6 +76,7 @@ import { isIrapNalType, VIDEO_SUBHEADER_LEN, VIDEO_CTRL_SUB_TYPE, + VideoStateCode, videoPacketCount, VIDEO_KEEPALIVE_SUB_TYPE, VideoFrameAssembler, @@ -125,7 +137,7 @@ export interface CallSnapshot { videoSending: boolean; /** 对端摄像头是否开着(收到对端视频流)。 */ videoReceiving: boolean; - /** 收到的视频帧数(含增强层)。 */ + /** 收到的视频帧数。 */ receivedVideoFrames: number; /** 视频流的最近一次关键帧信息;没收到过为 null。 */ videoInfo: VideoStreamInfo | null; @@ -139,6 +151,10 @@ export interface VideoStreamInfo { profile: number; /** 编码格式,目前只认 hevc。 */ codec: 'hevc'; + /** 编码帧率(可选)。 */ + fps?: number; + /** 目标码率(kbps,可选)。 */ + bitrateKbps?: number; } // ─────────────────────────── 依赖注入 ─────────────────────────── @@ -147,6 +163,14 @@ export interface VideoStreamInfo { export interface UdpTransport { /** 绑定本地端口(0 = 随机)。 */ bind(): Promise; + /** + * 绑定后的本地端口。 + * + * `bt=25` 要向服务端公告**本端的 P2P 端口**,对端随后就打这个端口。拿不到时 + * (老实现 / 测试里的假 socket)跳过公告 —— 但那样 P2P 永远建不起来,所以真实 + * 实现必须给。 + */ + localPort?(): number; send(data: Uint8Array, port: number, address: string): void; close(): void; /** 收到一个包。 */ @@ -232,8 +256,13 @@ export type CallVideoListener = (frame: VideoFramePayload, at: number) => void; export interface VideoFramePayload { gidx: number; fidx: number; - /** 基础层才能独立解码。 */ - isBaseLayer: boolean; + /** + * 16 位帧序号 `((fidx - 1) << 8) | gidx`。 + * + * **不要再用 `fidx` 判层**:`fidx` 是帧序号高字节(每 256 帧 +1),不是 SVC + * 层号。早先按 `fidx === 1` 过滤「增强层」,导致第 256 帧之后画面永久冻结。 + */ + frameIndex: number; isKeyframe: boolean; /** 真实帧时间戳(毫秒)。 */ timestamp: number; @@ -305,11 +334,35 @@ const MEDIA_PROBE_MS = 1000; /** 直连模式下多久没收到对端媒体包就回落服务端。 */ const MEDIA_DIRECT_TIMEOUT_MS = 2000; +/** P2P 握手(`0x02` ch=1)周期。实测真实客户端是 2 秒一拍。 */ +const PEER_PROBE_MS = 2000; + /** 媒体包的发送路径。 */ export type MediaPath = 'server' | 'probing' | 'direct'; // ─────────────────────────── 会话 ─────────────────────────── +/** + * 两个地址是否指向同一台主机(忽略 IPv4 的 IPv6 映射前缀)。 + * + * udp6 双栈 socket 上,IPv4 来源在 `rinfo.address` 里是 `::ffff:a.b.c.d`,而 + * 服务器表里是 `a.b.c.d`。直接字符串比较会漏判 —— 见 `rememberPeerSource` 的 + * 注释,这个漏判会让服务器回包被当成对端直连地址,媒体退化成全程中继。 + */ +function sameHost(a: string, b: string): boolean { + if (a === b) return true; + return normalizeHost(a) === normalizeHost(b); +} + +/** 去掉 IPv4-mapped IPv6 前缀并转小写(IPv6 十六进制大小写不敏感)。 */ +function normalizeHost(address: string): string { + const lower = address.toLowerCase(); + const mapped = lower.startsWith('::ffff:') ? lower.slice('::ffff:'.length) : lower; + // `::ffff:1.2.3.4` 去掉前缀就是点分 IPv4;但 `::ffff:0102:0304` 形态要去前缀后 + // 再按 IPv6 比较 —— 这里只处理点分那种(Node 在双栈 socket 上就给点分)。 + return mapped; +} + /** * 16 字节 IPv6 → 标准文本形式。 * @@ -354,24 +407,45 @@ function formatIpv6(raw: Uint8Array): string | null { return `${head}::${tail}`; } +/** + * fixed32 → 点分 IPv4。 + * + * `parsePb` 的 `fixed32` 是「按 wire 上的字节顺序读成的大端数」,所以直接按字节 + * 拆就是网络序的 IPv4:线上 `b7 2f 69 f0` → 183.47.105.240(`bt=9` 的 `f5` + * 与服务器表的 `ip` / `altIp` 都是这个约定)。 + */ +function formatIpv4Fixed(value: number): string { + const n = value >>> 0; + return `${(n >>> 24) & 0xff}.${(n >>> 16) & 0xff}.${(n >>> 8) & 0xff}.${n & 0xff}`; +} + /** * 从一帧 Annex B 里读 HEVC 规格(分辨率 / profile)。 * * 只在关键帧上调:IDR 前面带 VPS + SPS + PPS,SPS 里有分辨率。实测视频流 * 640×360 Main。解析失败返回 null —— 规格只是给界面显示的,读不到不影响解码。 * - * SPS 的字段顺序(HEVC):`profile_tier_level`(12B) 之后是 - * `sps_seq_parameter_set_id`、`chroma_format_idc`(==3 时多一个 - * `separate_colour_plane_flag`)、`pic_width_in_luma_samples`、 - * `pic_height_in_luma_samples`,都是 Exp-Golomb。 + * SPS 的字段顺序(HEVC):NAL 头 2 字节之后是 + * `sps_video_parameter_set_id`(4b) + `sps_max_sub_layers_minus1`(3b) + + * `sps_temporal_id_nesting_flag`(1b) = 1 字节,再往后是 + * `profile_tier_level`(12B),然后才是 `sps_seq_parameter_set_id`、 + * `chroma_format_idc`(==3 时多一个 `separate_colour_plane_flag`)、 + * `pic_width_in_luma_samples`、`pic_height_in_luma_samples`,都是 Exp-Golomb。 + * + * ⚠️ 偏移是 **13 字节**不是 12:早先按 12 字节跳,等于把 + * `temporal_id_nesting_flag` 当成了 `sps_seq_parameter_set_id` 的首位,读出来的 + * 是 `chroma=0 / width=0 / height=1`,于是这个函数**永远返回 null**(界面拿不到 + * 分辨率)。另外必须先剥掉 emulation prevention byte(`00 00 03` → `00 00`), + * 否则一旦载荷里出现该序列,位流整体错位。 */ function readHevcInfo(annexb: Uint8Array): VideoStreamInfo | null { for (const nal of splitHevcNals(annexb)) { // SPS = 33。NAL 头 2 字节,之后是 RBSP。 - if (nal.type !== 33 || nal.data.length < 15) continue; + if (nal.type !== 33 || nal.data.length < 16) continue; const profile = nal.data[1]!; // profile_idc 在 NAL 头的第二个字节 - const rbsp = nal.data.subarray(2 + 12); - const bits = new BitReader(rbsp); + const rbsp = removeEmulationPrevention(nal.data.subarray(2)); + if (rbsp.length < 13) continue; + const bits = new BitReader(rbsp.subarray(13)); try { bits.ue(); // sps_seq_parameter_set_id const chroma = bits.ue(); @@ -388,6 +462,36 @@ function readHevcInfo(annexb: Uint8Array): VideoStreamInfo | null { return null; } +/** + * 剥掉 NAL 载荷里的 emulation prevention byte。 + * + * H.264/H.265 规定 RBSP 里出现 `00 00 00/01/02/03` 时,编码端在第二个 `00` + * 之后插一个 `03` 防止与起始码混淆。解析位流前必须先把这些 `03` 去掉。 + */ +function removeEmulationPrevention(data: Uint8Array): Uint8Array { + // 绝大多数 NAL 不含该序列,先扫一遍,没有就零拷贝返回。 + let has = false; + for (let i = 0; i + 2 < data.length; i++) { + if (data[i] === 0 && data[i + 1] === 0 && data[i + 2] === 3) { + has = true; + break; + } + } + if (!has) return data; + const out = new Uint8Array(data.length); + let n = 0; + for (let i = 0; i < data.length; i++) { + if (i + 2 < data.length && data[i] === 0 && data[i + 1] === 0 && data[i + 2] === 3) { + out[n++] = 0; + out[n++] = 0; + i += 2; // 跳过被插入的 03 + continue; + } + out[n++] = data[i]!; + } + return out.subarray(0, n); +} + /** 极简位读取器,只够解析 SPS 的前几个 Exp-Golomb 字段。 */ class BitReader { private pos = 0; @@ -460,18 +564,54 @@ export class CallSession { private peerDirect: { address: string; port: number } | null = null; /** 最近一次收到对端包的来源(P2P 端口往往只能从实际流量里学到)。 */ private lastPeerFrom: { address: string; port: number } | null = null; + /** + * 服务端在 `bt=27` 里下发的对端 P2P 端口。 + * + * `bt=9` 的 `f8` 只是个近似值(实测 56082 vs 真实 56089),`bt=27` 才是权威。 + * 地址仍然要靠 `bt=9`(f7 IPv6 / f5 IPv4),两边拼起来才是完整端点。 + */ + private peerAnnouncedPort: number | null = null; + /** + * 本端 UDP 套接字的本地端口。 + * + * `bt=25` 要向服务端公告它,服务端再转给对端 —— 这是 P2P 能建立的开关。 + */ + private localPort = 0; + /** 已发出的 `bt=25` 条数(避免无限制重发)。 */ + private peerJoinReports = 0; + /** 本端控制包序号(中继 GET_IN 与 P2P 握手共用一条计数,实测如此)。 */ + private peerPacketSeq = 20_000 + Math.floor(Math.random() * 40_000); + /** 中继通道的序号(同一通内恒定,GET_IN 与保活共用)。 */ + private relaySeq = 0; + /** 对端回过我们的 P2P 握手应答(说明双向可达)。 */ + private peerProbeAnswered = false; + /** P2P 握手的定时器。 */ + private peerProbeTimer: unknown = null; /** * 媒体走哪条路。 * * 真实客户端不是双发,而是**先纯中继、后硬切 P2P**(实测 0–7.9 秒只发服务端, - * 之后只发对端直连,服务端一个包都收不到)。我们沿用这个顺序,但把切换点 - * 放在「真的学到对端端口」之后,中间留一小段双发探测期兜底。 + * 之后只发对端直连,服务端一个包都收不到)。我们沿用这个顺序,但切换点必须 + * 由**回程证据**决定,不能由「拿到了一个地址」决定 —— 见 + * {@link confirmPeerPath} 与 {@link applyPeerEndpoint}。 */ private mediaPath: MediaPath = 'server'; /** 进入 `probing` 的时刻(毫秒)。 */ private probeSince = 0; /** 最近一次收到对端媒体包的时刻(毫秒),直连回落用。 */ private lastPeerMediaAt = 0; + /** 已见过的媒体子类型(每个只记一次日志,避免逐包刷屏)。 */ + private readonly seenMediaSubTypes = new Set(); + /** 是否已发过视频流状态协商首包(`bt=31/f1=3`)。 */ + private videoStateStarted = false; + /** + * 进入 `probing` 的时刻,用于「探测期满仍无回程」的强制回落。 + * + * 与 {@link probeSince} 分开:`probeSince` 决定什么时候从 probing 推进, + * 这里只用来兜底判死。两者同源但语义不同,合在一起会让「重置探测期」 + * 把兜底计时也一起清掉。 + */ + private probeDeadlineAt = 0; /** 媒体路径看门狗。 */ private mediaWatchdogTimer: unknown = null; /** 已发出的视频帧数。 */ @@ -491,10 +631,14 @@ export class CallSession { */ private videoFrameFragment = 0; /** - * 会话基准 tick,接通那一刻定下来,写进 sub-header 的 `[12:16]`。 + * 对端视频流的时间基准,写进本端 sub-header 的 `[12:16]`。 * - * 实测 c2s 方向这个字段整通恒定,s2c 方向是 0 —— 我们只发 c2s,所以填它。 + * 实测(2026-10-26 抓包):对端还没开始发视频时填 0;收到对端首个视频帧后, + * 填**该帧 `[8:12]` 的值**,之后整通不变。语义是「我跟对端的哪个时间基准 + * 对齐」,不是本端接通的 tick —— 早期填 `this.tick()` 是错的。 */ + private peerVideoBase = 0; + /** 本端接通的 tick(保留给日志 / 调试,不再写进 sub-header)。 */ private mediaBaseTick = 0; /** 视频保活定时器。 */ private videoKeepaliveTimer: unknown = null; @@ -605,6 +749,7 @@ export class CallSession { return; } this.videoSending = enabled; + if (enabled) this.startVideoState(); this.sendVideoControl(); if (enabled) { this.videoFrameIndex = 0; @@ -651,10 +796,11 @@ export class CallSession { this.opts.transport.onMessage((data, from) => this.handlePacket(data, from)); this.opts.transport.onError((error) => this.fail(error.message)); await this.opts.transport.bind(); + // P2P 端点公告要用这个端口(见 sendPeerJoinReport)。 + this.localPort = this.opts.transport.localPort?.() ?? 0; // 4. 握手 this.sendAuth(); - this.sendRelayGetIn(); this.startHeartbeat(); this.startStatusReport(); this.sendCodecInit(); @@ -688,41 +834,43 @@ export class CallSession { // ── 服务器选择 ── /** - * 服务端给两张表:type=1 是 GCS(控制面 + 音频),type=2 是中继。 + * 选 GCS:**按端口优先,组内保持服务端下发的原始顺序**。 + * + * `type` 不能用来区分 GCS:QQ 本体实际连接的 `8000` 也经常标成 `type=2`。 + * 真正可靠的是端口:`8000` / `8001` 是 GCS,`8800` 是中继。 * - * 两张表里是**同一批机器**,只是端口不同(GCS 8000/8001,中继 8800)。 - * 所以「选哪个」不是查表的事 —— 得挑一个真的能收发的。这里的策略: + * 2026-10-26 关闭 TUN 后的对照抓包: * - * 1. 首次认证从 type=1 的第一台开始,失败(3 秒内没有认证回包)就换下一台; - * 2. 全部失败再退到 type=2; - * 3. 中继列表也去重,同一对 ip:port 只注册一次。 + * - WeQ 按原始顺序选中 `240e:930:c200:8a::1c:8800`,`bt=9` 只给 IPv4 + * `f5/f6`;WeQ 向该端点打洞、发媒体,对端始终不回包; + * - QQ 本体连接 `8000`,`bt=9` 给出对端 IPv6 `f7/f8`,随后 P2P 正常。 * - * 试错结果记在 {@link goodGcs},同一进程内的后续通话直接复用 —— 服务端每次 - * 分配的机器会变,但可达性通常一致,能省掉一次 3 秒的空等。 + * 因此先试所有 `8000` / `8001`,全部失败再回退其余端口。组内仍保持服务端 + * 原始顺序,并优先复用上次认证成功的 GCS IP。 */ private pickServers(servers: CallServer[]): void { - const gcsCandidates = servers.filter((s) => s.type === 1); - const relayCandidates = servers.filter((s) => s.type === 2); - this.gcsCandidates = [...gcsCandidates, ...relayCandidates]; - // 记住的 IP 优先在 **type=1(真 GCS)** 里找:同一个 IP 也可能出现在中继表 - // 里(不同端口),直接在整个候选表里 find 会选中 8800 那个中继端口 —— - // 音频还能通(服务端两个端口都收),但视频控制包发到中继就没人处理。 + const isGcsPort = (server: CallServer) => server.port === 8000 || server.port === 8001; + const preferred = servers.filter(isGcsPort); + const fallback = servers.filter((server) => !isGcsPort(server)); + this.gcsCandidates = [...preferred, ...fallback]; this.gcs = - gcsCandidates.find((s) => s.ip === CallSession.goodGcsIp) ?? - gcsCandidates[0] ?? + preferred.find((s) => s.ip === CallSession.goodGcsIp) ?? + preferred[0] ?? this.gcsCandidates.find((s) => s.ip === CallSession.goodGcsIp) ?? this.gcsCandidates[0] ?? null; this.gcsIndex = Math.max(0, this.gcsCandidates.indexOf(this.gcs!)); this.log?.debug('服务器表', { - gcs: gcsCandidates.length, - relay: relayCandidates.length, + total: servers.length, + ipv6: servers.filter((s) => s.ip.includes(':')).length, + gcsPorts: preferred.length, picked: this.gcs ? `${this.gcs.ip}:${this.gcs.port}` : 'none', }); + // 中继通道注册用前两台(按原始顺序,去重)。 const seen = new Set(); this.relays = []; - for (const server of [...relayCandidates, ...gcsCandidates]) { + for (const server of servers) { const key = `${server.ip}:${server.port}`; if (seen.has(key)) continue; seen.add(key); @@ -763,17 +911,54 @@ export class CallSession { // probing → direct 的推进放在这里惰性做:音频 20 ms 一帧、保活 2 秒一条都会 // 走到这条路径,摄像头关着也不会卡在 probing。 + // + // ⚠️ **探测期满不等于直连可用。** 旧实现到期就无条件切 `direct`,只要拿到 + // 一个「看起来像对端」的地址(中继下发的、bt=9 的 IPv4 近似值……)就切, + // 切完服务端一个包都收不到,而那个地址可能根本不回包 —— 2026-10-26 实测 + // 三个候选(中继:19341 / 11.149.189.80:8889 / 中继:19338)全是零回程, + // 对端于是完全收不到我们的流,报「对方网络质量不佳」。 + // + // 现在的判据是**回程证据**({@link confirmPeerPath} 置位): + // 探测期满仍无回程 → 直接放弃这一轮,退回 `server` 并清掉端口等重新学习。 if (this.mediaPath === 'probing' && this.timers.now() - this.probeSince >= MEDIA_PROBE_MS) { - this.mediaPath = hasPeer ? 'direct' : 'server'; - this.log?.info('媒体路径切换', { path: this.mediaPath }); + if (hasPeer && this.peerProbeAnswered) { + this.mediaPath = 'direct'; + this.log?.info('媒体路径切换', { path: this.mediaPath }); + } else { + this.log?.warn('探测期满无直连回程,保持中继', { + address: peer?.address, + port: peer?.port, + answered: this.peerProbeAnswered, + }); + this.mediaPath = 'server'; + this.probeDeadlineAt = 0; + // 端口归零、地址留着:等对端下次真的发包过来再重新学。 + if (this.peerDirect) this.peerDirect = { address: this.peerDirect.address, port: 0 }; + this.lastPeerFrom = null; + } + } + // 兜底:探测期被反复重置(对端零星发包)时,超过 3 倍探测期仍无回程也判死。 + if (this.mediaPath === 'probing' && this.probeDeadlineAt > 0) { + if ( + this.timers.now() - this.probeDeadlineAt >= MEDIA_PROBE_MS * 3 && + !this.peerProbeAnswered + ) { + this.log?.warn('直连探测超时,放弃本轮 P2P', { address: peer?.address }); + this.mediaPath = 'server'; + this.probeDeadlineAt = 0; + if (this.peerDirect) this.peerDirect = { address: this.peerDirect.address, port: 0 }; + this.lastPeerFrom = null; + } } const wantServer = this.mediaPath !== 'direct'; const wantPeer = hasPeer && this.mediaPath !== 'server'; if (wantServer) out.push({ address: this.gcs.ip, port: this.gcs.port }); if (wantPeer && peer) { - // 同一台机器(回环 / 服务端与对端同址)会撞上,避免重复发。 - if (peer.address !== this.gcs.ip || peer.port !== this.gcs.port) { + // 同一台机器(回环 / 服务端与对端同址)会撞上,避免重复发。地址比较要做 + // IPv4 映射归一化,否则 `::ffff:1.2.3.4` 与 `1.2.3.4` 会被当成两台机器, + // 同一份媒体包连发两遍。 + if (!sameHost(peer.address, this.gcs.ip) || peer.port !== this.gcs.port) { out.push({ address: peer.address, port: peer.port }); } } @@ -861,12 +1046,15 @@ export class CallSession { private sendRelayGetIn(): void { if (!this.room) return; - for (const [index, server] of this.relays.entries()) { + // 序号在同一通内恒定(GET_IN 与保活共用),不能每次重发都换一个。 + if (this.relaySeq === 0) this.relaySeq = this.nextPeerSeq(); + for (const server of this.relays) { this.sendTo( buildRelayGetIn({ roomId: this.room.roomId, selfUin: Number(this.opts.selfUin), - channelId: RELAY_CHANNEL_BASE + index, + peerUin: Number(this.opts.peerUin), + seq: this.relaySeq, authToken: this.authToken, }), server, @@ -926,6 +1114,7 @@ export class CallSession { this.statusTimer = this.timers.setInterval(() => { if (!this.room) return; this.statusReportSeq++; + const videoCall = this.opts.media === 'video' || this.videoSending; this.send( buildStatusReport({ roomId: this.room.roomId, @@ -936,6 +1125,7 @@ export class CallSession { recvPackets: this.receivedFrames, sendBytes: this.sentFrames * 80, seq: this.statusReportSeq, + media: videoCall ? 'video' : 'audio', }), ); }, STATUS_REPORT_MS); @@ -960,12 +1150,13 @@ export class CallSession { private startRelayKeepalive(): void { this.relayTimer = this.timers.setInterval(() => { if (!this.room) return; - for (const [index, server] of this.relays.entries()) { + for (const server of this.relays) { this.sendTo( buildRelayKeepalive({ roomId: this.room.roomId, selfUin: Number(this.opts.selfUin), - channelId: RELAY_CHANNEL_BASE + index, + peerUin: Number(this.opts.peerUin), + seq: this.relaySeq, authToken: this.authToken, }), server, @@ -1088,7 +1279,7 @@ export class CallSession { this.rememberPeerSource(from); this.handleAudio(data); } else if (stx === GCS_STX) this.handleGcs(data); - else if (stx === RELAY_STX) this.handleRelay(data); + else if (stx === RELAY_STX) this.handleRelay(data, from); } private handleGcs(data: Uint8Array): void { @@ -1106,14 +1297,21 @@ export class CallSession { this.handleCallEvent(packet.body, packet.typeId); break; case GcsBodyType.peerJoin: + // bt=27:服务端告知对端真实 P2P 端口(地址在 bt=9 里)。 + this.handlePeerJoin(packet.body); this.ackPush(packet.typeId, packet.bodyType, packet.body); break; case GcsBodyType.ctrlPush: // bt=9 带对端直连地址 —— 视频通话的媒体与控制包主要走 P2P, // 只发服务端对端收不到。解析完照常 ACK。 this.handlePeerAddress(packet.body); + // 对端已经进房:把自己的 P2P 端口公告出去(晚于接通时的那次补一条)。 + this.sendPeerJoinReport(2); this.ackPush(packet.typeId, packet.bodyType, packet.body); break; + case GcsBodyType.videoStatePush: + this.handleVideoStatePush(packet); + break; default: // 其它推送类型一律回 ACK:不回的话服务端会重推。 if (packet.bodyType % 2 === 1) this.ackPush(packet.typeId, packet.bodyType, packet.body); @@ -1122,31 +1320,294 @@ export class CallSession { } /** - * 解析 `bt=9` 里的对端直连地址。 + * 解析 `bt=9` 里的对端直连地址与端口。 + * + * body 结构(2026-10-26 同一次通话的 OIDB + UDP 抓包对照,QQ 本体 P2P 正常): + * + * ```text + * f1 bytes(10) 链路描述 + * f2 varint 对端 QQ 号 + * f3 varint 会话相关 + * f7 bytes(16) 对端 IPv6 地址 ★ + * f8 varint 对端 P2P 端口 ★ + * ``` * - * body 结构(实测): + * 实测(同一次通话): * * ```text - * f1 bytes 固定 8 字节描述(链路类型等) - * f2 varint 对端 QQ 号 - * f3 varint 端口相关(含义未定,实测不是监听端口) - * f7 bytes 16 字节 —— 对端 IPv6 地址 ★ - * f8 varint 端口相关 + * bt=9: f7 = 240e:399:f9d:3f10:7873:e70f:577e:60c5 + * f8 = 56082 + * 随后 QQ 直接向 240e:...:60c5:56089 发 0x02 relay 探测包(1696 个), + * 对端回包后 P2P 建立,媒体走 2493+1666 个包直连。 * ``` * - * 端口不在 `f3` / `f8` 里(实测对端 P2P 端口是 47652 / 38965,两者都对不上), - * 所以这里只取地址;端口用**实际收到对端包时的来源端口**(见 - * {@link rememberPeerSource})—— 对端一旦开始发,来源就是可用的回程地址。 + * 另一形态(同一通 20 秒后的第二条 `bt=9`,也是 WeQ 实际收到的那条): + * + * ```text + * f1 bytes(10) 对端中继(嵌套的 RELAY_SERVER) + * f4 bytes(10) 备选中继,可重复 + * f5 fixed32 对端 IPv4 地址 ★ + * f6 varint 对端 P2P 端口 ★ + * ``` + * + * 旧实现只认 `f7`,于是「服务端给的是 IPv4 候选」这一路被整个丢掉 —— 只要对端 + * 是 IPv4 出口(或服务端选的是 IPv4 候选),就永远停在纯中继。 + * + * **`f8` 只能当初始值。** 它是近似端口(实测 56082 vs 真实的 56089),权威值 + * 在 `bt=27`(见 {@link handlePeerJoin})。但 `mediaTargets` 要求 `port > 0` 才发 + * 对端 —— 不发第一包,`bt=27` 也永远不会被触发,所以这里先用近似值打开局面。 */ private handlePeerAddress(body: Uint8Array): void { - const raw = pbGetBytes(body, 7); - if (raw?.length !== 16) return; - const address = formatIpv6(raw); + const fields = parsePb(body); + const candidates: Array<{ address: string; port: number; ipv6: boolean }> = []; + const relayCandidates: CallServer[] = []; + + const v6 = fields.find((f) => f.field === 7 && f.wire === 2)?.bytes; + if (v6?.length === 16) { + const address = formatIpv6(v6); + if (address) + candidates.push({ address, port: Number(pbGetVarint(body, 8) ?? 0), ipv6: true }); + } + const v4 = fields.find((f) => f.field === 5 && f.wire === 5)?.fixed32; + if (v4 !== undefined) { + // f6 的端口在 IPv4 形态里是 protobuf fixed32,线上字节 `b9 22 00 00` + // 按小端解出来是 0x000022b9 = 8889。parsePb 为了兼容地址字段按大端存成 + // 0xb9220000,所以这里要把高 16 位再按字节序还原,不能直接取高 16 位。 + const fixed = fields.find((f) => f.field === 6 && f.wire === 5)?.fixed32; + const fixedPort = fixed === undefined ? 0 : fixed >>> 16; + const wirePort = ((fixedPort & 0xff) << 8) | (fixedPort >>> 8); + const port = Number(pbGetVarint(body, 6) ?? wirePort); + candidates.push({ address: formatIpv4Fixed(v4), port, ipv6: false }); + } + // `f3` 是本次通话的准入 token:它同时出现在中继 GET_IN 的 `[35:39]` 和每个 + // `0x02` 握手包里。信令那边解析失败时拿它兜底,否则中继永远不回我们。 + const pushToken = Number(pbGetVarint(body, 3) ?? 0); + if (pushToken > 0 && pushToken !== this.authToken) { + this.log?.info('bt=9 的准入 token 与信令不一致', { + pushToken, + signalingToken: this.authToken, + }); + if (this.authToken === 0) this.authToken = pushToken; + } + for (const field of fields) { + if ((field.field !== 1 && field.field !== 4) || field.wire !== 2 || !field.bytes) continue; + const relay = parsePb(field.bytes); + const ip = relay.find((item) => item.field === 1 && item.wire === 5)?.fixed32; + const port = Number(relay.find((item) => item.field === 2 && item.wire === 0)?.value ?? 0); + const type = Number(relay.find((item) => item.field === 3 && item.wire === 0)?.value ?? 0); + if (ip === undefined || port <= 0) continue; + relayCandidates.push({ ip: formatIpv4Fixed(ip), port, type }); + } + if (relayCandidates.length > 0 && this.authToken > 0) { + this.relays = relayCandidates; + this.sendRelayGetIn(); + if (!this.relayTimer) this.startRelayKeepalive(); + } + if (candidates.length === 0) return; + + // IPv6 优先(无 NAT,连通率最高)。 + const best = candidates[0]!; + // 端口优先级:真实流量里学到的 > bt=27 的权威值(只对 IPv6 成立)> bt=9 的近似值。 + // + // 为什么 bt=27 只套 IPv6:抓包里对端的 IPv6 socket 与 IPv4 socket 是两个端口 + // (56089 vs 18038),bt=27 下发的是它主端点(IPv6)的端口;IPv4 候选的端口 + // 就在 bt=9 的 f6 里,本来就是准的。 + const learned = + this.lastPeerFrom && sameHost(this.lastPeerFrom.address, best.address) + ? this.lastPeerFrom.port + : 0; + const announced = best.ipv6 ? this.peerAnnouncedPort : null; + const port = learned > 0 ? learned : (announced ?? best.port); + this.log?.info('拿到对端候选端点', { address: best.address, port, candidates }); + // 可信度分级: + // - 端口是从真实流量里学到的(learned)→ 对端确实从这儿发过包,可信; + // - IPv6 + bt=27 的权威端口 → 可信; + // - 其余(尤其 IPv4 的 f5/f6 近似值)→ 只是候选,必须等回程。 + // + // 2026-10-26 实测:IPv4 候选 `11.149.189.80:8889` 发了 515 个媒体包, + // **零回包**,旧实现却因为「拿到地址」就切了 direct,媒体从此离开 GCS。 + const trustworthy = learned > 0 || (best.ipv6 && announced !== null); + this.applyPeerEndpoint(best.address, port, { provisional: !trustworthy }); + } + + /** + * 处理 `bt=27`:服务端下发对端的真实 P2P 端口。 + * + * 地址不在这条里,要和 `bt=9` 的 `f7` / `f5` 拼起来。端口到达后立即开始 + * `0x02` 打洞握手(真实客户端也是这个顺序)。 + */ + private handlePeerJoin(body: Uint8Array): void { + const push = parsePeerJoin(body); + if (!push) return; + this.peerAnnouncedPort = push.port; + this.log?.info('服务端下发对端 P2P 端口', { peerUin: push.peerUin, port: push.port }); + // 只有主端点(IPv6)用这个端口;IPv4 候选的端口在 bt=9 的 f6 里。 + const address = this.peerDirect?.address ?? this.lastPeerFrom?.address; if (!address) return; - if (this.peerDirect?.address === address) return; - // 端口先留 0:真正可用的是对端发包时的来源端口。 - this.peerDirect = { address, port: this.peerDirect?.port ?? 0 }; - this.log?.info('拿到对端直连地址', { address, port: this.peerDirect.port }); + const ipv4 = /^\d+\.\d+\.\d+\.\d+$/.test(address) || address.startsWith('::ffff:'); + if (!ipv4) this.applyPeerEndpoint(address, push.port); + } + + /** + * 确定对端端点并开始打洞。 + * + * 这是「知道对端在哪」到「真的把路打通」之间的一步:探索包一发,对端就会回 + * `0x02` 应答,两端几乎同时切到直连(实测 QQ 从拿到地址到媒体直连 <200 ms)。 + * + * **拿到地址 ≠ 直连可用。** 这里的两种来源可信度差很多: + * + * - `bt=27` 给的端口是服务端确认过的对端 P2P 端口,可以直接进 `probing`; + * - 中继路径下发(`cmd=3`)、`bt=9` 的 IPv4 `f5/f6` 只是「候选」, + * 2026-10-26 实测这些地址可能根本是黑洞(发出去 515 包、0 回包)。 + * + * 所以 `provisional` 的候选**不改变 `mediaPath`**,只用来发打洞探测包; + * 只有真收到回程({@link confirmPeerPath})或 `bt=27` 才允许进 `probing`。 + */ + private applyPeerEndpoint( + address: string, + port: number, + opts: { provisional?: boolean } = {}, + ): void { + const normalizedPort = port > 0 && port < 65536 ? port : 0; + if (this.peerDirect?.address === address && this.peerDirect.port === normalizedPort) return; + this.peerDirect = { address, port: normalizedPort }; + if (normalizedPort === 0) return; + if (!opts.provisional && this.mediaPath === 'server') { + this.mediaPath = 'probing'; + this.probeSince = this.timers.now(); + this.probeDeadlineAt = this.probeSince; + this.peerProbeAnswered = false; + this.log?.info('媒体路径切换', { path: 'probing' }); + } + // 无论可信与否,都去打洞探测 —— 这是拿到回程证据的唯一途径。 + // + // ⚠️ 循环已经在跑时也要**立刻补一拍**。`bt=27` 的权威端口常常晚于 `bt=9` + // 到达,而 `bt=9` 已经启动了探测循环;只依赖 `startPeerProbeLoop()` 的话, + // 新端口要等 2 秒后的下一拍才会用上,可探测期 1 秒就到期把端口清零了 —— + // 权威端口于是永远发不出去,P2P 握手死锁在中继上。 + if (this.peerProbeTimer === null) this.startPeerProbeLoop(); + else this.sendPeerProbe(); + } + + /** + * 公告本端 P2P 端点(`bt=25`)。 + * + * 最多发 `max` 条:接通时一条,对端进房(收到 `bt=9`)后再补一条。 + * + * ⚠️ **不要靠重发 `bt=25` 去催 `bt=27`。** 2026-10-26 Linux QQ 抓包实测: + * `bt=27` 在 `bt=25` 之前 2.7ms 就到了(服务端主动推),两者是同一次协商的 + * 两个方向,不是「发了 25 才有 27」。拿不到 `bt=27` 是网络/服务端判定问题, + * 重发只会制造噪音。 + */ + private sendPeerJoinReport(max = 1): void { + if (!this.room || !['calling', 'ringing', 'connecting', 'connected'].includes(this.state)) { + return; + } + if (this.peerJoinReports >= max) return; + if (this.localPort <= 0) { + if (this.peerJoinReports === 0) { + this.log?.warn('拿不到本地 UDP 端口,跳过 P2P 端点公告'); + this.peerJoinReports = max; + } + return; + } + this.peerJoinReports++; + this.send( + buildPeerJoinReport({ + roomId: this.room.roomId, + selfUin: Number(this.opts.selfUin), + peerUin: Number(this.opts.peerUin), + sessionTs: this.sessionTs, + typeId: this.nextTypeId(), + port: this.localPort, + }), + ); + this.log?.info('公告本端 P2P 端点', { port: this.localPort }); + } + + /** 下一个控制包序号(中继 GET_IN 与 P2P 握手共用)。 */ + private nextPeerSeq(): number { + return ++this.peerPacketSeq; + } + + /** + * 启动 P2P 握手循环(`0x02` ch=1,2 秒一拍)。 + * + * 第一拍是 `probe`;一旦对端回过应答就换成 `probeAck`(实测真机的稳态就是 + * 两秒一对 2/3)。 + */ + private startPeerProbeLoop(): void { + if (this.peerProbeTimer !== null) return; + const beat = () => this.sendPeerProbe(); + beat(); + this.peerProbeTimer = this.timers.setInterval(beat, PEER_PROBE_MS); + } + + private stopPeerProbeLoop(): void { + if (this.peerProbeTimer === null) return; + this.timers.clearInterval(this.peerProbeTimer); + this.peerProbeTimer = null; + } + + private sendPeerProbe(): void { + const peer = this.peerDirect; + if (!this.room || !peer || peer.port <= 0) return; + const packet = buildPeerProbe({ + roomId: this.room.roomId, + cmd: this.peerProbeAnswered ? PeerProbeCmd.probeAck : PeerProbeCmd.probe, + uinAbout: Number(this.opts.selfUin), + seq: this.nextPeerSeq(), + otherUin: Number(this.opts.peerUin), + token: this.authToken, + clock: this.tick(), + peerClock: 0, + }); + this.opts.transport.send(packet, peer.port, peer.address); + } + + /** + * P2P 握手包(`0x02` ch=1)。 + * + * 只有「对方发来的探测」需要应答:0→1、2→3。收到 1/3 说明我们发出去的探测 + * 到了对方,这条路双向可用,媒体就可以停掉中继那份了。 + */ + private handlePeerChannel(data: Uint8Array, from: { address: string; port: number }): void { + const probe = parsePeerProbe(data); + if (!probe || !this.room) return; + + // 打洞包本身就是最可靠的端点来源:它就是从这个地址:端口发过来的。 + this.rememberPeerSource(from); + if (probe.cmd === PeerProbeCmd.probe || probe.cmd === PeerProbeCmd.probeAck) { + const reply: Uint8Array = buildPeerProbeReply(probe, { + roomId: this.room.roomId, + clock: this.tick(), + }); + this.opts.transport.send(reply, from.port, from.address); + } + if (probe.cmd === PeerProbeCmd.probeReply || probe.cmd === PeerProbeCmd.probeAckReply) { + this.peerProbeAnswered = true; + this.confirmPeerPath(); + } + } + + /** + * 直连已双向确认:媒体立刻切到 P2P(不等探测期跑完)。 + * + * 这是**唯一**能让 `mediaPath` 变成 `direct` 的路径之一(另一处是 `bt=27` + * 给的权威端口 + 探测期)。没有这一步,任何「拿到地址就切」都会把媒体推进 + * 黑洞 —— 见 {@link mediaTargets} 的注释。 + */ + private confirmPeerPath(): void { + this.lastPeerMediaAt = this.timers.now(); + this.peerProbeAnswered = true; + this.probeDeadlineAt = 0; + if (this.mediaPath === 'direct') return; + if (!this.peerDirect || this.peerDirect.port <= 0) return; + this.mediaPath = 'direct'; + this.log?.info('P2P 直连已确认,媒体切到直连', { + address: this.peerDirect.address, + port: this.peerDirect.port, + }); } /** @@ -1158,8 +1619,23 @@ export class CallSession { private rememberPeerSource(from: { address: string; port: number }): void { // 判据是「来源 != 服务端」而不是「和 bt=9 一致」:bt=9 只给地址不给端口, // 而真正的回程端口只能从对端实际发包的来源学到。服务端地址要排除掉。 - const isServer = this.gcs && this.gcs.ip === from.address && this.gcs.port === from.port; - // 只有绕过服务端的包才刷新「直连还活着」的时间戳 —— 被中继的包说明直连不通。 + // + // ⚠️ **必须做地址归一化再比**。socket 是 udp6 双栈(`ipv6Only: false`), + // IPv4 来源在 `rinfo.address` 里是 `::ffff:113.250.57.28` 形态,而服务器表里 + // 是 `113.250.57.28` —— 直接字符串比较永远不相等。后果很严重:服务器自己的 + // 回包被当成「对端直连地址」,媒体路径切到 direct,实际仍发往服务器(同 IP + // 同端口),于是全程走中继、`bt=9` 也永远拿不到对端的 f7(IPv6),表现为 + // 两端画面都极卡。 + // + // ⚠️ **中继节点也必须排除**。经中继转发的媒体包,源地址是中继自己,不是 + // 对端 —— 旧实现只排除了 GCS,于是中继的包既刷新了「直连还活着」的时间戳 + // (看门狗因此永远不回落),又把 `peerDirect` 改写成中继地址。2026-10-26 + // WeQ 抓包:整通 `sent 2478 / received 2389` 全在一条中继通道上,看门狗 + // 一次都没触发。 + const isServer = + (this.gcs !== null && sameHost(this.gcs.ip, from.address) && this.gcs.port === from.port) || + this.relays.some((r) => sameHost(r.ip, from.address) && r.port === from.port); + // 只有绕过服务端(和中继)的包才算「直连还活着」。 if (!isServer) this.lastPeerMediaAt = this.timers.now(); if (this.lastPeerFrom?.address === from.address && this.lastPeerFrom?.port === from.port) return; @@ -1168,19 +1644,27 @@ export class CallSession { this.peerDirect = { address: from.address, port: from.port }; this.log?.info('学到对端直连地址', { address: from.address, port: from.port }); // 首次学到端口:进入双发探测期。已经是 direct 时不动(回落由看门狗管)。 + // 这里的端口是**对端真的从这个地址发过包**得来的,可信,可以直接进 probing。 if (this.mediaPath === 'server') { this.mediaPath = 'probing'; this.probeSince = this.timers.now(); + this.probeDeadlineAt = this.probeSince; + this.peerProbeAnswered = false; this.log?.info('媒体路径切换', { path: 'probing' }); } + // 对端已经在打我们了,我们也要跟上(否则对方收不到回程应答,不敢切直连)。 + this.startPeerProbeLoop(); } private handleAuthRsp(): void { this.authAcked = true; this.clearGcsRetry(); if (this.gcs) CallSession.goodGcsIp = this.gcs.ip; - // 认证成功即开始中继保活(中继注册先于认证发出去,服务端会缓存)。 - if (!this.relayTimer) this.startRelayKeepalive(); + // relay token 通常在后续 bt=9 才下发;拿到 token 后再注册并开始保活。 + if (this.authToken > 0) { + this.sendRelayGetIn(); + if (!this.relayTimer) this.startRelayKeepalive(); + } this.log?.debug('GCS 认证成功', { roomId: this.room?.roomId.toString() }); } @@ -1216,6 +1700,7 @@ export class CallSession { switch (event.event) { case CallEvent.ringing: + this.startVideoState(); if (this.state === 'calling') { this.state = 'ringing'; this.emit(); @@ -1250,6 +1735,55 @@ export class CallSession { } } + /** + * 振铃后发起视频流状态协商。 + * + * 2026-10-26 三通原生抓包(电脑→电脑 / 电脑→手机 / WeQ→手机)对照: + * QQ 收到 `event=4` 后约 40–160 ms 发送 `bt=31 body=0803`(带 authKey)。 + * 服务端随后回普通 `bt=32 body=0803` 和 `bt=31 body=0806`。 + */ + private startVideoState(): void { + if (this.videoStateStarted || !this.room) return; + if (this.opts.media !== 'video' && !this.videoSending) return; + this.videoStateStarted = true; + this.sendVideoState(VideoStateCode.start); + } + + /** + * 处理 `bt=31` 视频流状态推送。 + * + * 服务端下发 `f1=4` 时,原生客户端会同时发空 `bt=32` ACK 和 + * `bt=31 body=0805`。后者不是普通 ACK,不能由「未知奇数推送一律 ACK」代替; + * 漏掉它时手机不会创建对端视频画面。 + */ + private handleVideoStatePush(packet: { + body: Uint8Array; + bodyType: number; + typeId: number; + }): void { + this.ackPush(packet.typeId, packet.bodyType, packet.body); + const state = pbGetVarint(packet.body, 1); + this.log?.info('视频流状态推送', { state }); + if ((this.opts.media === 'video' || this.videoSending) && state === VideoStateCode.offer) { + this.sendVideoState(VideoStateCode.accept); + } + } + + private sendVideoState(state: number): void { + if (!this.room) return; + this.send( + buildVideoState({ + roomId: this.room.roomId, + selfUin: Number(this.opts.selfUin), + sessionTs: this.sessionTs, + typeId: this.nextTypeId(), + authKey: this.room.authKey, + state, + }), + ); + this.log?.info('发送视频流状态', { state }); + } + private markConnected(): void { // 异步挂断期间的迟到 dataChange 不能复活通话。 if (this.state === 'connected' || this.state === 'ending') return; @@ -1265,6 +1799,8 @@ export class CallSession { this.startAudioLoop(); this.startVideoWatchdog(); this.startMediaWatchdog(); + // 把自己的 P2P 端口公告出去 —— 不发这条,对端永远不知道往哪打洞。 + this.sendPeerJoinReport(); // QQ 的默认行为:**视频通话接通时双方摄像头自动打开**(对方可以选择 // 语音接听,但那不影响我们这边开摄像头)。语音通话则保持关闭。 // @@ -1295,9 +1831,11 @@ export class CallSession { const header = parseAudioHeader(data); if (!header) return; this.lastPeerTick = header.tick; - // 0x5b 的每个子类型都记一笔:排查「对端到底发没发」时这是唯一线索。 - if (header.subType !== AudioSubType.audio) { - this.log?.info('收到非音频媒体包', { + // 每个子类型**首次**出现时记一笔就够了:排查「对端到底发没发」时这是唯一 + // 线索,但逐包记录会刷屏(一通视频通话上千条,实测 1014 条/通)。 + if (header.subType !== AudioSubType.audio && !this.seenMediaSubTypes.has(header.subType)) { + this.seenMediaSubTypes.add(header.subType); + this.log?.info('收到媒体包子类型', { subType: `0x${header.subType.toString(16)}`, len: data.length, from: 'peer', @@ -1404,6 +1942,13 @@ export class CallSession { this.receivedVideoFrames++; this.lastVideoPacketAt = this.timers.now(); + // 记下对端视频流的时间基准:本端 sub-header 的 `[12:16]` 要填它。 + // 实测是「对端首个视频帧的 `[8:12]` 值」,收到后整通不变。 + if (this.peerVideoBase === 0 && frame.timestamp !== 0) { + this.peerVideoBase = frame.timestamp; + this.log?.info('记下对端视频时间基准', { peerVideoBase: frame.timestamp }); + } + const hevc = extractHevc(frame); // 前几帧把组帧结果打出来:拼不上 / 剥不出裸流是「收到包但没画面」的典型原因。 if (this.receivedVideoFrames <= 5 || this.receivedVideoFrames % 50 === 0) { @@ -1430,7 +1975,7 @@ export class CallSession { const payload: VideoFramePayload = { gidx: frame.gidx, fidx: frame.fidx, - isBaseLayer: frame.isBaseLayer, + frameIndex: frame.frameIndex, isKeyframe: frame.isKeyframe, timestamp: frame.timestamp, info: this.videoInfo, @@ -1477,6 +2022,8 @@ export class CallSession { // 地址留着(bt=9 给的是对的),端口归零,等对端下次发包重新学。 if (this.peerDirect) this.peerDirect = { address: this.peerDirect.address, port: 0 }; this.lastPeerFrom = null; + this.peerProbeAnswered = false; + this.probeDeadlineAt = 0; }, 1000); } @@ -1624,13 +2171,15 @@ export class CallSession { const now = this.timers.now(); const ptsMs = this.connectedAt === null ? 0 : now - this.connectedAt; // `[16:20]` 要提前知道片数,所以先按「sub-header + 裸流」的总长估一次。 + // 实测取值是 `0..totalPkg-1` 里的一个值(不是 `totalPkg-1` 定值),语义未 + // 还原,用随机值对齐分布。 const packetCount = videoPacketCount(VIDEO_SUBHEADER_LEN + hevc.length); - this.videoFrameFragment = Math.max(0, packetCount - 1); + this.videoFrameFragment = packetCount <= 1 ? 0 : Math.floor(Math.random() * packetCount); const subHeader = buildVideoSubHeader({ direction: 'c2s', frameIndex: this.videoFrameIndex, timestampMs: ptsMs, - baseTick: this.mediaBaseTick, + peerVideoBase: this.peerVideoBase, // `[6:8]`:对端只拿它做统计,按真实分布抽样即可(见 sampleVideoFrameQuality)。 quality: sampleVideoFrameQuality(splitHevcNals(hevc).some((nal) => isIrapNalType(nal.type))), fragment: this.videoFrameFragment, @@ -1641,7 +2190,9 @@ export class CallSession { const packets = buildVideoPackets({ frameData, - gidx: this.videoFrameIndex & 0xff, + // 16 位帧序号:包头里的 gidx/fidx 由它拆出。只写 gidx 会在第 256 帧回绕、 + // 帧序号倒退,对端按序号判连续性时会当成乱序而丢帧。 + frameIndex: this.videoFrameIndex, sessionTs: this.sessionTs, // tick 帧内恒定;seq 由 buildVideoPackets 按片递增。 tick: this.tick(), @@ -1652,11 +2203,87 @@ export class CallSession { this.videoFrameIndex++; } - private handleRelay(data: Uint8Array): void { + /** + * `0x02` 分流。 + * + * - `[4] = 1`:P2P 打洞握手(cmd 0–3),走 {@link handlePeerChannel}; + * - `[4] = 0`:服务端通道 —— GET_IN 应答(cmd=2)/ 对端路径下发(cmd=3)。 + * + * 旧实现只看 `[5:7]` 的 cmd 且只打日志,P2P 通道的包全被当成服务端包丢掉了: + * 对端的打洞探测没人应答,对方就一直不敢切直连。 + */ + private handleRelay(data: Uint8Array, from: { address: string; port: number }): void { if (data.length < 7) return; + if (data[4] === RELAY_CHANNEL.peer) { + this.handlePeerChannel(data, from); + return; + } const cmd = (data[5]! << 8) | data[6]!; // GET_IN 的应答(cmd=2)说明中继通道注册成功。 - if (cmd === 2) this.log?.debug('中继通道已注册'); + if (cmd === RelayCmd.getInAck) { + this.log?.debug('中继通道已注册'); + } else if (cmd === RelayCmd.peerPathInfo) { + const body = data.subarray(RELAY_HEADER_LEN); + if (body.length >= 24) { + const address = `${body[0]}.${body[1]}.${body[2]}.${body[3]}`; + const primaryPort = (body[6]! << 8) | body[7]!; + const directPort = (body[22]! << 8) | body[23]!; + const port = directPort > 0 ? directPort : primaryPort; + // 回 ACK(cmd=4)。真实客户端收到路径下发就应答,不回的话中继会每秒重发 + // 同一条(2026-10-26 WeQ 抓包:cmd=3 从 64 秒一直刷到挂断,cmd=4 一条 + // 都没有)。应答走「来的那个中继节点」原路返回。 + this.sendRelayPeerPathAck(from); + // ⚠️ **这条下发的是「对端在哪条中继通道上」,不是对端直连地址。** + // 旧实现直接 `applyPeerEndpoint(address, port)`,把中继节点自己当成了 + // 对端:媒体从 GCS 挪到中继、`mediaPath` 变成 `direct`,于是「以为直连 + // 了,其实还在中继」—— 和文档 §7.1.2 第 2 个坑同构,只是这次来源是 + // 中继推送而不是服务器回包。 + // + // 判据:地址若是中继节点自己(或就是 GCS),一律只记日志。 + this.log?.info('中继下发对端路径', { + address, + primaryPort, + directPort, + from: from.address, + }); + const isRelaySelf = + (this.gcs !== null && sameHost(this.gcs.ip, address) && this.gcs.port === port) || + this.relays.some((r) => sameHost(r.ip, address) && r.port === port) || + this.relays.some((r) => sameHost(r.ip, address) && r.port === primaryPort) || + sameHost(from.address, address); + if (isRelaySelf) { + this.log?.info('中继路径是节点自身,不当作对端端点', { address, port }); + return; + } + // 即便不是节点自身,中继下发的也只是「中继侧入口」,仍然要等回程证据 + // 才允许切直连 —— 所以这里只把它作为候选交给探测,不改变 mediaPath。 + if (port > 0) this.applyPeerEndpoint(address, port, { provisional: true }); + } + } + } + + /** + * 回 `cmd=4`:确认收到中继的对端路径下发。 + * + * body 全 0(16 字节),和 `cmd=3` 的 24 字节 body 不同 —— 见 2026-10-26 + * QQ 本体抓包:`cmd=3` 84 字节、`cmd=4` 56 字节,差值正好是 body 长度。 + */ + private sendRelayPeerPathAck(to: { address: string; port: number }): void { + if (!this.room) return; + this.opts.transport.send( + buildRelayPacket({ + roomId: this.room.roomId, + selfUin: Number(this.opts.selfUin), + peerUin: Number(this.opts.peerUin), + seq: this.relaySeq, + token: this.authToken, + channel: RELAY_CHANNEL.server, + cmd: RelayCmd.peerPathAck, + body: new Uint8Array(16), + }), + to.port, + to.address, + ); } // ── 超时 ── @@ -1798,6 +2425,7 @@ export class CallSession { for (const [handle, kind] of pairs) { if (handle !== null) this.timers[kind](handle); } + this.stopPeerProbeLoop(); this.heartbeatTimer = null; this.statusTimer = null; this.qfbTimer = null; diff --git a/packages/service/src/account/lbs_share.ts b/packages/service/src/account/lbs_share.ts new file mode 100644 index 000000000..0b1a193e3 --- /dev/null +++ b/packages/service/src/account/lbs_share.ts @@ -0,0 +1,149 @@ +/** + * LocationShareService —— 位置共享(`QQLBSShareSvc`):发起 / 上报 / 查询。 + * + * 与 {@link GroupModerationService} 同构:只负责在在线 pid 上发包,离线 / 风控失败 + * 原样上抛。协议细节见 `@weq/protocol` 的 `msg/lbs-share.ts`。 + * + * ## 三条命令的分工 + * + * {@link start} 建房间(私聊 / 群聊同一套命令,只有会话键不同) + * {@link report} 周期上报自己的坐标 —— **必须持续调用**,否则房间超时结束 + * {@link query} 拉取房间里所有成员的坐标 —— 唯一能拿到别人位置的途径 + * + * ## 没有「退出共享」 + * + * 房间靠上报续命,停止上报约 60 秒后服务端自己把状态置为 + * {@link LbsRoomStatus.ENDED}。所以退出 = 停掉心跳,不需要额外命令。 + * + * ## 卡片要单独发 + * + * 真实客户端发起后,服务端会推一条「发起了位置共享」消息;但 WeQ 的 SSO 是 + * 「发一包就断」,服务端判定发起方不在线就不投递。所以这里额外提供 + * {@link sendShareCard},由调用方自己发消息卡片(元素见 + * `buildShareLocationElements`)。 + * + * ## 调用方注意 + * + * 本服务**不维护定时器、也不缓存房间状态** —— 心跳节拍由调用方自己驱动 + * (服务端在 `report` 的响应里给出 `interval`,实测 2 秒)。这样做的原因: + * 渲染层/主进程谁持有会话谁负责生命周期,服务里藏一个 setInterval 会让 + * 「切账号 / 关窗口」时的清理变得不可追踪。 + */ +import type { NtHelperBinding } from '@weq/native'; +import { + buildShareLocationElements, + QueryLocation, + ReportLocation, + StartLocationShare, + type LbsScene, + type LocationReportResult, + type LocationRoom, + type LocationShareStartResult, +} from '@weq/protocol'; + +/** 一次共享的会话定位。 */ +export interface LocationShareTarget { + /** c2c = 私聊,group = 群聊。 */ + scene: LbsScene; + /** 私聊:对端 QQ 号。群聊:群号。 */ + peer: number; + /** 自己的 QQ 号;私聊必填,群聊可省(上报时仍需要)。 */ + selfUin: number; +} + +/** 发起共享的参数。 */ +export interface StartLocationShareServiceParams extends LocationShareTarget { + /** 经度(GCJ-02,**经度在前**)。 */ + longitude: number; + /** 纬度(GCJ-02)。 */ + latitude: number; + /** 期望上报间隔(秒);缺省 2。 */ + interval?: number; +} + +/** 上报一次坐标的参数。 */ +export interface ReportLocationServiceParams extends LocationShareTarget { + /** 经度(GCJ-02)。 */ + longitude: number; + /** 纬度(GCJ-02)。 */ + latitude: number; + /** 语义未确认的 double,缺省 0。 */ + extra?: number; +} + +/** 查询房间的参数。 */ +export interface QueryLocationServiceParams extends LocationShareTarget { + /** `0` = 首次,`1` = 轮询(缺省)。 */ + mode?: 0 | 1; +} + +/** 发卡片的参数。 */ +export interface SendShareCardParams extends LocationShareTarget { + /** 私聊需要对方 uid(有就带上,新版客户端以 uid 为准)。 */ + peerUid?: string; +} + +export class LocationShareService { + constructor( + private readonly nt: Pick, + private readonly resolvePid: () => number, + ) {} + + /** + * 发起一次位置共享。返回服务端的结果码(`ok=false` 时看 `code` / `message`)。 + * + * 发起后**立刻开始按 `interval` 调 {@link report}**,否则房间会超时。 + */ + async start(params: StartLocationShareServiceParams): Promise { + return StartLocationShare.invoke(this.nt, this.resolvePid(), { + scene: params.scene, + peer: params.peer, + selfUin: params.selfUin, + longitude: params.longitude, + latitude: params.latitude, + ...(params.interval !== undefined ? { interval: params.interval } : {}), + }); + } + + /** + * 上报一次坐标。响应里的 `interval` 是服务端要求的下次间隔,调用方按它排下一次。 + */ + async report(params: ReportLocationServiceParams): Promise { + return ReportLocation.invoke(this.nt, this.resolvePid(), { + scene: params.scene, + peer: params.peer, + selfUin: params.selfUin, + longitude: params.longitude, + latitude: params.latitude, + ...(params.extra !== undefined ? { extra: params.extra } : {}), + }); + } + + /** + * 查询房间里所有成员的坐标。 + * + * `status === LbsRoomStatus.ENDED` 说明房间已经结束(停止上报超时), + * 此时 `members` 会变空。 + */ + async query(params: QueryLocationServiceParams): Promise { + return QueryLocation.invoke(this.nt, this.resolvePid(), { + scene: params.scene, + peer: params.peer, + selfUin: params.selfUin, + ...(params.mode !== undefined ? { mode: params.mode } : {}), + }); + } + + /** + * 发一条「发起了位置共享」消息卡片。 + * + * 私聊只需要灰条 + 文本;群聊必须额外带 `generalFlags(37)`,否则能显示但点不了。 + * 具体元素由 `buildShareLocationElements` 构造(见 `@weq/protocol`)。 + * + * 这里**不直接调 sendMessage** —— 发消息要走 `MessageSendService`(它管 uid + * 解析、字体、装扮)。本方法只负责给出元素,实际发送由调用方转交。 + */ + buildShareCardElements(scene: LbsScene) { + return buildShareLocationElements(scene); + } +} diff --git a/packages/service/src/index.ts b/packages/service/src/index.ts index 5f79a8b36..a9bfb17db 100644 --- a/packages/service/src/index.ts +++ b/packages/service/src/index.ts @@ -798,6 +798,14 @@ export type { SendGroupJoinRequestServiceParams, } from './account/interaction'; export { GroupModerationService } from './account/group_moderation'; +export { LocationShareService } from './account/lbs_share'; +export type { + LocationShareTarget, + StartLocationShareServiceParams, + ReportLocationServiceParams, + QueryLocationServiceParams, + SendShareCardParams, +} from './account/lbs_share'; export { AccountWriteService } from './account/account_write'; export type { AccountWriteIdentity, AccountWriteOutcome } from './account/account_write'; export type { diff --git a/packages/service/test/call_session.test.ts b/packages/service/test/call_session.test.ts index 546b76aeb..6a59dc7f7 100644 --- a/packages/service/test/call_session.test.ts +++ b/packages/service/test/call_session.test.ts @@ -207,7 +207,7 @@ function authRspPacket(sessionTs = 999): Uint8Array { // ─────────────────────────── 用例 ─────────────────────────── describe('通话会话状态机', () => { - it('发起后先发认证、中继注册与编码器初始化', async () => { + it('发起后先发认证与编码器初始化,认证成功后再注册中继', async () => { const { session, transport } = makeSession(); await session.start(); @@ -215,6 +215,9 @@ describe('通话会话状态机', () => { expect(transport.sent.length).toBeGreaterThan(0); // 认证包发两次 expect(transport.gcs(GcsBodyType.authReq)).toHaveLength(2); + // 中继注册要等认证成功,不能抢跑 + expect(transport.sent.some((s) => s.data[0] === 0x02)).toBe(false); + transport.deliver(authRspPacket()); // 中继注册(首字节 0x02) expect(transport.sent.some((s) => s.data[0] === 0x02)).toBe(true); // 编码器初始化帧(0x5b, subType=1, seq=0) diff --git a/packages/service/test/call_video_session.test.ts b/packages/service/test/call_video_session.test.ts index e425fed96..878b22522 100644 --- a/packages/service/test/call_video_session.test.ts +++ b/packages/service/test/call_video_session.test.ts @@ -23,7 +23,13 @@ import { AUDIO_HEADER_LEN, AUDIO_STX, CallEvent, + GCS_STX, GcsBodyType, + PeerProbeCmd, + RELAY_STX, + type CallServer, + buildPeerProbe, + parsePeerProbe, VIDEO_CTRL_SUB_TYPE, VIDEO_EXT_LEN, VIDEO_EXT_TYPE, @@ -33,9 +39,13 @@ import { buildGcsPacket, buildVideoControl, buildVideoKeepalive, + parseGcsPacket, + pbGetBytes, parseVideoPacket, pbBytes, pbVarint, + videoFrameIndex, + VideoStateCode, } from '@weq/protocol'; // ─────────────────────────── 测试替身 ─────────────────────────── @@ -131,9 +141,14 @@ class FakeTimers implements CallTimers { class FakeTransport implements UdpTransport { readonly sent: Array<{ data: Uint8Array; port: number; address: string }> = []; closed = false; + /** 模拟本端 UDP 端口(`bt=25` 要公告它)。 */ + port = 51_348; private messageHandler?: (data: Uint8Array, from: { address: string; port: number }) => void; async bind(): Promise {} + localPort(): number { + return this.port; + } send(data: Uint8Array, port: number, address: string): void { this.sent.push({ data, port, address }); } @@ -204,7 +219,9 @@ class FakeVideoEncoder implements CallVideoEncoder { } } -function fakeSignaling(): CallSignaling & { startedWith: unknown[] } { +function fakeSignaling( + servers: CallServer[] = [{ ip: '113.250.57.28', port: 8000, type: 1 }], +): CallSignaling & { startedWith: unknown[] } { const startedWith: unknown[] = []; return { startedWith, @@ -216,7 +233,7 @@ function fakeSignaling(): CallSignaling & { startedWith: unknown[] } { sessionId: 88n, relationId: 15001, authKey: new Uint8Array(80).fill(1), - servers: [{ ip: '113.250.57.28', port: 8000, type: 1 }], + servers, }, authToken: 1234, }; @@ -225,10 +242,16 @@ function fakeSignaling(): CallSignaling & { startedWith: unknown[] } { }; } -function makeSession(options: { media?: 'audio' | 'video'; videoEncoder?: CallVideoEncoder } = {}) { +function makeSession( + options: { + media?: 'audio' | 'video'; + videoEncoder?: CallVideoEncoder; + servers?: CallServer[]; + } = {}, +) { const timers = new FakeTimers(); const transport = new FakeTransport(); - const signaling = fakeSignaling(); + const signaling = fakeSignaling(options.servers); const session = new CallSession({ selfUin: '1707889225', selfUid: 'u_self', @@ -270,6 +293,63 @@ function authRspPacket(sessionTs = 999): Uint8Array { return buildGcsPacket(head, new Uint8Array([0x08, 0x01])); } +/** + * `bt=9`(ctrlPush)——对端直连地址 + 端口。 + * + * 默认值取自 2026-10-26 同一次通话的抓包:`f7` = 对端 IPv6,`f8` = 56082。 + */ +function ctrlPushPacket(peerIpv6: Uint8Array, port: number): Uint8Array { + return ctrlPushWithBody(new Uint8Array([...pbBytes(7, peerIpv6), ...pbVarint(8, port)])); +} + +/** 自定 body 的 `bt=9`(给「只有 IPv4 候选」这类形态用)。 */ +function ctrlPushWithBody(body: Uint8Array): Uint8Array { + const head = buildGcsHead({ + roomId: 777n, + selfUin: 1707889225, + sessionTs: 555, + typeId: 0x6242, + bodyType: GcsBodyType.ctrlPush, + }); + return buildGcsPacket(head, body); +} + +/** + * `bt=27`(peerJoin)—— 服务端告知对端的真实 P2P 端口。 + * + * 黄金样本:2026-10-26 抓包 `08 d18da7d50a 10 c9a4b1ae06 18 02 22 08 d10700000000 db19`。 + */ +function peerJoinPacket(port: number): Uint8Array { + const head = buildGcsHead({ + roomId: 777n, + selfUin: 1707889225, + sessionTs: 555, + typeId: 0x6243, + bodyType: GcsBodyType.peerJoin, + }); + const endpoint = new Uint8Array(8); + endpoint.set([0xd1, 0x07, 0, 0, 0, 0, (port >> 8) & 0xff, port & 0xff], 0); + const body = new Uint8Array([ + ...pbVarint(1, 2863253201), + ...pbVarint(2, 1707889225), + ...pbVarint(3, 2), + ...pbBytes(4, endpoint), + ]); + return buildGcsPacket(head, body); +} + +/** 服务端下发的 `bt=31` 视频流状态。 */ +function videoStatePacket(state: number): Uint8Array { + const head = buildGcsHead({ + roomId: 777n, + selfUin: 1707889225, + sessionTs: 555, + typeId: 0x6244, + bodyType: GcsBodyType.videoStatePush, + }); + return buildGcsPacket(head, pbVarint(1, state)); +} + /** 拼一个视频分片(带 20 字节 sub-header + Annex B)。 */ function videoPacket(params: { gidx: number; @@ -298,6 +378,14 @@ function videoPacket(params: { return out; } +/** 十六进制串 → 字节。 */ +function hexBytes(s: string): Uint8Array { + const clean = s.replace(/\s+/g, ''); + const out = new Uint8Array(clean.length / 2); + for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16); + return out; +} + /** 把会话推进到 connected。 */ async function connect(ctx: ReturnType): Promise { await ctx.session.start(); @@ -348,7 +436,7 @@ describe('接收视频', () => { expect(frames).toHaveLength(1); const frame = frames[0]!; expect(frame.gidx).toBe(0); - expect(frame.isBaseLayer).toBe(true); + expect(frame.frameIndex).toBe(0); expect(frame.isKeyframe).toBe(true); expect(frame.hevc).not.toBeNull(); expect([...frame.hevc!]).toEqual([...body]); @@ -399,6 +487,37 @@ describe('接收视频', () => { expect(frames).toHaveLength(1); }); + /** + * 回归:关键帧要能读出分辨率。 + * + * 真实 SPS 来自 2026-10-25 抓包(Windows QQ 发的 640×360 Main)。早先 RBSP + * 起点按 12 字节跳(少算了 `sps_video_parameter_set_id` 那 1 字节),读出来是 + * `chroma=0 / w=0 / h=1`,`readHevcInfo` 永远返回 null —— 界面拿不到分辨率。 + */ + it('从真实关键帧的 SPS 读出 640×360', async () => { + const ctx = makeSession(); + await connect(ctx); + + // VPS + SPS + PPS + IDR_W_RADL,SPS 用的是抓包原文。 + const annexb = hexBytes( + '0000000140010c01' + + '00000001420101016000000300900000030000030096a005020169659aa4932e4020' + + '000000014401c172b0' + + '000000012601afe0', + ); + const whole = new Uint8Array(20 + annexb.length); + whole[0] = 20; + whole.set(annexb, 20); + + ctx.transport.deliver(videoPacket({ gidx: 0, pkgIdx: 0, totalPkg: 1, chunk: whole })); + expect(ctx.session.snapshot().videoInfo).toEqual({ + width: 640, + height: 360, + profile: 1, + codec: 'hevc', + }); + }); + it('控制包是周期公告,重复收到不翻转状态', async () => { // 2026-10-25 真机抓包:一方 14 秒内发了 316 个控制包(每秒 20+)。 // 早先按「收到即取反」实现,界面会每秒闪 20 次。 @@ -476,6 +595,87 @@ describe('发送视频', () => { expect(ctx.session.snapshot().videoSending).toBe(false); }); + /** + * 2026-10-26 三通原生抓包:视频通话收到 `event=4` 后发送 + * `bt=31/f1=3`,开启后续 f4/f5 建流握手。 + */ + it('视频通话振铃时发送一次 bt31/f1=3,并带 authKey', async () => { + const ctx = makeSession({ media: 'video' }); + await ctx.session.start(); + ctx.transport.deliver(authRspPacket()); + ctx.transport.reset(); + + ctx.transport.deliver(notifyPacket(CallEvent.ringing)); + ctx.transport.deliver(notifyPacket(CallEvent.ringing)); + + const states = ctx.transport.sent + .filter((entry) => entry.data[0] === GCS_STX) + .map((entry) => parseGcsPacket(entry.data)) + .filter((packet) => packet?.bodyType === GcsBodyType.videoStatePush); + expect(states).toHaveLength(1); + expect([...states[0]!.body]).toEqual([0x08, VideoStateCode.start]); + expect(pbGetBytes(states[0]!.head, 5)).toEqual(new Uint8Array(80).fill(1)); + }); + + it('语音通话振铃不发送视频流状态', async () => { + const ctx = makeSession(); + await ctx.session.start(); + ctx.transport.deliver(authRspPacket()); + ctx.transport.reset(); + + ctx.transport.deliver(notifyPacket(CallEvent.ringing)); + + expect( + ctx.transport.sent.filter( + (entry) => + entry.data[0] === GCS_STX && + parseGcsPacket(entry.data)?.bodyType === GcsBodyType.videoStatePush, + ), + ).toHaveLength(0); + }); + + it('收到 bt31/f1=4 时回空 bt32 ACK 和 bt31/f1=5', async () => { + const ctx = makeSession({ media: 'video' }); + await ctx.session.start(); + ctx.transport.deliver(authRspPacket()); + ctx.transport.reset(); + + ctx.transport.deliver(videoStatePacket(VideoStateCode.offer)); + + const parsed = ctx.transport.sent + .filter((entry) => entry.data[0] === GCS_STX) + .map((entry) => parseGcsPacket(entry.data)!); + const answers = parsed.filter((packet) => packet.bodyType === GcsBodyType.videoStatePush); + expect(answers).toHaveLength(1); + expect([...answers[0]!.body]).toEqual([0x08, VideoStateCode.accept]); + expect(pbGetBytes(answers[0]!.head, 5)).toEqual(new Uint8Array(80).fill(1)); + + const acks = parsed.filter((packet) => packet.bodyType === GcsBodyType.videoStateAck); + expect(acks).toHaveLength(1); + expect(acks[0]!.body).toHaveLength(0); + }); + + /** + * `bt=77` 是服务端推送。原生 QQ 在电脑→电脑和电脑→手机两通中都是 + * client→server 0 条,WeQ 不能自行伪造。 + */ + it('开摄像头和接通后都不发送客户端 bt=77', async () => { + const encoder = new FakeVideoEncoder(1); + const ctx = makeSession({ media: 'video', videoEncoder: encoder }); + await connect(ctx); + ctx.transport.reset(); + ctx.session.setCamera(false); + ctx.session.setCamera(true); + await ctx.timers.advanceAsync(2500); + + const pushes = ctx.transport.sent.filter( + (entry) => + entry.data[0] === GCS_STX && + parseGcsPacket(entry.data)?.bodyType === GcsBodyType.videoParamPush, + ); + expect(pushes).toHaveLength(0); + }); + it('快照里 videoSending 跟着切换', async () => { const ctx = makeSession({ media: 'video' }); await connect(ctx); @@ -513,6 +713,37 @@ describe('发送视频', () => { expect([...frame!.frameData.subarray(24, 26)]).toEqual([0x40, 0x01]); }); + /** + * 回归:发送侧必须用 16 位帧序号。 + * + * 早先 `gidx = videoFrameIndex & 0xff`、`fidx` 恒 1,第 256 帧包头从 + * `g=255/f=1` 跳回 `g=0/f=1`,帧序号倒退 —— 对端按序号判连续性时会当成乱序。 + */ + it('跨 256 帧后 fidx 递增,帧序号不回退', async () => { + const encoder = new FakeVideoEncoder(300); + const ctx = makeSession({ media: 'video', videoEncoder: encoder }); + await connect(ctx); + ctx.session.setCamera(true); + await ctx.timers.advanceAsync(5000); + + // 只看每个视频帧的首个分片(pkgIdx === 0),按发送顺序取帧序号。 + const seen: Array<{ gidx: number; fidx: number }> = []; + for (const packet of ctx.transport.media(VideoSubType.max)) { + const parsed = parseVideoPacket(packet)!; + if (parsed.rtp.pkgIdx !== 0) continue; + seen.push({ gidx: parsed.rtp.gidx, fidx: parsed.rtp.fidx }); + } + expect(seen.length).toBeGreaterThan(256); + + const indexes = seen.map((s) => videoFrameIndex(s.gidx, s.fidx)); + for (let i = 1; i < indexes.length; i++) { + expect(indexes[i]!).toBe(indexes[i - 1]! + 1); + } + // 第 256 帧必须已经换到 fidx=2,而不是把 gidx 绕回 0。 + expect(seen[255]).toEqual({ gidx: 255, fidx: 1 }); + expect(seen[256]).toEqual({ gidx: 0, fidx: 2 }); + }); + it('sub-header 按 c2s 方向填([2]=0x00、[3]=0x06、PTS 递增)', async () => { const encoder = new FakeVideoEncoder(2); const ctx = makeSession({ media: 'video', videoEncoder: encoder }); @@ -530,9 +761,41 @@ describe('发送视频', () => { // [8:12] 是距接通的毫秒数(首帧可能为 0,至少不能是 Date.now() 量级)。 const pts = new DataView(sh.buffer, sh.byteOffset + 8, 4).getUint32(0); expect(pts).toBeLessThan(60_000); - // [12:16] 是接通时定下的会话基准 tick(非 0)。 + // [12:16] 是对端视频流的时间基准:还没收到对端视频时必须是 0 + // (实测真实客户端前 128 帧全 0)。 const base = new DataView(sh.buffer, sh.byteOffset + 12, 4).getUint32(0); - expect(base).toBeGreaterThan(0); + expect(base).toBe(0); + }); + + /** + * 回归:收到对端视频后,本端 `[12:16]` 要换成对端的时间基准。 + * + * 2026-10-26 抓包:对端首个视频帧的 `[8:12]` 是 1952894717,本端从下一帧起 + * 就把它写进 `[12:16]`,之后整通不变。 + */ + it('收到对端视频后,[12:16] 换成对端时间基准', async () => { + const encoder = new FakeVideoEncoder(100); + const ctx = makeSession({ media: 'video', videoEncoder: encoder }); + await connect(ctx); + ctx.session.setCamera(true); + + // 对端发一帧:sub-header [8:12] = 1952894717(0x7466cefd)。 + const sub = new Uint8Array(20); + sub[0] = 20; + sub.set([0x74, 0x66, 0xce, 0xfd], 8); + const body = new Uint8Array([0, 0, 0, 1, 0x26, 0x01, 0xaf, 0xe0]); + const whole = new Uint8Array(sub.length + body.length); + whole.set(sub, 0); + whole.set(body, sub.length); + ctx.transport.deliver(videoPacket({ gidx: 0, pkgIdx: 0, totalPkg: 1, chunk: whole })); + ctx.transport.reset(); + + await ctx.timers.advanceAsync(200); + + const videos = ctx.transport.media(VideoSubType.max); + expect(videos.length).toBeGreaterThan(0); + const sh = parseVideoPacket(videos[0]!)!.payload; + expect([...sh.subarray(12, 16)]).toEqual([0x74, 0x66, 0xce, 0xfd]); }); it('关摄像头后不再发视频包', async () => { @@ -608,7 +871,7 @@ describe('媒体路径(中继 / 直连)', () => { expect(ctx.transport.mediaTo(PEER_IP, VideoSubType.max).length).toBeGreaterThan(0); }); - it('探测期结束后只发 P2P', async () => { + it('探测得到回程后只发 P2P', async () => { const encoder = new FakeVideoEncoder(10000); const ctx = makeSession({ media: 'video', videoEncoder: encoder }); await connect(ctx); @@ -627,6 +890,21 @@ describe('媒体路径(中继 / 直连)', () => { { address: PEER_IP, port: 47708 }, ); } + // 只有媒体包还不够:探测期满仍无 `0x02` 回程会被判成黑洞并退回中继, + // 所以这里补一条对端对我们探测的应答(见 `confirmPeerPath`)。 + ctx.transport.deliverFrom( + buildPeerProbe({ + roomId: 777n, + cmd: PeerProbeCmd.probeReply, + uinAbout: 1707889225, + seq: 7, + otherUin: 2863253201, + token: 1234, + clock: 1, + peerClock: 2, + }), + { address: PEER_IP, port: 47708 }, + ); ctx.transport.reset(); await ctx.timers.advanceAsync(50); @@ -662,6 +940,61 @@ describe('媒体路径(中继 / 直连)', () => { expect(ctx.transport.mediaTo(SERVER_IP, VideoSubType.max).length).toBeGreaterThan(0); }); + /** + * 回归:`bt=9` 的 `f8` 必须被当成对端端口,拿到就主动发包。 + * + * 2026-10-26 抓包:QQ 收到 `bt=9`(f7=对端 IPv6,f8=56082)后**立即**向 + * `240e:...:60c5:56089` 发 0x02 relay 探测包,对端回包后 P2P 建立。 + * + * 旧实现把端口硬编码 0,而 `mediaTargets` 要求 `port > 0` 才发对端 —— + * 不发就收不到回包,永远学不到端口,死锁在中继路径。 + */ + it('收到 bt=9 后按 f8 端口主动发往对端', async () => { + const encoder = new FakeVideoEncoder(1000); + const ctx = makeSession({ media: 'video', videoEncoder: encoder }); + await connect(ctx); + + const peerIpv6 = new Uint8Array([ + 0x24, 0x0e, 0x03, 0x99, 0x0f, 0x9d, 0x3f, 0x10, 0x78, 0x73, 0xe7, 0x0f, 0x57, 0x7e, 0x60, + 0xc5, + ]); + ctx.transport.reset(); + ctx.transport.deliver(ctrlPushPacket(peerIpv6, 56082)); + await ctx.timers.advanceAsync(20); + + const peer = '240e:399:f9d:3f10:7873:e70f:577e:60c5'; + const toPeer = ctx.transport.sent.filter((s) => s.address === peer); + expect(toPeer.length).toBeGreaterThan(0); + expect(toPeer.every((s) => s.port === 56082)).toBe(true); + }); + + /** + * 回归:GCS 按服务端下发的**原始顺序**取第一台,不看 `type`。 + * + * 2026-10-26 同一次通话的 OIDB + UDP 抓包(QQ 本体,P2P 正常):服务器表第一条 + * 是 `240e:930:c200:88::17:8000 type=2`,QQ 连的就是它。旧实现按 `type === 1` + * 过滤,把前面的 IPv6 条目全滤掉,只能退到第 6 条的 IPv4。 + */ + it('按原始顺序取第一台,不按 type 过滤', async () => { + const ctx = makeSession({ + media: 'video', + videoEncoder: new FakeVideoEncoder(1000), + servers: [ + { ip: '240e:930:c200:88::17', port: 8000, type: 2 }, + { ip: '240e:930:c200:86::1c', port: 8000, type: 2 }, + { ip: '113.250.57.133', port: 8001, type: 1 }, + ], + }); + await connect(ctx); + ctx.transport.reset(); + await ctx.timers.advanceAsync(20); + + const toFirst = ctx.transport.sent.filter((s) => s.address === '240e:930:c200:88::17'); + expect(toFirst.length).toBeGreaterThan(0); + // 后面的条目一个包都不该收到。 + expect(ctx.transport.sent.filter((s) => s.address === '113.250.57.133')).toHaveLength(0); + }); + it('保活包每拍两条:0x02 与 0x03 变体', async () => { const ctx = makeSession({ media: 'video' }); await connect(ctx); @@ -674,4 +1007,253 @@ describe('媒体路径(中继 / 直连)', () => { expect(bodies).toContain(0x02); expect(bodies).toContain(0x03); }); + + /** + * 回归:服务器自己的回包不能被当成「对端直连地址」。 + * + * socket 是 udp6 双栈,IPv4 来源在 `rinfo.address` 里是 `::ffff:a.b.c.d`,而 + * 服务器表里是 `a.b.c.d`。早先直接字符串比较,两者永远不等 —— 服务器回包被 + * 当成对端,媒体路径切到 direct 后其实仍发往同一台服务器(同 IP 同端口), + * 表现为「以为直连了,其实全程中继」,两端画面都极卡。 + */ + it('服务器回包(IPv4 映射形态)不会被学成对端直连地址', async () => { + const encoder = new FakeVideoEncoder(1000); + const ctx = makeSession({ media: 'video', videoEncoder: encoder }); + await connect(ctx); + + const whole = new Uint8Array(26); + whole[0] = 20; + whole.set([0, 0, 0, 1, 0x40, 0x01], 20); + const packet = (gidx: number) => videoPacket({ gidx, pkgIdx: 0, totalPkg: 1, chunk: whole }); + + // ① 对端先从真实 P2P 地址发一包 —— 学到端口,peerDirect 才有 port, + // 否则 mediaTargets 的 wantPeer 恒为 false,测不出问题。 + ctx.transport.deliverFrom(packet(0), { address: PEER_IP, port: 47708 }); + // ② 服务器以 `::ffff:SERVER_IP` 形态回包(真实 udp6 socket 的行为)。 + // 端口 8000 与服务器表一致,只有地址形态不同。**之后不再有对端包** —— + // 否则对端的下一包会把 peerDirect 改回去,掩盖问题。 + ctx.transport.deliverFrom(packet(1), { + address: `::ffff:${SERVER_IP}`, + port: 8000, + }); + ctx.transport.reset(); + await ctx.timers.advanceAsync(1500); + + // 服务器回包被误判成对端时,`peerDirect` 被改写成 + // `::ffff:113.250.57.28:8000`,媒体随后只发这个「伪对端」。 + const toFakePeer = ctx.transport.mediaTo(`::ffff:${SERVER_IP}`, VideoSubType.max); + expect(toFakePeer).toHaveLength(0); + // 真对端仍然收得到(直连没被服务器回包挤掉)。 + expect(ctx.transport.mediaTo(PEER_IP, VideoSubType.max).length).toBeGreaterThan(0); + }); +}); + +/** + * P2P 端点交换与打洞握手。 + * + * 2026-10-26 同一次通话的两份抓包(QQ 本体 P2P 正常 vs WeQ 全程中继)对比出来的 + * 三处缺口,全部在下面覆盖: + * + * 1. WeQ 从不发 `bt=25`(公告本端 P2P 端口)→ 对端永远不知道往哪打洞; + * 2. WeQ 不解析 `bt=27`(对端真实端口)→ 自己也不会打洞; + * 3. WeQ 不应答 `0x02 ch=1` 握手 → 对端不敢切直连,媒体只能全程中继。 + */ +describe('P2P 端点交换(bt=25 / bt=27)', () => { + const SERVER_IP = '113.250.57.28'; + const PEER_IP = '240e:399:f9d:3f10:7873:e70f:577e:60c5'; + const peerIpv6 = new Uint8Array([ + 0x24, 0x0e, 0x03, 0x99, 0x0f, 0x9d, 0x3f, 0x10, 0x78, 0x73, 0xe7, 0x0f, 0x57, 0x7e, 0x60, 0xc5, + ]); + + function joinReports(ctx: ReturnType): Uint8Array[] { + return ctx.transport.sent + .filter( + (s) => + s.data[0] === GCS_STX && parseGcsPacket(s.data)?.bodyType === GcsBodyType.peerJoinReport, + ) + .map((s) => s.data); + } + + it('接通后公告 bt=25,端口就是本端 UDP 端口', async () => { + const ctx = makeSession({ media: 'video', videoEncoder: new FakeVideoEncoder(10) }); + await connect(ctx); + + const reports = joinReports(ctx); + expect(reports.length).toBeGreaterThanOrEqual(1); + const body = parseGcsPacket(reports[0]!)!.body; + // 固定前 6 字节 + 末两字节的大端端口(抓包:d10700000000 c894)。 + expect([...body.subarray(body.length - 8, body.length - 2)]).toEqual([0xd1, 0x07, 0, 0, 0, 0]); + expect((body[body.length - 2]! << 8) | body[body.length - 1]!).toBe(ctx.transport.port); + }); + + it('未接通时不会公告(房间还没就绪)', async () => { + const ctx = makeSession({ media: 'video' }); + await ctx.session.start(); + expect(joinReports(ctx)).toHaveLength(0); + }); + + it('bt=27 的端口优先于 bt=9 的近似 f8', async () => { + const ctx = makeSession({ + media: 'video', + videoEncoder: new FakeVideoEncoder(1000), + servers: [{ ip: SERVER_IP, port: 8000, type: 1 }], + }); + await connect(ctx); + + // bt=9 给近似端口 56082,bt=27 给权威端口 56089(实测值)。 + ctx.transport.reset(); + ctx.transport.deliver(ctrlPushPacket(peerIpv6, 56082)); + ctx.transport.deliver(peerJoinPacket(56089)); + // bt=27 一到就应该立刻向权威端口补一拍探测(不能等 2 秒的下一拍, + // 那时探测期早已到期把端口清零)。 + const firstRound = ctx.transport.sent.filter( + (s) => s.data[0] === RELAY_STX && s.address === PEER_IP, + ); + expect(firstRound.length).toBeGreaterThan(0); + expect(firstRound[firstRound.length - 1]!.port).toBe(56089); + + // 对端从权威端口回包确认直连,之后持续有媒体包(否则直连静默看门狗 + // 会在 2 秒后回落中继、把端口清零)。 + ctx.transport.deliverFrom( + buildPeerProbe({ + roomId: 777n, + cmd: PeerProbeCmd.probeReply, + uinAbout: 1707889225, + seq: 7, + otherUin: 2863253201, + token: 1234, + clock: 1, + peerClock: 2, + }), + { address: PEER_IP, port: 56089 }, + ); + ctx.transport.reset(); + const whole = new Uint8Array(26); + whole[0] = 20; + whole.set([0, 0, 0, 1, 0x40, 0x01], 20); + for (let i = 0; i < 21; i++) { + await ctx.timers.advanceAsync(100); + ctx.transport.deliverFrom( + videoPacket({ gidx: i + 1, pkgIdx: 0, totalPkg: 1, chunk: whole }), + { address: PEER_IP, port: 56089 }, + ); + } + + const probes = ctx.transport.sent.filter( + (s) => s.data[0] === RELAY_STX && s.address === PEER_IP, + ); + expect(probes.length).toBeGreaterThan(0); + expect(probes.every((p) => p.port === 56089)).toBe(true); + }); + + it('只有 IPv4 候选(f5/f6)时也能学到对端', async () => { + const ctx = makeSession({ + media: 'video', + videoEncoder: new FakeVideoEncoder(1000), + servers: [{ ip: SERVER_IP, port: 8000, type: 1 }], + }); + await connect(ctx); + + // 抓包里 WeQ 实际收到的那种形态:只有 f5(fixed32 IPv4)+ f6(端口)。 + const body = new Uint8Array([(5 << 3) | 5, 0xcb, 0xf6, 0xb0, 0x50, ...pbVarint(6, 8889)]); + ctx.transport.reset(); + ctx.transport.deliver(ctrlPushWithBody(body)); + await ctx.timers.advanceAsync(20); + + const probes = ctx.transport.sent.filter((s) => s.address === '203.246.176.80'); + expect(probes.length).toBeGreaterThan(0); + expect(probes.every((p) => p.port === 8889)).toBe(true); + }); +}); + +describe('P2P 打洞握手(0x02 ch=1)', () => { + const PEER_IP = '240e:399:f9d:3f10:7873:e70f:577e:60c5'; + const SERVER_IP = '113.250.57.28'; + + function probeFromPeer(cmd: number, seq = 5): Uint8Array { + return buildPeerProbe({ + roomId: 777n, + cmd, + uinAbout: 2863253201, + seq, + otherUin: 1707889225, + token: 1234, + clock: 0x74d7e43a, + peerClock: 0, + }); + } + + it('收到对端 probe 后应答 cmd1(回抄字段、换上自己的时钟)', async () => { + const ctx = makeSession({ media: 'video', videoEncoder: new FakeVideoEncoder(1000) }); + await connect(ctx); + ctx.transport.reset(); + + const packet = probeFromPeer(PeerProbeCmd.probe); + ctx.transport.deliverFrom(packet, { address: PEER_IP, port: 56089 }); + + // 除了应答,我们同时也会开始向对端打洞(probe),所以按 cmd 筛。 + const replies = ctx.transport.sent.filter( + (s) => + s.data[0] === RELAY_STX && + s.address === PEER_IP && + parsePeerProbe(s.data)?.cmd === PeerProbeCmd.probeReply, + ); + expect(replies).toHaveLength(1); + const parsed = parsePeerProbe(replies[0]!.data)!; + // 回抄的是「关于对端」的那一份 + expect(parsed.uinAbout).toBe(2863253201); + expect(parsed.seq).toBe(5); + expect(parsed.token).toBe(1234); + expect(parsed.clock).toBe(0x74d7e43a); + expect(replies[0]!.port).toBe(56089); + }); + + it('收到对端 probeAck(cmd2)后应答 cmd3', async () => { + const ctx = makeSession({ media: 'video', videoEncoder: new FakeVideoEncoder(1000) }); + await connect(ctx); + ctx.transport.reset(); + + ctx.transport.deliverFrom(probeFromPeer(PeerProbeCmd.probeAck, 6), { + address: PEER_IP, + port: 56089, + }); + + const replies = ctx.transport.sent.filter( + (s) => + s.data[0] === RELAY_STX && + s.address === PEER_IP && + parsePeerProbe(s.data)?.cmd === PeerProbeCmd.probeAckReply, + ); + expect(replies).toHaveLength(1); + }); + + it('对端应答我们的 probe 后,媒体立即切直连(不再发服务器)', async () => { + const ctx = makeSession({ media: 'video', videoEncoder: new FakeVideoEncoder(1000) }); + await connect(ctx); + ctx.transport.deliverFrom(probeFromPeer(PeerProbeCmd.probe), { + address: PEER_IP, + port: 56089, + }); + ctx.transport.reset(); + + // 对端回了我们 probe 的应答(cmd1)。 + ctx.transport.deliverFrom( + buildPeerProbe({ + roomId: 777n, + cmd: PeerProbeCmd.probeReply, + uinAbout: 1707889225, + seq: 7, + otherUin: 2863253201, + token: 1234, + clock: 1, + peerClock: 2, + }), + { address: PEER_IP, port: 56089 }, + ); + ctx.transport.reset(); + await ctx.timers.advanceAsync(50); + + expect(ctx.transport.mediaTo(SERVER_IP, VideoSubType.max)).toHaveLength(0); + expect(ctx.transport.mediaTo(PEER_IP, VideoSubType.max).length).toBeGreaterThan(0); + }); }); diff --git a/packages/service/test/lbs_share.test.ts b/packages/service/test/lbs_share.test.ts new file mode 100644 index 000000000..264726b32 --- /dev/null +++ b/packages/service/test/lbs_share.test.ts @@ -0,0 +1,217 @@ +/** + * LocationShareService 的离线单测:断言三条命令真正上 wire 的 cmd / body, + * 并钉住私聊与群聊的会话键差异。不连任何网络。 + * + * 报文里的每个字节都对着真机抓包核对过(见 `@weq/protocol` 的 `msg/lbs-share`)。 + */ + +import { describe, expect, it } from 'vitest'; +import { LocationShareService } from '../src/account/lbs_share'; +import { + buildShareLocationElements, + decode, + QueryLocation, + ReportLocation, + StartLocationShare, +} from '@weq/protocol'; + +interface TrpcCall { + pid: number; + cmd: string; + body: Uint8Array; + needSign: boolean; +} + +function fakeNative() { + const trpc: TrpcCall[] = []; + return { + trpc, + sendPacket: async ( + pid: number, + cmd: string, + body: Buffer, + needSign: boolean, + ): Promise => { + trpc.push({ pid, cmd, body: new Uint8Array(body), needSign }); + return Buffer.alloc(0); + }, + }; +} + +const PID = 4321; +const SELF = 2863253201; +const PEER = 1707889225; +const GROUP = 673646675; +const LNG = 103.931543; +const LAT = 30.75535; + +function makeService(nt: ReturnType) { + return new LocationShareService(nt, () => PID); +} + +describe('LocationShareService', () => { + it('start(c2c) 走 room_operation,不签名,会话键按 uin 升序', async () => { + const nt = fakeNative(); + await makeService(nt).start({ + scene: 'c2c', + peer: PEER, + selfUin: SELF, + longitude: LNG, + latitude: LAT, + }); + + expect(nt.trpc).toHaveLength(1); + const call = nt.trpc[0]!; + expect(call.pid).toBe(PID); + expect(call.cmd).toBe('QQLBSShareSvc.room_operation'); + expect(call.needSign).toBe(false); + + const body = decode(StartLocationShare.reqSchema, call.body); + // 私聊会话键按 uin 升序(小号在前),不是「对端/自己」。 + expect(body.session).toEqual({ scene: 2, uinA: BigInt(PEER), uinB: BigInt(SELF) }); + expect(body.op).toBe(1); + expect(body.interval).toBe(2); + expect(body.longitude).toBeCloseTo(LNG, 6); + expect(body.latitude).toBeCloseTo(LAT, 6); + }); + + it('c2c 反方向发包产生完全相同的 wire(服务端只认规范顺序)', async () => { + // 回归:早期实现把「对端」写 field2、「自己」写 field3,于是「自己是大号」 + // 时碰巧正确、「自己是小号」时服务端回 10001 param error。同一对号的两个方向 + // 必须逐字节相同 —— 安卓抓包里两个账号各自发包,请求体完全一致。 + const forward = fakeNative(); + await makeService(forward).start({ + scene: 'c2c', + peer: PEER, + selfUin: SELF, + longitude: LNG, + latitude: LAT, + }); + const backward = fakeNative(); + await makeService(backward).start({ + scene: 'c2c', + peer: SELF, + selfUin: PEER, + longitude: LNG, + latitude: LAT, + }); + + expect(Buffer.from(backward.trpc[0]!.body).equals(Buffer.from(forward.trpc[0]!.body))).toBe( + true, + ); + const body = decode(StartLocationShare.reqSchema, backward.trpc[0]!.body); + expect(body.session).toEqual({ scene: 2, uinA: BigInt(PEER), uinB: BigInt(SELF) }); + }); + + it('start(group) 会话键是 {scene:1, peer:群号, selfUin:0}', async () => { + const nt = fakeNative(); + await makeService(nt).start({ + scene: 'group', + peer: GROUP, + selfUin: SELF, + longitude: LNG, + latitude: LAT, + }); + + const body = decode(StartLocationShare.reqSchema, nt.trpc[0]!.body); + expect(body.session).toEqual({ scene: 1, uinA: BigInt(GROUP) }); + }); + + it('report(group) 的会话键仍是 {scene:2, peer:群号, selfUin}', async () => { + // 抓包里两种场景的 report_location 1.1 都是 2、1.3 都是自己 —— 不跟场景走。 + const nt = fakeNative(); + await makeService(nt).report({ + scene: 'group', + peer: GROUP, + selfUin: SELF, + longitude: LNG, + latitude: LAT, + }); + + expect(nt.trpc[0]!.cmd).toBe('QQLBSShareSvc.report_location'); + const body = decode(ReportLocation.reqSchema, nt.trpc[0]!.body); + expect(body.session).toEqual({ scene: 2, uinA: BigInt(GROUP), uinB: BigInt(SELF) }); + // extra 缺省 0 → proto3 省略,解码回来自然没有这个字段。 + expect(body.extra).toBeUndefined(); + }); + + it('report(c2c) 带 extra', async () => { + const nt = fakeNative(); + await makeService(nt).report({ + scene: 'c2c', + peer: PEER, + selfUin: SELF, + longitude: LNG, + latitude: LAT, + extra: 208.03, + }); + + const body = decode(ReportLocation.reqSchema, nt.trpc[0]!.body); + expect(body.extra).toBeCloseTo(208.03, 2); + }); + + it('query 默认 mode=1,群聊会话键同 room_operation', async () => { + const nt = fakeNative(); + await makeService(nt).query({ scene: 'group', peer: GROUP, selfUin: SELF }); + + expect(nt.trpc[0]!.cmd).toBe('QQLBSShareSvc.room_query'); + const body = decode(QueryLocation.reqSchema, nt.trpc[0]!.body); + expect(body.session).toEqual({ scene: 1, uinA: BigInt(GROUP) }); + expect(body.mode).toBe(1); + }); + + it('私聊 start 缺 selfUin 直接报错,不发包', async () => { + const nt = fakeNative(); + await expect( + makeService(nt).start({ + scene: 'c2c', + peer: PEER, + selfUin: 0, + longitude: LNG, + latitude: LAT, + }), + ).rejects.toThrow(/selfUin/); + expect(nt.trpc).toHaveLength(0); + }); + + it('坐标越界直接报错', async () => { + const nt = fakeNative(); + await expect( + makeService(nt).start({ + scene: 'c2c', + peer: PEER, + selfUin: SELF, + longitude: 999, + latitude: LAT, + }), + ).rejects.toThrow(/longitude/); + expect(nt.trpc).toHaveLength(0); + }); +}); + +describe('buildShareLocationElements', () => { + it('私聊只有灰条 + 文本兜底', () => { + const elements = buildShareLocationElements('c2c'); + expect(elements).toHaveLength(2); + expect(elements[0]).toMatchObject({ kind: 'raw' }); + expect(elements[1]).toMatchObject({ kind: 'raw' }); + }); + + it('群聊额外带 generalFlags(37),否则卡片点不了', () => { + const elements = buildShareLocationElements('group'); + expect(elements).toHaveLength(3); + const general = elements[1] as { kind: 'raw'; elem: Record }; + expect(general.elem).toHaveProperty('generalFlags'); + }); + + it('灰条是 commonElem serviceType=31,文案「发起了位置共享」', () => { + const [tip] = buildShareLocationElements('c2c') as { + kind: 'raw'; + elem: { commonElem: { serviceType: number; pbElem: Uint8Array; businessType: number } }; + }[]; + expect(tip!.elem.commonElem.serviceType).toBe(31); + expect(tip!.elem.commonElem.businessType).toBe(1); + const text = new TextDecoder().decode(tip!.elem.commonElem.pbElem.subarray(2)); + expect(text).toBe('发起了位置共享'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 255bc103a..65d142e4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -455,7 +455,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.0.0 - version: 4.3.0(vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)) + version: 4.3.0(vite@8.3.4(@types/node@22.19.20)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)) '@types/archiver': specifier: ^5.3.4 version: 5.3.4 @@ -466,8 +466,8 @@ importers: specifier: ^8.5.13 version: 8.18.1 '@vitejs/plugin-react': - specifier: ^4.3.4 - version: 4.7.0(supports-color@7.2.0)(vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)) + specifier: ^6.1.2 + version: 6.1.2(vite@8.3.4(@types/node@22.19.20)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)) esbuild: specifier: ^0.28.1 version: 0.28.1 @@ -481,8 +481,8 @@ importers: specifier: ^5.7.3 version: 5.9.3 vite: - specifier: ^6.0.0 - version: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4) + specifier: ^8.3.4 + version: 8.3.4(@types/node@22.19.20)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4) packages/account: dependencies: @@ -759,18 +759,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-react-jsx-self@7.29.7': - resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-jsx-source@7.29.7': - resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -933,12 +921,6 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.25.12': - resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} @@ -951,12 +933,6 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.25.12': - resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.28.1': resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} @@ -969,12 +945,6 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.25.12': - resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.28.1': resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} @@ -987,12 +957,6 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.25.12': - resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.28.1': resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} @@ -1005,12 +969,6 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.25.12': - resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.28.1': resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} @@ -1023,12 +981,6 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.25.12': - resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.28.1': resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} @@ -1041,12 +993,6 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.25.12': - resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.28.1': resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} @@ -1059,12 +1005,6 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.12': - resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} @@ -1077,12 +1017,6 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.25.12': - resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.28.1': resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} @@ -1095,12 +1029,6 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.25.12': - resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.28.1': resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} @@ -1113,12 +1041,6 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.25.12': - resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.28.1': resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} @@ -1131,12 +1053,6 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.25.12': - resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.28.1': resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} @@ -1149,12 +1065,6 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.25.12': - resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.28.1': resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} @@ -1167,12 +1077,6 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.25.12': - resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.28.1': resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} @@ -1185,12 +1089,6 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.25.12': - resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.28.1': resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} @@ -1203,12 +1101,6 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.25.12': - resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.28.1': resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} @@ -1221,24 +1113,12 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.25.12': - resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.28.1': resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.25.12': - resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-arm64@0.28.1': resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} @@ -1251,24 +1131,12 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.12': - resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.25.12': - resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-arm64@0.28.1': resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} @@ -1281,24 +1149,12 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.12': - resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.25.12': - resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/openharmony-arm64@0.28.1': resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} @@ -1311,12 +1167,6 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.25.12': - resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.28.1': resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} @@ -1329,12 +1179,6 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.25.12': - resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.28.1': resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} @@ -1347,12 +1191,6 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.25.12': - resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.28.1': resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} @@ -1365,12 +1203,6 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.25.12': - resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.28.1': resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} @@ -1843,9 +1675,6 @@ packages: cpu: [x64] os: [win32] - '@rolldown/pluginutils@1.0.0-beta.27': - resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} - '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} @@ -2176,18 +2005,6 @@ packages: '@types/archiver@5.3.4': resolution: {integrity: sha512-Lj7fLBIMwYFgViVVZHEdExZC3lVYsl+QL0VmdNdIzGZH544jHveYWij6qdnBgJQDnR7pMKliN9z2cPZFEbhyPw==} - '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} - - '@types/babel__generator@7.27.0': - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} - - '@types/babel__template@7.4.4': - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} - - '@types/babel__traverse@7.28.0': - resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} @@ -2373,12 +2190,6 @@ packages: '@upsetjs/venn.js@2.0.0': resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} - '@vitejs/plugin-react@4.7.0': - resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} - engines: {node: ^14.18.0 || >=16.0.0} - peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - '@vitejs/plugin-react@6.1.2': resolution: {integrity: sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3280,11 +3091,6 @@ packages: engines: {node: '>=12'} hasBin: true - esbuild@0.25.12: - resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} - engines: {node: '>=18'} - hasBin: true - esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} @@ -4616,10 +4422,6 @@ packages: peerDependencies: react: '*' - react-refresh@0.17.0: - resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} - engines: {node: '>=0.10.0'} - react@19.2.7: resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} engines: {node: '>=0.10.0'} @@ -5268,46 +5070,6 @@ packages: terser: optional: true - vite@6.4.3: - resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - peerDependencies: - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - jiti: '>=1.21.0' - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - vite@8.3.4: resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5631,16 +5393,6 @@ snapshots: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-plugin-utils': 7.29.7 - - '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-plugin-utils': 7.29.7 - '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -5820,225 +5572,147 @@ snapshots: '@esbuild/aix-ppc64@0.21.5': optional: true - '@esbuild/aix-ppc64@0.25.12': - optional: true - '@esbuild/aix-ppc64@0.28.1': optional: true '@esbuild/android-arm64@0.21.5': optional: true - '@esbuild/android-arm64@0.25.12': - optional: true - '@esbuild/android-arm64@0.28.1': optional: true '@esbuild/android-arm@0.21.5': optional: true - '@esbuild/android-arm@0.25.12': - optional: true - '@esbuild/android-arm@0.28.1': optional: true '@esbuild/android-x64@0.21.5': optional: true - '@esbuild/android-x64@0.25.12': - optional: true - '@esbuild/android-x64@0.28.1': optional: true '@esbuild/darwin-arm64@0.21.5': optional: true - '@esbuild/darwin-arm64@0.25.12': - optional: true - '@esbuild/darwin-arm64@0.28.1': optional: true '@esbuild/darwin-x64@0.21.5': optional: true - '@esbuild/darwin-x64@0.25.12': - optional: true - '@esbuild/darwin-x64@0.28.1': optional: true '@esbuild/freebsd-arm64@0.21.5': optional: true - '@esbuild/freebsd-arm64@0.25.12': - optional: true - '@esbuild/freebsd-arm64@0.28.1': optional: true '@esbuild/freebsd-x64@0.21.5': optional: true - '@esbuild/freebsd-x64@0.25.12': - optional: true - '@esbuild/freebsd-x64@0.28.1': optional: true '@esbuild/linux-arm64@0.21.5': optional: true - '@esbuild/linux-arm64@0.25.12': - optional: true - '@esbuild/linux-arm64@0.28.1': optional: true '@esbuild/linux-arm@0.21.5': optional: true - '@esbuild/linux-arm@0.25.12': - optional: true - '@esbuild/linux-arm@0.28.1': optional: true '@esbuild/linux-ia32@0.21.5': optional: true - '@esbuild/linux-ia32@0.25.12': - optional: true - '@esbuild/linux-ia32@0.28.1': optional: true '@esbuild/linux-loong64@0.21.5': optional: true - '@esbuild/linux-loong64@0.25.12': - optional: true - '@esbuild/linux-loong64@0.28.1': optional: true '@esbuild/linux-mips64el@0.21.5': optional: true - '@esbuild/linux-mips64el@0.25.12': - optional: true - '@esbuild/linux-mips64el@0.28.1': optional: true '@esbuild/linux-ppc64@0.21.5': optional: true - '@esbuild/linux-ppc64@0.25.12': - optional: true - '@esbuild/linux-ppc64@0.28.1': optional: true '@esbuild/linux-riscv64@0.21.5': optional: true - '@esbuild/linux-riscv64@0.25.12': - optional: true - '@esbuild/linux-riscv64@0.28.1': optional: true '@esbuild/linux-s390x@0.21.5': optional: true - '@esbuild/linux-s390x@0.25.12': - optional: true - '@esbuild/linux-s390x@0.28.1': optional: true '@esbuild/linux-x64@0.21.5': optional: true - '@esbuild/linux-x64@0.25.12': - optional: true - '@esbuild/linux-x64@0.28.1': optional: true - '@esbuild/netbsd-arm64@0.25.12': - optional: true - '@esbuild/netbsd-arm64@0.28.1': optional: true '@esbuild/netbsd-x64@0.21.5': optional: true - '@esbuild/netbsd-x64@0.25.12': - optional: true - '@esbuild/netbsd-x64@0.28.1': optional: true - '@esbuild/openbsd-arm64@0.25.12': - optional: true - '@esbuild/openbsd-arm64@0.28.1': optional: true '@esbuild/openbsd-x64@0.21.5': optional: true - '@esbuild/openbsd-x64@0.25.12': - optional: true - '@esbuild/openbsd-x64@0.28.1': optional: true - '@esbuild/openharmony-arm64@0.25.12': - optional: true - '@esbuild/openharmony-arm64@0.28.1': optional: true '@esbuild/sunos-x64@0.21.5': optional: true - '@esbuild/sunos-x64@0.25.12': - optional: true - '@esbuild/sunos-x64@0.28.1': optional: true '@esbuild/win32-arm64@0.21.5': optional: true - '@esbuild/win32-arm64@0.25.12': - optional: true - '@esbuild/win32-arm64@0.28.1': optional: true '@esbuild/win32-ia32@0.21.5': optional: true - '@esbuild/win32-ia32@0.25.12': - optional: true - '@esbuild/win32-ia32@0.28.1': optional: true '@esbuild/win32-x64@0.21.5': optional: true - '@esbuild/win32-x64@0.25.12': - optional: true - '@esbuild/win32-x64@0.28.1': optional: true @@ -6415,8 +6089,6 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.2.14': optional: true - '@rolldown/pluginutils@1.0.0-beta.27': {} - '@rolldown/pluginutils@1.0.1': {} '@rollup/rollup-android-arm-eabi@4.61.1': @@ -6608,13 +6280,6 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 - '@tailwindcss/vite@4.3.0(vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4))': - dependencies: - '@tailwindcss/node': 4.3.0 - '@tailwindcss/oxide': 4.3.0 - tailwindcss: 4.3.0 - vite: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4) - '@tailwindcss/vite@4.3.0(vite@8.3.4(@types/node@22.19.20)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4))': dependencies: '@tailwindcss/node': 4.3.0 @@ -6660,27 +6325,6 @@ snapshots: dependencies: '@types/readdir-glob': 1.1.5 - '@types/babel__core@7.20.5': - dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - '@types/babel__generator': 7.27.0 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.28.0 - - '@types/babel__generator@7.27.0': - dependencies: - '@babel/types': 7.29.7 - - '@types/babel__template@7.4.4': - dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - - '@types/babel__traverse@7.28.0': - dependencies: - '@babel/types': 7.29.7 - '@types/cacheable-request@6.0.3': dependencies: '@types/http-cache-semantics': 4.2.0 @@ -6902,18 +6546,6 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) - '@vitejs/plugin-react@4.7.0(supports-color@7.2.0)(vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4))': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@rolldown/pluginutils': 1.0.0-beta.27 - '@types/babel__core': 7.20.5 - react-refresh: 0.17.0 - vite: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4) - transitivePeerDependencies: - - supports-color - '@vitejs/plugin-react@6.1.2(vite@8.3.4(@types/node@22.19.20)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4))': dependencies: '@rolldown/pluginutils': 1.0.1 @@ -7964,35 +7596,6 @@ snapshots: '@esbuild/win32-ia32': 0.21.5 '@esbuild/win32-x64': 0.21.5 - esbuild@0.25.12: - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.12 - '@esbuild/android-arm': 0.25.12 - '@esbuild/android-arm64': 0.25.12 - '@esbuild/android-x64': 0.25.12 - '@esbuild/darwin-arm64': 0.25.12 - '@esbuild/darwin-x64': 0.25.12 - '@esbuild/freebsd-arm64': 0.25.12 - '@esbuild/freebsd-x64': 0.25.12 - '@esbuild/linux-arm': 0.25.12 - '@esbuild/linux-arm64': 0.25.12 - '@esbuild/linux-ia32': 0.25.12 - '@esbuild/linux-loong64': 0.25.12 - '@esbuild/linux-mips64el': 0.25.12 - '@esbuild/linux-ppc64': 0.25.12 - '@esbuild/linux-riscv64': 0.25.12 - '@esbuild/linux-s390x': 0.25.12 - '@esbuild/linux-x64': 0.25.12 - '@esbuild/netbsd-arm64': 0.25.12 - '@esbuild/netbsd-x64': 0.25.12 - '@esbuild/openbsd-arm64': 0.25.12 - '@esbuild/openbsd-x64': 0.25.12 - '@esbuild/openharmony-arm64': 0.25.12 - '@esbuild/sunos-x64': 0.25.12 - '@esbuild/win32-arm64': 0.25.12 - '@esbuild/win32-ia32': 0.25.12 - '@esbuild/win32-x64': 0.25.12 - esbuild@0.28.1: optionalDependencies: '@esbuild/aix-ppc64': 0.28.1 @@ -9617,8 +9220,6 @@ snapshots: dependencies: react: 19.2.7 - react-refresh@0.17.0: {} - react@19.2.7: {} read-binary-file-arch@1.0.6(supports-color@7.2.0): @@ -10399,21 +10000,6 @@ snapshots: fsevents: 2.3.3 lightningcss: 1.32.0 - vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4): - dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.15 - rollup: 4.61.1 - tinyglobby: 0.2.17 - optionalDependencies: - '@types/node': 22.19.20 - fsevents: 2.3.3 - jiti: 2.7.0 - lightningcss: 1.32.0 - tsx: 4.22.4 - vite@8.3.4(@types/node@22.19.20)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4): dependencies: lightningcss: 1.33.0 diff --git a/resources/emoji/market.csv b/resources/emoji/market.csv index 91c5fca21..f9f0afec2 100644 --- a/resources/emoji/market.csv +++ b/resources/emoji/market.csv @@ -27039,3 +27039,40 @@ Some are born to endless night.",1 249242,猫悠-Mew1,猫悠-Mew,1 249243,可可小宅天使篇1,B站关注可可小宅谢谢喵~,1 249244,水小宝,节约用水、低碳生活、爱护自然,1 +249245,流桑桑桑2,这里是流桑,一只破画画的赤狐,1 +249246,暖萌治愈日常,软乎乎的企鹅与小浣熊的暖冬治愈日,1 +249247,欧武欧武,欧武表情第一期,1 +249248,抽象糕列巴2,小糕再次在神秘力量下变抽象了,1 +249249,狐兰兹_01,可爱狐兰兹表情包,1 +249250,皮卡少个丘w2,虚拟主播皮卡少个丘w,1 +249251,咖咪02.1,恶魔猫小咖,B咖啡咪KoFFee,1 +249252,咖咪02.2,恶魔猫小咖B站咖啡咪KoFFee,1 +249253,咖咪02.4,恶魔猫小咖B站咖啡咪KoFFee,1 +249254,萧浩的狐兔开心宝1,一起来玩吧,1 +249255,空雨,(^ω^) 绘:盐水毛小豆,1 +249256,傻傻cookiya系列1,傻傻克琪亚!,1 +249257,日月熜熜,熜喵、甜妹和笔架。,1 +249258,小明扬呀,关注音符明扬,画师小连翘,1 +249259,喵千绘,是一只魔法猫猫喵~,1 +249260,契和他的朋友们Dx,但为君故,沉吟至今。,1 +249261,赛鹿鹿,小魔女日常表情包,软萌治愈,1 +249262,影之颂歌角色篇,一定是个阳光开朗的故事喵~,1 +249263,蜂蜜菠萝喵,祝你好运喵(作者:甘乐能),1 +249264,吸血姬凌泠依,世界上最强大的吸血鬼!!!,1 +249265,芒果糯咪团,小猫你可以请假(作者:甘乐能),1 +249266,糯米天师_01,米需米制作的糯米天师,1 +249267,糯米天师_02,米需米制作的糯米天师,1 +249268,布萨沃夫,神秘白色小狼 画师b站夜卡,1 +249269,年糕狐V2,喜欢和酒狐贴贴的年糕狐,1 +249270,水水和南南1,水水和南南的日常,1 +249271,Morsd小巫师,小巫师,今天也在认真搞怪。,1 +249272,冰盐大虎的日常1,冰盐大虎的日常1,1 +249273,小蜘蛛表情包第七弹jpg版,小蜘蛛表情包第七弹jpg版,1 +249274,小蜘蛛表情包第六弹jpg版,小蜘蛛表情包第六弹jpg版,1 +249275,小蜘蛛表情包第四弹jpg版,小蜘蛛表情包第四弹jpg版,1 +249276,泉户猫丸K,B站泉户猫丸,画师riri哒,1 +249277,时间向前,干员们的日常生活,1 +249278,猪猪们的实习生活,金牌实习生来了!,1 +249279,柚小冈,适合日常聊天、推荐家乡蜜柚,1 +249280,星塔日常篇,诺瓦大陆可爱的日常,1 +249281,闪闪鱼碎碎念,闪闪鱼表情包,猫猫鱼的多样情绪,1 diff --git a/resources/redpack/skins.json b/resources/redpack/skins.json index 0e9f2502a..212a028d4 100644 --- a/resources/redpack/skins.json +++ b/resources/redpack/skins.json @@ -1,5 +1,5 @@ { - "generatedAt": 1792374450, + "generatedAt": 1793042730, "count": 836, "range": [1039, 2683], "skins": [ diff --git a/scripts/fetch-native.mjs b/scripts/fetch-native.mjs index 13660a704..658fa1756 100644 --- a/scripts/fetch-native.mjs +++ b/scripts/fetch-native.mjs @@ -67,7 +67,7 @@ import https from 'node:https'; import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; -import { Readable, Transform } from 'node:stream'; +import { Transform } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { connect as tlsConnect } from 'node:tls'; import { fileURLToPath } from 'node:url'; @@ -398,11 +398,14 @@ async function readBody(res) { } async function download(url, dest, { label, size } = {}) { - const res = await fetch(url, { redirect: 'follow' }); - if (!res.ok || !res.body) { - throw new Error(`下载失败 ${res.status} ${res.statusText}:${url}`); + // 必须走 httpGet 而不是内置 fetch:内置 fetch 不认 HTTP(S)_PROXY, + // 在只挂本地代理(如 Clash 7897)的环境里会直连超时,只报一句 `fetch failed`。 + const res = await httpGet(url); + if (res.statusCode < 200 || res.statusCode >= 300) { + res.resume(); + throw new Error(`下载失败 ${res.statusCode} ${res.statusMessage ?? ''}:${url}`); } - const contentLength = Number(res.headers.get('content-length')) || 0; + const contentLength = Number(res.headers['content-length']) || 0; const total = size || contentLength; const progress = quiet ? undefined : new Progress(label ?? url); let done = 0; @@ -415,7 +418,7 @@ async function download(url, dest, { label, size } = {}) { }, }); try { - await pipeline(Readable.fromWeb(res.body), counter, createWriteStream(dest)); + await pipeline(res, counter, createWriteStream(dest)); } catch (err) { if (progress && IS_TTY) process.stdout.write('\n'); throw err; diff --git a/scripts/fetch_dress_resources.ts b/scripts/fetch_dress_resources.ts index e54d78a89..c4a447ef2 100644 --- a/scripts/fetch_dress_resources.ts +++ b/scripts/fetch_dress_resources.ts @@ -697,5 +697,5 @@ main().catch((e) => { * pnpm tsx scripts/fetch_dress_resources.ts --bubble --font --widget --extend * 它会从各段真实最大 id 往后扫,连续 200 个空 id 自动收手,并至少越过旧边界 +200。 * - * 2026-10-24 + * 2026-10-27 * */ diff --git a/scripts/lbs_share.ts b/scripts/lbs_share.ts new file mode 100644 index 000000000..c179dedf6 --- /dev/null +++ b/scripts/lbs_share.ts @@ -0,0 +1,203 @@ +/** + * 位置共享联调脚本:借同机在线 QQ 的凭据,发起一次私聊 / 群聊位置共享, + * 按服务端要求的心跳持续上报,并周期性查询房间成员。 + * + * 协议实现本身在 `@weq/protocol` 的 `msg/lbs-share` 与 `@weq/service` 的 + * `LocationShareService` 里 —— 本脚本只负责「拿到凭据 + 驱动心跳」, + * 不再自己拼 protobuf。 + * + * 用法: + * pnpm tsx scripts/lbs_share.ts --peer 1707889225 [--seconds 120] + * pnpm tsx scripts/lbs_share.ts --group 673646675 [--seconds 120] + * pnpm tsx scripts/lbs_share.ts --peer 1707889225 --lng 116.397 --lat 39.908 + * pnpm tsx scripts/lbs_share.ts --peer 1707889225 --no-card # 只建房间不发卡片 + * + * 权限:读别的进程内存 = ptrace attach。Windows 要管理员,Linux 要 root 或 + * `CAP_SYS_PTRACE`,macOS 要 root 且关 SIP。 + */ + +import { loadNative } from '../packages/native/src'; +import type { Platform } from '../packages/platform/src'; +import { + createDarwinPlatform, + createLinuxPlatform, + createWin32Platform, +} from '../packages/platform/src'; +import { + registerSsoSession, + resolveDeviceGuid, + resolveSubAppId, +} from '../packages/service/src/account/sso_session'; +import { readLoginAccounts } from '../packages/service/src/bootstrap/login_db'; +import { ensureAttached } from '../packages/testkit/src/attach'; +import { LocationShareService } from '../packages/service/src/account/lbs_share'; +import { LbsRoomStatus } from '../packages/protocol/src/msg/lbs-share'; +import { sendMessage } from '../packages/protocol/src/msg/send'; + +const LABEL = 'lbs-share'; + +function log(message: string): void { + console.log(`[${LABEL}] ${new Date().toISOString().slice(11, 23)} ${message}`); +} + +function fail(message: string): never { + console.error(`[${LABEL}] 失败: ${message}`); + process.exit(1); +} + +// ─────────────────────────── 命令行 ─────────────────────────── + +const argv = process.argv.slice(2); +const has = (flag: string): boolean => argv.includes(flag); +const opt = (name: string): string | undefined => { + const index = argv.indexOf(name); + return index >= 0 ? argv[index + 1] : undefined; +}; + +const peerText = opt('--peer'); +const groupText = opt('--group'); +if (!peerText && !groupText) fail('缺少 --peer <对端 QQ 号> 或 --group <群号>'); +if (peerText && groupText) fail('--peer 与 --group 只能给一个'); + +const scene = groupText !== undefined ? ('group' as const) : ('c2c' as const); +/** 会话号:私聊是 peerUin,群聊是 groupCode。 */ +const peer = Number(peerText ?? groupText); +if (!Number.isSafeInteger(peer) || peer <= 0) fail(`会话号不合法:${peerText ?? groupText}`); + +const lng = Number(opt('--lng') ?? '103.931543'); +const lat = Number(opt('--lat') ?? '30.75535'); +const totalSeconds = Number(opt('--seconds') ?? '120'); +const heartbeatMs = Number(opt('--interval') ?? '2000'); +const queryEvery = Number(opt('--query-every') ?? '5'); + +// ─────────────────────────── 平台 / 账号 ─────────────────────────── + +function createPlatform( + native: ReturnType, + getUidForUin: (uin: string) => string | null, +): Platform { + if (process.platform === 'linux') return createLinuxPlatform(native, () => null, getUidForUin); + if (process.platform === 'darwin') return createDarwinPlatform(native, () => null, getUidForUin); + return createWin32Platform( + native, + () => null, + () => null, + ); +} + +async function main(): Promise { + const native = loadNative(); + const nt = native.ntHelper; + const uidByUin = new Map(); + const platform = createPlatform(native, (uin) => uidByUin.get(uin) ?? null); + + const accounts = await readLoginAccounts(platform); + for (const account of accounts) uidByUin.set(account.uin, account.uid); + + let target: { uin: string; uid: string; pid: number } | null = null; + for (const account of accounts) { + const pid = platform.resolveQqPid(account.uin); + if (pid === null) continue; + target = { uin: account.uin, uid: account.uid, pid }; + break; + } + if (!target) fail('没有在线账号,先把 QQ 打开并登录'); + if (!target.uid) fail(`账号 ${target.uin} 的 uid 为空`); + + const selfUin = Number(target.uin); + const pid = target.pid; + log(`目标账号 uin=${selfUin} uid=${target.uid} pid=${pid}`); + log( + `${scene === 'group' ? '群聊 groupCode' : '私聊 peerUin'}=${peer} 坐标 lng=${lng} lat=${lat}`, + ); + + // ① 借凭据登记 SSO 会话(只登记,不建连)。 + const material = await ensureAttached(nt, pid, target.uin, { label: LABEL }); + const guid = resolveDeviceGuid(nt, platform); + if (!guid) fail('读不到设备 guid'); + const subAppId = resolveSubAppId(nt, platform, pid); + const registered = await registerSsoSession( + nt, + platform, + pid, + { uin: target.uin, uid: target.uid, guid, subAppId }, + material, + ); + if (!registered) fail('会话物料不齐,没登记上 SSO 会话'); + log('SSO 会话已登记'); + + const service = new LocationShareService(nt, () => pid); + + // ② 发起。 + const started = await service.start({ scene, peer, selfUin, longitude: lng, latitude: lat }); + log(`room_operation ok=${started.ok} code=${started.code} ${started.message}`); + if (!started.ok) fail('服务端拒绝发起位置共享'); + + // ③ 自己发「发起了位置共享」卡片。 + // + // 真实客户端这一步由服务端 MsgPush 完成,但 WeQ 的 SSO 是「发一包就断」, + // 服务端判定发起方不在线就不投递 —— 所以自己 SendMsg。 + if (!has('--no-card')) { + const elements = service.buildShareCardElements(scene); + const peerUid = uidByUin.get(String(peer)); + try { + const receipt = await sendMessage(nt, pid, { + ...(scene === 'group' ? { groupId: peer } : { userUin: peer }), + ...(scene === 'c2c' && peerUid ? { userUid: peerUid } : {}), + elements, + ...(scene === 'c2c' ? { msgFlag: Math.floor(Date.now() / 1000) } : {}), + }); + log(`卡片消息 ok=${receipt.ok} result=${receipt.result} ${receipt.errMsg || ''}`); + } catch (error) { + log(`卡片消息失败: ${error instanceof Error ? error.message : error}`); + } + } + + // ④ 心跳:上报自己 + 定期查询成员。停上报约 60 秒后服务端会把房间置为已结束, + // 所以「退出共享」不需要额外命令。 + const deadline = Date.now() + totalSeconds * 1000; + let round = 0; + let lastQuery = 0; + while (Date.now() < deadline) { + round++; + // extra 语义未确认(抓包里每次不同、服务端原样回显),给个稳定值即可。 + const extra = 208 + (round % 7) * 0.1; + try { + const reply = await service.report({ + scene, + peer, + selfUin, + longitude: lng, + latitude: lat, + extra, + }); + log(`#${round} report ok=${reply.ok} 下次间隔=${reply.interval}s`); + } catch (error) { + log(`#${round} report 失败: ${error instanceof Error ? error.message : error}`); + } + + if (Date.now() - lastQuery >= queryEvery * heartbeatMs) { + lastQuery = Date.now(); + try { + const room = await service.query({ scene, peer, selfUin, mode: 1 }); + const members = room.members + .map((m) => `${m.uin}@${m.longitude.toFixed(6)},${m.latitude.toFixed(6)}`) + .join(' '); + log( + `#${round} query status=${room.status === LbsRoomStatus.ACTIVE ? '进行中' : room.status} ` + + `成员(${room.members.length}): ${members || '<空>'}`, + ); + } catch (error) { + log(`#${round} query 失败: ${error instanceof Error ? error.message : error}`); + } + } + + await new Promise((resolve) => setTimeout(resolve, heartbeatMs)); + } + log(`跑满 ${totalSeconds} 秒,结束(停止上报,房间会自行超时关闭)`); +} + +main().catch((error: unknown) => { + console.error(`[${LABEL}] 失败:`, error instanceof Error ? error.message : error); + process.exit(1); +});