virlen-remote 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,22 @@
3
3
  本文件记录对外可见的变更(协议 / 导出面 / 行为)。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
4
4
  版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
5
5
 
6
+ ## [0.1.1] - 2026-09-28
7
+
8
+ ### Added
9
+
10
+ - **链路类型判定**(`classifyLinkKind` / `probeLinkKind` / `LinkKindWatcher` / `LINK_KIND_POLL_MS`):
11
+ 从 `RTCPeerConnection.getStats()` 的**所选候选对**判定本次连接是 P2P 直连还是 TURN 中继
12
+ (`host` / `srflx` / `prflx` → 直连;任一端 `relay` → 中继;拿不到结论 → `unknown`,**不猜**)。
13
+ 这是**两端共用的唯一一份口径**:此前电脑端(virlen-app)与手机端(virlen-mobile)各持一份副本,
14
+ 存在「手机说直连、电脑说中继」的漂移风险,本版收敛到本包。附带导出 `pickCandidatePair` /
15
+ `findCandidate`,供消费方按**同一次挑选**提取其它 stats 字段(rtt / 协议等)。
16
+
17
+ ### Notes
18
+
19
+ - 无破坏性变更;0.1.0 的导出面保持不变。
20
+ - 判定逻辑为**纯函数**(喂 `Record<string, unknown>[]` 即可),不依赖 DOM 类型。
21
+
6
22
  ## [0.1.0] - 2026-09-30
7
23
 
8
24
  首次发布。内容为 Virlen「手机控制」M0–M6 期间沉淀的协议栈,两端(virlen-app / virlen-mobile)此前以源码方式共用,
@@ -38,3 +54,4 @@
38
54
  真值判断不收敛联合类型。
39
55
 
40
56
  [0.1.0]: https://github.com/WeiChens/virlen-remote/releases/tag/v0.1.0
57
+ [0.1.1]: https://github.com/WeiChens/virlen-remote/releases/tag/v0.1.1
package/dist/index.d.ts CHANGED
@@ -39,6 +39,8 @@ export { SseSignalingClient, fetchRoomStatus, fetchHostOnlineMap } from './trans
39
39
  export type { SignalingChannel, SignalingRole, SseSignalingOptions, EventSourceLike, KickedInfo, FetchRoomStatusOptions, } from './transport/signaling.js';
40
40
  export { RtcTransport } from './transport/rtc.js';
41
41
  export type { RtcTransportOptions } from './transport/rtc.js';
42
+ export { LINK_KIND_POLL_MS, classifyLinkKind, probeLinkKind, pickCandidatePair, findCandidate, LinkKindWatcher, } from './transport/link-kind.js';
43
+ export type { LinkKind, LinkStatsEntry, StatsReportLike, StatsProvider, } from './transport/link-kind.js';
42
44
  export { ICE_CONFIG_VERSION, ICE_API_PATH, ICE_CUSTOM_STORAGE_KEY, ICE_REMOTE_STORAGE_KEY, ICE_CACHE_TTL_MS, ICE_FETCH_TIMEOUT_MS, sanitizeIceServers, parseIceText, formatIceServers, describeIceSource, fetchIceServers, resolveIceServers, readCustomIceText, writeCustomIceText, } from './transport/ice.js';
43
45
  export type { IceServerInit, IceSource, IceStoragePort, ResolvedIceServers, ParseIceResult, FetchIceOptions, ResolveIceOptions, } from './transport/ice.js';
44
46
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -29,6 +29,8 @@ export { MemoryTransport, createMemoryPair } from './transport/memory.js';
29
29
  export { BroadcastTransport, createBroadcastPair } from './transport/broadcast.js';
30
30
  export { SseSignalingClient, fetchRoomStatus, fetchHostOnlineMap } from './transport/signaling.js';
31
31
  export { RtcTransport } from './transport/rtc.js';
32
+ // ── RTC 链路类型判定(直连 / 中继,两端同一份口径)──
33
+ export { LINK_KIND_POLL_MS, classifyLinkKind, probeLinkKind, pickCandidatePair, findCandidate, LinkKindWatcher, } from './transport/link-kind.js';
32
34
  // ── ICE 配置(M7,§31):客户端不再内置任何 TURN 凭证 ──
33
35
  export { ICE_CONFIG_VERSION, ICE_API_PATH, ICE_CUSTOM_STORAGE_KEY, ICE_REMOTE_STORAGE_KEY, ICE_CACHE_TTL_MS, ICE_FETCH_TIMEOUT_MS, sanitizeIceServers, parseIceText, formatIceServers, describeIceSource, fetchIceServers, resolveIceServers, readCustomIceText, writeCustomIceText, } from './transport/ice.js';
34
36
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,iBAAiB;AACjB,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,yBAAyB,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AACtG,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,SAAS,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAA;AAGnG,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AAG9D,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAA;AAE7C,yBAAyB;AACzB,OAAO,EACL,eAAe,EACf,iBAAiB,EACjB,YAAY,EACZ,WAAW,EACX,YAAY,EACZ,qBAAqB,EACrB,SAAS,EACT,YAAY,EACZ,aAAa,EACb,WAAW,EACX,OAAO,EACP,eAAe,EACf,UAAU,EACV,cAAc,EACd,UAAU,EACV,UAAU,EACV,sBAAsB,GACvB,MAAM,qBAAqB,CAAA;AAG5B,yBAAyB;AACzB,OAAO,EACL,uBAAuB,EACvB,qBAAqB,EACrB,mBAAmB,EACnB,oBAAoB,EACpB,mBAAmB,EACnB,aAAa,GACd,MAAM,oBAAoB,CAAA;AAG3B,OAAO,EAAE,qBAAqB,EAAE,iBAAiB,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AAG3F,OAAO,EAAE,SAAS,EAAE,qBAAqB,EAAE,MAAM,kBAAkB,CAAA;AAEnE,OAAO,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;AAU9C,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AACjF,OAAO,EAAE,6BAA6B,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAA;AAsClF,OAAO,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAA;AAItD,OAAO,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AAEtE,OAAO,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAA;AAC/E,OAAO,EAAE,kBAAkB,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAA;AAS/F,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA;AAG9C,yCAAyC;AACzC,OAAO,EACL,kBAAkB,EAClB,YAAY,EACZ,sBAAsB,EACtB,sBAAsB,EACtB,gBAAgB,EAChB,oBAAoB,EACpB,kBAAkB,EAClB,YAAY,EACZ,gBAAgB,EAChB,iBAAiB,EACjB,eAAe,EACf,iBAAiB,EACjB,iBAAiB,EACjB,kBAAkB,GACnB,MAAM,iBAAiB,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,iBAAiB;AACjB,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,yBAAyB,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AACtG,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,SAAS,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAA;AAGnG,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AAG9D,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAA;AAE7C,yBAAyB;AACzB,OAAO,EACL,eAAe,EACf,iBAAiB,EACjB,YAAY,EACZ,WAAW,EACX,YAAY,EACZ,qBAAqB,EACrB,SAAS,EACT,YAAY,EACZ,aAAa,EACb,WAAW,EACX,OAAO,EACP,eAAe,EACf,UAAU,EACV,cAAc,EACd,UAAU,EACV,UAAU,EACV,sBAAsB,GACvB,MAAM,qBAAqB,CAAA;AAG5B,yBAAyB;AACzB,OAAO,EACL,uBAAuB,EACvB,qBAAqB,EACrB,mBAAmB,EACnB,oBAAoB,EACpB,mBAAmB,EACnB,aAAa,GACd,MAAM,oBAAoB,CAAA;AAG3B,OAAO,EAAE,qBAAqB,EAAE,iBAAiB,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AAG3F,OAAO,EAAE,SAAS,EAAE,qBAAqB,EAAE,MAAM,kBAAkB,CAAA;AAEnE,OAAO,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;AAU9C,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AACjF,OAAO,EAAE,6BAA6B,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAA;AAsClF,OAAO,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAA;AAItD,OAAO,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AAEtE,OAAO,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAA;AAC/E,OAAO,EAAE,kBAAkB,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAA;AAS/F,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA;AAG9C,mCAAmC;AACnC,OAAO,EACL,iBAAiB,EACjB,gBAAgB,EAChB,aAAa,EACb,iBAAiB,EACjB,aAAa,EACb,eAAe,GAChB,MAAM,uBAAuB,CAAA;AAQ9B,yCAAyC;AACzC,OAAO,EACL,kBAAkB,EAClB,YAAY,EACZ,sBAAsB,EACtB,sBAAsB,EACtB,gBAAgB,EAChB,oBAAoB,EACpB,kBAAkB,EAClB,YAAY,EACZ,gBAAgB,EAChB,iBAAiB,EACjB,eAAe,EACf,iBAAiB,EACjB,iBAAiB,EACjB,kBAAkB,GACnB,MAAM,iBAAiB,CAAA"}
@@ -0,0 +1,121 @@
1
+ /**
2
+ * 链路通讯类型 —— 「这次连接到底是**直连**还是**走了中继**」。
3
+ *
4
+ * ## 为什么放进共享包
5
+ *
6
+ * 这个判定**只存在于本机 PC 的 ICE 候选对里**,不信令、不落盘、重连就变 —— 所以两端各自读、
7
+ * 各自算。但「算」的口径必须**只有一份**:电脑端(`virlen-app/src/bridge/link-kind.ts`)
8
+ * 曾经与手机端(`virlen-mobile/src/lib/rtc-stats.ts`)各持一份副本,一旦漂移就会出现
9
+ * 「手机说直连、电脑说中继」——两台设备互相打脸,而用户无从判断该信谁。
10
+ * 本文件即那份唯一实现:两端都从这里引入,改一处两端同时生效(编译期就能发现不兼容)。
11
+ *
12
+ * 桌面设置页「已连接」旁边那枚胶囊、手机「通讯状态面板」、以及协议事件
13
+ * `host.event.connection.changed`(电脑视角下发),都应读同一个结论。
14
+ *
15
+ * ## 判定口径(取 `RTCPeerConnection.getStats()` 的**所选候选对**)
16
+ *
17
+ * - 候选对任一端 `candidateType === 'relay'` → **中继**(字节确实过了 TURN 服务器);
18
+ * - `host`(同网段)/ `srflx`(STUN 打洞后的公网映射)/ `prflx`(对端反射)→ **直连**;
19
+ * - 拿不到结论(stats 还没填好、环境不支持)→ `unknown` —— **不猜**,宁可不显示。
20
+ *
21
+ * ⚠️ `classifyLinkKind` 是**纯函数**(喂普通对象数组即可),因为真实 WebRTC 跑不进 CI;
22
+ * 真机上的正确性靠「口径钉在单测里」兜住。
23
+ *
24
+ * ## 为什么还要 `LinkKindWatcher`(巡检)
25
+ *
26
+ * 「连上了」不是一次就问完的事:**所选候选对随时会换** ——
27
+ * - 刚打通那几秒:先成的是 relay 那条(打洞还没探完)→ 随后直连候选对胜出**换成 P2P**;
28
+ * - 之后任何一环变网(手机 WiFi → 蜂窝):ICE 重新协商,直连可能又退回中继。
29
+ *
30
+ * 而 `iceconnectionstatechange` **只在状态变时触发**:重新提名 / 重协商后换候选对,状态可以一直是
31
+ * `completed` —— 那一刻一个事件都不会来,胶囊就会一直停在「TURN 中继」上骗人。
32
+ *
33
+ * `RTCIceTransport.onselectedcandidatepairchange` 是规范里专为此存在的精确信号,但它挂在
34
+ * `pc.sctp.transport.iceTransport` 上、还得等 SCTP 建好(数据通道这条路上不一定拿得到),
35
+ * 所以采用「**定时问一次**」兜底:一次 `getStats()` 只有几毫秒,换来的是**类型永远跟得上现实**。
36
+ */
37
+ /** 通讯类型:`direct` = P2P 直连;`relay` = TURN 中继;`unknown` = 没拿到结论。 */
38
+ export type LinkKind = 'direct' | 'relay' | 'unknown';
39
+ /** 一条 stats 记录(`RTCStatsReport` 的 value;只用到少数几个字段)。 */
40
+ export type LinkStatsEntry = Record<string, unknown>;
41
+ /** `RTCStatsReport` 的最小形状(测试可喂 Map / 假对象)。 */
42
+ export interface StatsReportLike {
43
+ forEach(callback: (entry: unknown) => void): void;
44
+ }
45
+ /** `RTCPeerConnection.getStats()` 的最小形状。 */
46
+ export interface StatsProvider {
47
+ getStats(): Promise<StatsReportLike>;
48
+ }
49
+ /**
50
+ * 巡检间隔(毫秒)。
51
+ *
52
+ * 取 3s 而不是更短:变化只影响一枚胶囊,用户感知不到亚秒差异;而 3s 一次 `getStats()` 对一条
53
+ * 数据通道而言开销可忽略(比固定更频繁地问更划算的是「只在链路开着时问」)。
54
+ */
55
+ export declare const LINK_KIND_POLL_MS = 3000;
56
+ /**
57
+ * 从 stats 全集里判定通讯类型。
58
+ *
59
+ * 顺序刻意如此:
60
+ * 1. `transport.selectedCandidatePairId` —— 标准里**明确**指出「正在用哪条候选对」,最可信;
61
+ * 2. 退而求其次:`nominated` 且 `succeeded` 的候选对(老实现不一定给 `transport` 记录);
62
+ * 3. 再退:任意 `succeeded` 的候选对;
63
+ * 4. 都没有 → `unknown`。
64
+ */
65
+ export declare function classifyLinkKind(stats: Iterable<LinkStatsEntry>): LinkKind;
66
+ /**
67
+ * 挑出「正在用哪条候选对」:标准路径(`transport.selectedCandidatePairId`)优先,退而求其次取
68
+ * `nominated` + `succeeded` 的那条,再退取任意 `succeeded` 的候选对。
69
+ *
70
+ * 单独导出是因为消费方(手机端样本提取)也要拿它读 rtt / 协议等字段 —— 与判定走**同一次挑选**,
71
+ * 否则判定与显示可能取自不同的候选对。
72
+ */
73
+ export declare function pickCandidatePair(list: LinkStatsEntry[]): LinkStatsEntry | undefined;
74
+ /**
75
+ * 按 id 找候选记录。
76
+ *
77
+ * `localcandidate` / `remotecandidate` 是 2014 版规范里的类型名(部分老实现仍在用),
78
+ * 一起认下来 —— 认不出的代价是「明明是直连却显示未知」。
79
+ */
80
+ export declare function findCandidate(list: LinkStatsEntry[], id: unknown, side: 'local' | 'remote'): LinkStatsEntry | undefined;
81
+ /**
82
+ * 采一次样(`getStats()` 是异步的)。
83
+ *
84
+ * ⚠️ **不吞异常**:调用方需要知道「问不到」,那是 `unknown` 而不是直连。
85
+ */
86
+ export declare function probeLinkKind(pc: StatsProvider): Promise<LinkKind>;
87
+ /**
88
+ * 通讯类型巡检器 —— 链路开着期间定时问一次「现在是直连还是中继」,变了就上报。
89
+ *
90
+ * 生命周期与链路绑定:`watch(pc)` 开始、`stop()` 结束(换链路 / 断链 / 停用都要 stop)。
91
+ * 不碰 UI、不碰 store,只负责「问」与「去重」,因此能用假 PC + 假时钟完整覆盖。
92
+ */
93
+ export declare class LinkKindWatcher {
94
+ private readonly onChange;
95
+ private readonly intervalMs;
96
+ private timer;
97
+ private pc;
98
+ private _kind;
99
+ constructor(onChange: (kind: LinkKind) => void, intervalMs?: number);
100
+ /** 当前结论(`unknown` = 没结论,或巡检已停)。 */
101
+ get kind(): LinkKind;
102
+ /** 开始巡检(换链路时直接再调一次,旧的会先停)。不会等一个间隔:立刻先问一次。 */
103
+ watch(pc: StatsProvider): void;
104
+ /**
105
+ * 停:链路没了 → 结论跟着作废(会广播 `unknown`,UI 据此收起胶囊)。
106
+ *
107
+ * 「换个链路」也是这个动作(`watch` 内部先调它),所以不会残留上一条链路的结论。
108
+ */
109
+ stop(): void;
110
+ /**
111
+ * 探一次并上报。
112
+ *
113
+ * ⚠️ 两个刻意行为:
114
+ * - 链路**还开着**时读到 `unknown`(stats 还没填好 / 正在重协商)→ **保留上次结论**:
115
+ * 一次读不到不等于「不知道怎么连的」,而在直连/中继之间闪一下比稍旧更难受;
116
+ * - `await` 期间链路可能已换/已停 → 认准「还是这个 PC」再写回。
117
+ */
118
+ poll(): Promise<void>;
119
+ private publish;
120
+ }
121
+ //# sourceMappingURL=link-kind.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"link-kind.d.ts","sourceRoot":"","sources":["../../src/transport/link-kind.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAEH,kEAAkE;AAClE,MAAM,MAAM,QAAQ,GAAG,QAAQ,GAAG,OAAO,GAAG,SAAS,CAAA;AAErD,uDAAuD;AACvD,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;AAEpD,8CAA8C;AAC9C,MAAM,WAAW,eAAe;IAC9B,OAAO,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,GAAG,IAAI,CAAA;CAClD;AAED,4CAA4C;AAC5C,MAAM,WAAW,aAAa;IAC5B,QAAQ,IAAI,OAAO,CAAC,eAAe,CAAC,CAAA;CACrC;AAKD;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,OAAO,CAAA;AAErC;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,QAAQ,CAAC,cAAc,CAAC,GAAG,QAAQ,CAiB1E;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,cAAc,EAAE,GAAG,cAAc,GAAG,SAAS,CAEpF;AAkBD;;;;;GAKG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,cAAc,EAAE,EACtB,EAAE,EAAE,OAAO,EACX,IAAI,EAAE,OAAO,GAAG,QAAQ,GACvB,cAAc,GAAG,SAAS,CAK5B;AAED;;;;GAIG;AACH,wBAAsB,aAAa,CAAC,EAAE,EAAE,aAAa,GAAG,OAAO,CAAC,QAAQ,CAAC,CAOxE;AAED;;;;;GAKG;AACH,qBAAa,eAAe;IAMxB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAN7B,OAAO,CAAC,KAAK,CAA8C;IAC3D,OAAO,CAAC,EAAE,CAA6B;IACvC,OAAO,CAAC,KAAK,CAAsB;IAEnC,YACmB,QAAQ,EAAE,CAAC,IAAI,EAAE,QAAQ,KAAK,IAAI,EAClC,UAAU,GAAE,MAA0B,EACrD;IAEJ,mCAAmC;IACnC,IAAI,IAAI,IAAI,QAAQ,CAEnB;IAED,6CAA6C;IAC7C,KAAK,CAAC,EAAE,EAAE,aAAa,GAAG,IAAI,CAK7B;IAED;;;;OAIG;IACH,IAAI,IAAI,IAAI,CAOX;IAED;;;;;;;OAOG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAW1B;IAED,OAAO,CAAC,OAAO;CAOhB"}
@@ -0,0 +1,195 @@
1
+ /**
2
+ * 链路通讯类型 —— 「这次连接到底是**直连**还是**走了中继**」。
3
+ *
4
+ * ## 为什么放进共享包
5
+ *
6
+ * 这个判定**只存在于本机 PC 的 ICE 候选对里**,不信令、不落盘、重连就变 —— 所以两端各自读、
7
+ * 各自算。但「算」的口径必须**只有一份**:电脑端(`virlen-app/src/bridge/link-kind.ts`)
8
+ * 曾经与手机端(`virlen-mobile/src/lib/rtc-stats.ts`)各持一份副本,一旦漂移就会出现
9
+ * 「手机说直连、电脑说中继」——两台设备互相打脸,而用户无从判断该信谁。
10
+ * 本文件即那份唯一实现:两端都从这里引入,改一处两端同时生效(编译期就能发现不兼容)。
11
+ *
12
+ * 桌面设置页「已连接」旁边那枚胶囊、手机「通讯状态面板」、以及协议事件
13
+ * `host.event.connection.changed`(电脑视角下发),都应读同一个结论。
14
+ *
15
+ * ## 判定口径(取 `RTCPeerConnection.getStats()` 的**所选候选对**)
16
+ *
17
+ * - 候选对任一端 `candidateType === 'relay'` → **中继**(字节确实过了 TURN 服务器);
18
+ * - `host`(同网段)/ `srflx`(STUN 打洞后的公网映射)/ `prflx`(对端反射)→ **直连**;
19
+ * - 拿不到结论(stats 还没填好、环境不支持)→ `unknown` —— **不猜**,宁可不显示。
20
+ *
21
+ * ⚠️ `classifyLinkKind` 是**纯函数**(喂普通对象数组即可),因为真实 WebRTC 跑不进 CI;
22
+ * 真机上的正确性靠「口径钉在单测里」兜住。
23
+ *
24
+ * ## 为什么还要 `LinkKindWatcher`(巡检)
25
+ *
26
+ * 「连上了」不是一次就问完的事:**所选候选对随时会换** ——
27
+ * - 刚打通那几秒:先成的是 relay 那条(打洞还没探完)→ 随后直连候选对胜出**换成 P2P**;
28
+ * - 之后任何一环变网(手机 WiFi → 蜂窝):ICE 重新协商,直连可能又退回中继。
29
+ *
30
+ * 而 `iceconnectionstatechange` **只在状态变时触发**:重新提名 / 重协商后换候选对,状态可以一直是
31
+ * `completed` —— 那一刻一个事件都不会来,胶囊就会一直停在「TURN 中继」上骗人。
32
+ *
33
+ * `RTCIceTransport.onselectedcandidatepairchange` 是规范里专为此存在的精确信号,但它挂在
34
+ * `pc.sctp.transport.iceTransport` 上、还得等 SCTP 建好(数据通道这条路上不一定拿得到),
35
+ * 所以采用「**定时问一次**」兜底:一次 `getStats()` 只有几毫秒,换来的是**类型永远跟得上现实**。
36
+ */
37
+ /** 直连的候选类型:本机候选(同网段)/ 服务器反射(打洞成功)/ 对端反射。 */
38
+ const DIRECT_TYPES = ['host', 'srflx', 'prflx'];
39
+ /**
40
+ * 巡检间隔(毫秒)。
41
+ *
42
+ * 取 3s 而不是更短:变化只影响一枚胶囊,用户感知不到亚秒差异;而 3s 一次 `getStats()` 对一条
43
+ * 数据通道而言开销可忽略(比固定更频繁地问更划算的是「只在链路开着时问」)。
44
+ */
45
+ export const LINK_KIND_POLL_MS = 3000;
46
+ /**
47
+ * 从 stats 全集里判定通讯类型。
48
+ *
49
+ * 顺序刻意如此:
50
+ * 1. `transport.selectedCandidatePairId` —— 标准里**明确**指出「正在用哪条候选对」,最可信;
51
+ * 2. 退而求其次:`nominated` 且 `succeeded` 的候选对(老实现不一定给 `transport` 记录);
52
+ * 3. 再退:任意 `succeeded` 的候选对;
53
+ * 4. 都没有 → `unknown`。
54
+ */
55
+ export function classifyLinkKind(stats) {
56
+ const list = [...stats];
57
+ const pair = pickCandidatePair(list);
58
+ if (!pair)
59
+ return 'unknown';
60
+ // 还没定型的候选对(in-progress / failed)不算结论:此刻「怎么连的」还没确定
61
+ const state = pair['state'];
62
+ if (typeof state === 'string' && state !== 'succeeded')
63
+ return 'unknown';
64
+ const local = findCandidate(list, pair['localCandidateId'], 'local');
65
+ const remote = findCandidate(list, pair['remoteCandidateId'], 'remote');
66
+ const types = [local, remote]
67
+ .map((c) => c?.['candidateType'])
68
+ .filter((t) => typeof t === 'string');
69
+ if (types.includes('relay'))
70
+ return 'relay';
71
+ if (types.some((t) => DIRECT_TYPES.includes(t)))
72
+ return 'direct';
73
+ return 'unknown';
74
+ }
75
+ /**
76
+ * 挑出「正在用哪条候选对」:标准路径(`transport.selectedCandidatePairId`)优先,退而求其次取
77
+ * `nominated` + `succeeded` 的那条,再退取任意 `succeeded` 的候选对。
78
+ *
79
+ * 单独导出是因为消费方(手机端样本提取)也要拿它读 rtt / 协议等字段 —— 与判定走**同一次挑选**,
80
+ * 否则判定与显示可能取自不同的候选对。
81
+ */
82
+ export function pickCandidatePair(list) {
83
+ return selectedPair(list) ?? succeededPair(list);
84
+ }
85
+ /** 标准路径:`transport` 记录里明确指出的那条候选对。 */
86
+ function selectedPair(list) {
87
+ const transport = list.find((s) => s['type'] === 'transport' && typeof s['selectedCandidatePairId'] === 'string');
88
+ const id = transport?.['selectedCandidatePairId'];
89
+ if (typeof id !== 'string')
90
+ return undefined;
91
+ return list.find((s) => s['type'] === 'candidate-pair' && s['id'] === id);
92
+ }
93
+ /** 兜底路径:`nominated`(已被选中的那条)+ `succeeded`;没有 nominated 就取第一条 succeeded。 */
94
+ function succeededPair(list) {
95
+ const pairs = list.filter((s) => s['type'] === 'candidate-pair' && s['state'] === 'succeeded');
96
+ return pairs.find((p) => p['nominated'] === true) ?? pairs[0];
97
+ }
98
+ /**
99
+ * 按 id 找候选记录。
100
+ *
101
+ * `localcandidate` / `remotecandidate` 是 2014 版规范里的类型名(部分老实现仍在用),
102
+ * 一起认下来 —— 认不出的代价是「明明是直连却显示未知」。
103
+ */
104
+ export function findCandidate(list, id, side) {
105
+ if (typeof id !== 'string')
106
+ return undefined;
107
+ const kinds = side === 'local' ? ['local-candidate', 'localcandidate'] : ['remote-candidate', 'remotecandidate'];
108
+ return list.find((s) => s['id'] === id && kinds.includes(s['type']));
109
+ }
110
+ /**
111
+ * 采一次样(`getStats()` 是异步的)。
112
+ *
113
+ * ⚠️ **不吞异常**:调用方需要知道「问不到」,那是 `unknown` 而不是直连。
114
+ */
115
+ export async function probeLinkKind(pc) {
116
+ const report = await pc.getStats();
117
+ const list = [];
118
+ report.forEach((entry) => {
119
+ if (entry && typeof entry === 'object')
120
+ list.push(entry);
121
+ });
122
+ return classifyLinkKind(list);
123
+ }
124
+ /**
125
+ * 通讯类型巡检器 —— 链路开着期间定时问一次「现在是直连还是中继」,变了就上报。
126
+ *
127
+ * 生命周期与链路绑定:`watch(pc)` 开始、`stop()` 结束(换链路 / 断链 / 停用都要 stop)。
128
+ * 不碰 UI、不碰 store,只负责「问」与「去重」,因此能用假 PC + 假时钟完整覆盖。
129
+ */
130
+ export class LinkKindWatcher {
131
+ constructor(onChange, intervalMs = LINK_KIND_POLL_MS) {
132
+ this.onChange = onChange;
133
+ this.intervalMs = intervalMs;
134
+ this.timer = null;
135
+ this.pc = null;
136
+ this._kind = 'unknown';
137
+ }
138
+ /** 当前结论(`unknown` = 没结论,或巡检已停)。 */
139
+ get kind() {
140
+ return this._kind;
141
+ }
142
+ /** 开始巡检(换链路时直接再调一次,旧的会先停)。不会等一个间隔:立刻先问一次。 */
143
+ watch(pc) {
144
+ this.stop();
145
+ this.pc = pc;
146
+ void this.poll();
147
+ this.timer = setInterval(() => void this.poll(), this.intervalMs);
148
+ }
149
+ /**
150
+ * 停:链路没了 → 结论跟着作废(会广播 `unknown`,UI 据此收起胶囊)。
151
+ *
152
+ * 「换个链路」也是这个动作(`watch` 内部先调它),所以不会残留上一条链路的结论。
153
+ */
154
+ stop() {
155
+ if (this.timer) {
156
+ clearInterval(this.timer);
157
+ this.timer = null;
158
+ }
159
+ this.pc = null;
160
+ this.publish('unknown');
161
+ }
162
+ /**
163
+ * 探一次并上报。
164
+ *
165
+ * ⚠️ 两个刻意行为:
166
+ * - 链路**还开着**时读到 `unknown`(stats 还没填好 / 正在重协商)→ **保留上次结论**:
167
+ * 一次读不到不等于「不知道怎么连的」,而在直连/中继之间闪一下比稍旧更难受;
168
+ * - `await` 期间链路可能已换/已停 → 认准「还是这个 PC」再写回。
169
+ */
170
+ async poll() {
171
+ const pc = this.pc;
172
+ if (!pc)
173
+ return;
174
+ let kind = 'unknown';
175
+ try {
176
+ kind = await probeLinkKind(pc);
177
+ }
178
+ catch {
179
+ /* `getStats()` 失败 = 拿不到结论,不等于「直连」 */
180
+ }
181
+ if (this.pc !== pc)
182
+ return;
183
+ this.publish(kind);
184
+ }
185
+ publish(kind) {
186
+ // 开着链路时的 `unknown` = 这一次没读到(见 `poll` 的说明)→ 保留上次结论
187
+ if (kind === 'unknown' && this.pc)
188
+ return;
189
+ if (kind === this._kind)
190
+ return;
191
+ this._kind = kind;
192
+ this.onChange(kind);
193
+ }
194
+ }
195
+ //# sourceMappingURL=link-kind.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"link-kind.js","sourceRoot":"","sources":["../../src/transport/link-kind.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "virlen-remote",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Virlen 手机控制 · 协议栈共享包 —— 帧 / RPC / 错误 / 能力协商 / 设备身份与授权凭证 / 配对载荷 / ICE 解析 / Transport(WebRTC / 内存 / Broadcast)",
5
5
  "license": "MIT",
6
6
  "author": "WeiChens",
package/src/index.ts CHANGED
@@ -127,6 +127,22 @@ export type {
127
127
  export { RtcTransport } from './transport/rtc'
128
128
  export type { RtcTransportOptions } from './transport/rtc'
129
129
 
130
+ // ── RTC 链路类型判定(直连 / 中继,两端同一份口径)──
131
+ export {
132
+ LINK_KIND_POLL_MS,
133
+ classifyLinkKind,
134
+ probeLinkKind,
135
+ pickCandidatePair,
136
+ findCandidate,
137
+ LinkKindWatcher,
138
+ } from './transport/link-kind'
139
+ export type {
140
+ LinkKind,
141
+ LinkStatsEntry,
142
+ StatsReportLike,
143
+ StatsProvider,
144
+ } from './transport/link-kind'
145
+
130
146
  // ── ICE 配置(M7,§31):客户端不再内置任何 TURN 凭证 ──
131
147
  export {
132
148
  ICE_CONFIG_VERSION,
@@ -0,0 +1,222 @@
1
+ /**
2
+ * 链路通讯类型 —— 「这次连接到底是**直连**还是**走了中继**」。
3
+ *
4
+ * ## 为什么放进共享包
5
+ *
6
+ * 这个判定**只存在于本机 PC 的 ICE 候选对里**,不信令、不落盘、重连就变 —— 所以两端各自读、
7
+ * 各自算。但「算」的口径必须**只有一份**:电脑端(`virlen-app/src/bridge/link-kind.ts`)
8
+ * 曾经与手机端(`virlen-mobile/src/lib/rtc-stats.ts`)各持一份副本,一旦漂移就会出现
9
+ * 「手机说直连、电脑说中继」——两台设备互相打脸,而用户无从判断该信谁。
10
+ * 本文件即那份唯一实现:两端都从这里引入,改一处两端同时生效(编译期就能发现不兼容)。
11
+ *
12
+ * 桌面设置页「已连接」旁边那枚胶囊、手机「通讯状态面板」、以及协议事件
13
+ * `host.event.connection.changed`(电脑视角下发),都应读同一个结论。
14
+ *
15
+ * ## 判定口径(取 `RTCPeerConnection.getStats()` 的**所选候选对**)
16
+ *
17
+ * - 候选对任一端 `candidateType === 'relay'` → **中继**(字节确实过了 TURN 服务器);
18
+ * - `host`(同网段)/ `srflx`(STUN 打洞后的公网映射)/ `prflx`(对端反射)→ **直连**;
19
+ * - 拿不到结论(stats 还没填好、环境不支持)→ `unknown` —— **不猜**,宁可不显示。
20
+ *
21
+ * ⚠️ `classifyLinkKind` 是**纯函数**(喂普通对象数组即可),因为真实 WebRTC 跑不进 CI;
22
+ * 真机上的正确性靠「口径钉在单测里」兜住。
23
+ *
24
+ * ## 为什么还要 `LinkKindWatcher`(巡检)
25
+ *
26
+ * 「连上了」不是一次就问完的事:**所选候选对随时会换** ——
27
+ * - 刚打通那几秒:先成的是 relay 那条(打洞还没探完)→ 随后直连候选对胜出**换成 P2P**;
28
+ * - 之后任何一环变网(手机 WiFi → 蜂窝):ICE 重新协商,直连可能又退回中继。
29
+ *
30
+ * 而 `iceconnectionstatechange` **只在状态变时触发**:重新提名 / 重协商后换候选对,状态可以一直是
31
+ * `completed` —— 那一刻一个事件都不会来,胶囊就会一直停在「TURN 中继」上骗人。
32
+ *
33
+ * `RTCIceTransport.onselectedcandidatepairchange` 是规范里专为此存在的精确信号,但它挂在
34
+ * `pc.sctp.transport.iceTransport` 上、还得等 SCTP 建好(数据通道这条路上不一定拿得到),
35
+ * 所以采用「**定时问一次**」兜底:一次 `getStats()` 只有几毫秒,换来的是**类型永远跟得上现实**。
36
+ */
37
+
38
+ /** 通讯类型:`direct` = P2P 直连;`relay` = TURN 中继;`unknown` = 没拿到结论。 */
39
+ export type LinkKind = 'direct' | 'relay' | 'unknown'
40
+
41
+ /** 一条 stats 记录(`RTCStatsReport` 的 value;只用到少数几个字段)。 */
42
+ export type LinkStatsEntry = Record<string, unknown>
43
+
44
+ /** `RTCStatsReport` 的最小形状(测试可喂 Map / 假对象)。 */
45
+ export interface StatsReportLike {
46
+ forEach(callback: (entry: unknown) => void): void
47
+ }
48
+
49
+ /** `RTCPeerConnection.getStats()` 的最小形状。 */
50
+ export interface StatsProvider {
51
+ getStats(): Promise<StatsReportLike>
52
+ }
53
+
54
+ /** 直连的候选类型:本机候选(同网段)/ 服务器反射(打洞成功)/ 对端反射。 */
55
+ const DIRECT_TYPES = ['host', 'srflx', 'prflx']
56
+
57
+ /**
58
+ * 巡检间隔(毫秒)。
59
+ *
60
+ * 取 3s 而不是更短:变化只影响一枚胶囊,用户感知不到亚秒差异;而 3s 一次 `getStats()` 对一条
61
+ * 数据通道而言开销可忽略(比固定更频繁地问更划算的是「只在链路开着时问」)。
62
+ */
63
+ export const LINK_KIND_POLL_MS = 3000
64
+
65
+ /**
66
+ * 从 stats 全集里判定通讯类型。
67
+ *
68
+ * 顺序刻意如此:
69
+ * 1. `transport.selectedCandidatePairId` —— 标准里**明确**指出「正在用哪条候选对」,最可信;
70
+ * 2. 退而求其次:`nominated` 且 `succeeded` 的候选对(老实现不一定给 `transport` 记录);
71
+ * 3. 再退:任意 `succeeded` 的候选对;
72
+ * 4. 都没有 → `unknown`。
73
+ */
74
+ export function classifyLinkKind(stats: Iterable<LinkStatsEntry>): LinkKind {
75
+ const list = [...stats]
76
+ const pair = pickCandidatePair(list)
77
+ if (!pair) return 'unknown'
78
+ // 还没定型的候选对(in-progress / failed)不算结论:此刻「怎么连的」还没确定
79
+ const state = pair['state']
80
+ if (typeof state === 'string' && state !== 'succeeded') return 'unknown'
81
+
82
+ const local = findCandidate(list, pair['localCandidateId'], 'local')
83
+ const remote = findCandidate(list, pair['remoteCandidateId'], 'remote')
84
+ const types = [local, remote]
85
+ .map((c) => c?.['candidateType'])
86
+ .filter((t): t is string => typeof t === 'string')
87
+
88
+ if (types.includes('relay')) return 'relay'
89
+ if (types.some((t) => DIRECT_TYPES.includes(t))) return 'direct'
90
+ return 'unknown'
91
+ }
92
+
93
+ /**
94
+ * 挑出「正在用哪条候选对」:标准路径(`transport.selectedCandidatePairId`)优先,退而求其次取
95
+ * `nominated` + `succeeded` 的那条,再退取任意 `succeeded` 的候选对。
96
+ *
97
+ * 单独导出是因为消费方(手机端样本提取)也要拿它读 rtt / 协议等字段 —— 与判定走**同一次挑选**,
98
+ * 否则判定与显示可能取自不同的候选对。
99
+ */
100
+ export function pickCandidatePair(list: LinkStatsEntry[]): LinkStatsEntry | undefined {
101
+ return selectedPair(list) ?? succeededPair(list)
102
+ }
103
+
104
+ /** 标准路径:`transport` 记录里明确指出的那条候选对。 */
105
+ function selectedPair(list: LinkStatsEntry[]): LinkStatsEntry | undefined {
106
+ const transport = list.find(
107
+ (s) => s['type'] === 'transport' && typeof s['selectedCandidatePairId'] === 'string',
108
+ )
109
+ const id = transport?.['selectedCandidatePairId']
110
+ if (typeof id !== 'string') return undefined
111
+ return list.find((s) => s['type'] === 'candidate-pair' && s['id'] === id)
112
+ }
113
+
114
+ /** 兜底路径:`nominated`(已被选中的那条)+ `succeeded`;没有 nominated 就取第一条 succeeded。 */
115
+ function succeededPair(list: LinkStatsEntry[]): LinkStatsEntry | undefined {
116
+ const pairs = list.filter((s) => s['type'] === 'candidate-pair' && s['state'] === 'succeeded')
117
+ return pairs.find((p) => p['nominated'] === true) ?? pairs[0]
118
+ }
119
+
120
+ /**
121
+ * 按 id 找候选记录。
122
+ *
123
+ * `localcandidate` / `remotecandidate` 是 2014 版规范里的类型名(部分老实现仍在用),
124
+ * 一起认下来 —— 认不出的代价是「明明是直连却显示未知」。
125
+ */
126
+ export function findCandidate(
127
+ list: LinkStatsEntry[],
128
+ id: unknown,
129
+ side: 'local' | 'remote',
130
+ ): LinkStatsEntry | undefined {
131
+ if (typeof id !== 'string') return undefined
132
+ const kinds =
133
+ side === 'local' ? ['local-candidate', 'localcandidate'] : ['remote-candidate', 'remotecandidate']
134
+ return list.find((s) => s['id'] === id && kinds.includes(s['type'] as string))
135
+ }
136
+
137
+ /**
138
+ * 采一次样(`getStats()` 是异步的)。
139
+ *
140
+ * ⚠️ **不吞异常**:调用方需要知道「问不到」,那是 `unknown` 而不是直连。
141
+ */
142
+ export async function probeLinkKind(pc: StatsProvider): Promise<LinkKind> {
143
+ const report = await pc.getStats()
144
+ const list: LinkStatsEntry[] = []
145
+ report.forEach((entry) => {
146
+ if (entry && typeof entry === 'object') list.push(entry as LinkStatsEntry)
147
+ })
148
+ return classifyLinkKind(list)
149
+ }
150
+
151
+ /**
152
+ * 通讯类型巡检器 —— 链路开着期间定时问一次「现在是直连还是中继」,变了就上报。
153
+ *
154
+ * 生命周期与链路绑定:`watch(pc)` 开始、`stop()` 结束(换链路 / 断链 / 停用都要 stop)。
155
+ * 不碰 UI、不碰 store,只负责「问」与「去重」,因此能用假 PC + 假时钟完整覆盖。
156
+ */
157
+ export class LinkKindWatcher {
158
+ private timer: ReturnType<typeof setInterval> | null = null
159
+ private pc: StatsProvider | null = null
160
+ private _kind: LinkKind = 'unknown'
161
+
162
+ constructor(
163
+ private readonly onChange: (kind: LinkKind) => void,
164
+ private readonly intervalMs: number = LINK_KIND_POLL_MS,
165
+ ) {}
166
+
167
+ /** 当前结论(`unknown` = 没结论,或巡检已停)。 */
168
+ get kind(): LinkKind {
169
+ return this._kind
170
+ }
171
+
172
+ /** 开始巡检(换链路时直接再调一次,旧的会先停)。不会等一个间隔:立刻先问一次。 */
173
+ watch(pc: StatsProvider): void {
174
+ this.stop()
175
+ this.pc = pc
176
+ void this.poll()
177
+ this.timer = setInterval(() => void this.poll(), this.intervalMs)
178
+ }
179
+
180
+ /**
181
+ * 停:链路没了 → 结论跟着作废(会广播 `unknown`,UI 据此收起胶囊)。
182
+ *
183
+ * 「换个链路」也是这个动作(`watch` 内部先调它),所以不会残留上一条链路的结论。
184
+ */
185
+ stop(): void {
186
+ if (this.timer) {
187
+ clearInterval(this.timer)
188
+ this.timer = null
189
+ }
190
+ this.pc = null
191
+ this.publish('unknown')
192
+ }
193
+
194
+ /**
195
+ * 探一次并上报。
196
+ *
197
+ * ⚠️ 两个刻意行为:
198
+ * - 链路**还开着**时读到 `unknown`(stats 还没填好 / 正在重协商)→ **保留上次结论**:
199
+ * 一次读不到不等于「不知道怎么连的」,而在直连/中继之间闪一下比稍旧更难受;
200
+ * - `await` 期间链路可能已换/已停 → 认准「还是这个 PC」再写回。
201
+ */
202
+ async poll(): Promise<void> {
203
+ const pc = this.pc
204
+ if (!pc) return
205
+ let kind: LinkKind = 'unknown'
206
+ try {
207
+ kind = await probeLinkKind(pc)
208
+ } catch {
209
+ /* `getStats()` 失败 = 拿不到结论,不等于「直连」 */
210
+ }
211
+ if (this.pc !== pc) return
212
+ this.publish(kind)
213
+ }
214
+
215
+ private publish(kind: LinkKind): void {
216
+ // 开着链路时的 `unknown` = 这一次没读到(见 `poll` 的说明)→ 保留上次结论
217
+ if (kind === 'unknown' && this.pc) return
218
+ if (kind === this._kind) return
219
+ this._kind = kind
220
+ this.onChange(kind)
221
+ }
222
+ }