virlen-remote 0.1.0

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.
Files changed (99) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/LICENSE +21 -0
  3. package/README.md +192 -0
  4. package/dist/index.d.ts +44 -0
  5. package/dist/index.d.ts.map +1 -0
  6. package/dist/index.js +34 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/protocol/answer.d.ts +48 -0
  9. package/dist/protocol/answer.d.ts.map +1 -0
  10. package/dist/protocol/answer.js +65 -0
  11. package/dist/protocol/answer.js.map +1 -0
  12. package/dist/protocol/api.d.ts +576 -0
  13. package/dist/protocol/api.d.ts.map +1 -0
  14. package/dist/protocol/api.js +38 -0
  15. package/dist/protocol/api.js.map +1 -0
  16. package/dist/protocol/endpoint.d.ts +76 -0
  17. package/dist/protocol/endpoint.d.ts.map +1 -0
  18. package/dist/protocol/endpoint.js +307 -0
  19. package/dist/protocol/endpoint.js.map +1 -0
  20. package/dist/protocol/errors.d.ts +32 -0
  21. package/dist/protocol/errors.d.ts.map +1 -0
  22. package/dist/protocol/errors.js +56 -0
  23. package/dist/protocol/errors.js.map +1 -0
  24. package/dist/protocol/frame.d.ts +51 -0
  25. package/dist/protocol/frame.d.ts.map +1 -0
  26. package/dist/protocol/frame.js +154 -0
  27. package/dist/protocol/frame.js.map +1 -0
  28. package/dist/protocol/hello.d.ts +73 -0
  29. package/dist/protocol/hello.d.ts.map +1 -0
  30. package/dist/protocol/hello.js +37 -0
  31. package/dist/protocol/hello.js.map +1 -0
  32. package/dist/protocol/host.d.ts +116 -0
  33. package/dist/protocol/host.d.ts.map +1 -0
  34. package/dist/protocol/host.js +52 -0
  35. package/dist/protocol/host.js.map +1 -0
  36. package/dist/protocol/identity.d.ts +107 -0
  37. package/dist/protocol/identity.d.ts.map +1 -0
  38. package/dist/protocol/identity.js +143 -0
  39. package/dist/protocol/identity.js.map +1 -0
  40. package/dist/protocol/ids.d.ts +2 -0
  41. package/dist/protocol/ids.d.ts.map +1 -0
  42. package/dist/protocol/ids.js +20 -0
  43. package/dist/protocol/ids.js.map +1 -0
  44. package/dist/protocol/pairing.d.ts +43 -0
  45. package/dist/protocol/pairing.d.ts.map +1 -0
  46. package/dist/protocol/pairing.js +78 -0
  47. package/dist/protocol/pairing.js.map +1 -0
  48. package/dist/testing/index.d.ts +6 -0
  49. package/dist/testing/index.d.ts.map +1 -0
  50. package/dist/testing/index.js +5 -0
  51. package/dist/testing/index.js.map +1 -0
  52. package/dist/testing/mock-host.d.ts +66 -0
  53. package/dist/testing/mock-host.d.ts.map +1 -0
  54. package/dist/testing/mock-host.js +573 -0
  55. package/dist/testing/mock-host.js.map +1 -0
  56. package/dist/transport/broadcast.d.ts +26 -0
  57. package/dist/transport/broadcast.d.ts.map +1 -0
  58. package/dist/transport/broadcast.js +71 -0
  59. package/dist/transport/broadcast.js.map +1 -0
  60. package/dist/transport/ice.d.ts +130 -0
  61. package/dist/transport/ice.d.ts.map +1 -0
  62. package/dist/transport/ice.js +338 -0
  63. package/dist/transport/ice.js.map +1 -0
  64. package/dist/transport/memory.d.ts +52 -0
  65. package/dist/transport/memory.d.ts.map +1 -0
  66. package/dist/transport/memory.js +135 -0
  67. package/dist/transport/memory.js.map +1 -0
  68. package/dist/transport/rtc.d.ts +76 -0
  69. package/dist/transport/rtc.d.ts.map +1 -0
  70. package/dist/transport/rtc.js +310 -0
  71. package/dist/transport/rtc.js.map +1 -0
  72. package/dist/transport/signaling.d.ts +101 -0
  73. package/dist/transport/signaling.d.ts.map +1 -0
  74. package/dist/transport/signaling.js +235 -0
  75. package/dist/transport/signaling.js.map +1 -0
  76. package/dist/transport/types.d.ts +41 -0
  77. package/dist/transport/types.d.ts.map +1 -0
  78. package/dist/transport/types.js +11 -0
  79. package/dist/transport/types.js.map +1 -0
  80. package/package.json +69 -0
  81. package/src/index.ts +155 -0
  82. package/src/protocol/answer.ts +89 -0
  83. package/src/protocol/api.ts +517 -0
  84. package/src/protocol/endpoint.ts +405 -0
  85. package/src/protocol/errors.ts +90 -0
  86. package/src/protocol/frame.ts +196 -0
  87. package/src/protocol/hello.ts +112 -0
  88. package/src/protocol/host.ts +150 -0
  89. package/src/protocol/identity.ts +184 -0
  90. package/src/protocol/ids.ts +20 -0
  91. package/src/protocol/pairing.ts +95 -0
  92. package/src/testing/index.ts +5 -0
  93. package/src/testing/mock-host.ts +691 -0
  94. package/src/transport/broadcast.ts +81 -0
  95. package/src/transport/ice.ts +402 -0
  96. package/src/transport/memory.ts +164 -0
  97. package/src/transport/rtc.ts +344 -0
  98. package/src/transport/signaling.ts +317 -0
  99. package/src/transport/types.ts +50 -0
@@ -0,0 +1,235 @@
1
+ /**
2
+ * 信令通道抽象 + SSE 实现(对接 virlen-api `/api/rtc`)。
3
+ *
4
+ * 分层理由(docs/phone-control-bridge.md §6):**RtcTransport 只依赖本接口**,不认识 HTTP/SSE。
5
+ * 于是:
6
+ * - 单测可注入「环回信令」验证 offer/answer 编排(不碰网络);
7
+ * - 将来信令换成 WebSocket / 或挪到 Rust,**只换一个实现**。
8
+ *
9
+ * 协议(virlen-api `/api/rtc`,M6 设备房间语义,见 §30.3):
10
+ * POST join { room, role, deviceKey?, clientName? }
11
+ * → { id, room, role, ip, hostOnline, peers }
12
+ * GET events ?room&id
13
+ * → SSE:{ type: 'peers' | 'peer-joined' | 'peer-left' | 'signal' | 'kicked' }
14
+ * POST signal { room, from, to, data }
15
+ * POST status { rooms: string[] } → { rooms: RoomStatus[] }(批量在线查询,不占房间)
16
+ *
17
+ * 角色语义(**M6 起服务器强制**):
18
+ * - `room` 由**电脑设备 key** 派生(`roomFor`),不再由客户端自定;
19
+ * - 同一房间**最多一个 host、一个 guest**;新 guest 加入会**顶掉**旧 guest(旧端收 `kicked`);
20
+ * - 同 key 的 host 重入 = 接管(电脑重启后旧连接多是死连接)。
21
+ */
22
+ import { BridgeError } from '../protocol/errors.js';
23
+ import { roomFor } from '../protocol/identity.js';
24
+ function normalizeBase(baseUrl) {
25
+ return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
26
+ }
27
+ export class SseSignalingClient {
28
+ constructor(options) {
29
+ this.options = options;
30
+ this.selfId = '';
31
+ this.peerId = null;
32
+ this.onPeer = null;
33
+ this.onData = null;
34
+ this.onError = null;
35
+ this.onKicked = null;
36
+ this.es = null;
37
+ this.closed = false;
38
+ /** 被顶号后置位:此后一切信令发送都无意义(房间已把我们移除)。 */
39
+ this.kicked = false;
40
+ this.base = normalizeBase(options.baseUrl);
41
+ }
42
+ async join() {
43
+ const res = await this.fetch()(this.base + 'join', {
44
+ method: 'POST',
45
+ headers: { 'content-type': 'application/json' },
46
+ body: JSON.stringify({
47
+ room: this.options.room,
48
+ role: this.options.role,
49
+ ...(this.options.deviceKey ? { deviceKey: this.options.deviceKey } : {}),
50
+ ...(this.options.clientName ? { clientName: this.options.clientName } : {}),
51
+ }),
52
+ });
53
+ if (!res.ok) {
54
+ const detail = await readErrorDetail(res);
55
+ throw new BridgeError('E_TRANSPORT', `信令 join 失败:HTTP ${res.status}${detail}`);
56
+ }
57
+ const info = (await res.json());
58
+ this.selfId = info.id;
59
+ // 手机端:电脑不在线就别占着 guest 位 —— 直接失败,让上层给「电脑不在线」
60
+ if (this.options.role === 'guest' && this.options.requireHostOnline && info.hostOnline === false) {
61
+ throw new BridgeError('E_TRANSPORT', '电脑不在线(信令房间内没有 host)', {
62
+ data: { reason: 'host-offline' },
63
+ });
64
+ }
65
+ this.openEvents();
66
+ // 房间里可能已有对端(本端后加入)→ 立刻通知,避免只依赖 peer-joined
67
+ const existing = (info.peers ?? []).find((p) => p.id !== this.selfId);
68
+ if (existing)
69
+ this.setPeer(existing.id);
70
+ return this.selfId;
71
+ }
72
+ async send(data) {
73
+ if (!this.peerId || this.kicked || this.closed)
74
+ return;
75
+ await this.fetch()(this.base + 'signal', {
76
+ method: 'POST',
77
+ headers: { 'content-type': 'application/json' },
78
+ body: JSON.stringify({
79
+ room: this.options.room,
80
+ from: this.selfId,
81
+ to: this.peerId,
82
+ data,
83
+ }),
84
+ }).catch(() => {
85
+ /* 信令转发失败:ICE 会重试/另择候选,不回滚 */
86
+ });
87
+ }
88
+ close() {
89
+ if (this.closed)
90
+ return;
91
+ this.closed = true;
92
+ try {
93
+ this.es?.close();
94
+ }
95
+ catch {
96
+ /* 忽略 */
97
+ }
98
+ this.es = null;
99
+ }
100
+ fetch() {
101
+ if (this.options.fetchImpl)
102
+ return this.options.fetchImpl;
103
+ if (typeof fetch === 'function')
104
+ return fetch;
105
+ throw new BridgeError('E_INTERNAL', '当前环境没有 fetch,未注入 fetchImpl');
106
+ }
107
+ openEvents() {
108
+ const factory = this.options.eventSourceFactory ?? defaultEventSourceFactory();
109
+ const url = `${this.base}events?room=${encodeURIComponent(this.options.room)}&id=${encodeURIComponent(this.selfId)}`;
110
+ const es = factory(url);
111
+ es.onopen = () => {
112
+ /* 连接就绪:无操作(首个事件即 peers) */
113
+ };
114
+ es.onerror = () => {
115
+ if (!this.closed)
116
+ this.onError?.(new Error('信令事件流异常'));
117
+ };
118
+ es.onmessage = (ev) => this.route(ev.data);
119
+ this.es = es;
120
+ }
121
+ route(raw) {
122
+ let msg;
123
+ try {
124
+ msg = JSON.parse(raw);
125
+ }
126
+ catch {
127
+ return;
128
+ }
129
+ switch (msg.type) {
130
+ case 'peers': {
131
+ const other = msg.peers.find((p) => p.id !== this.selfId);
132
+ if (other)
133
+ this.setPeer(other.id);
134
+ break;
135
+ }
136
+ case 'peer-joined':
137
+ this.setPeer(msg.peer.id);
138
+ break;
139
+ case 'peer-left':
140
+ this.setPeer(null);
141
+ break;
142
+ case 'kicked':
143
+ this.handleKicked(msg.reason ?? 'replaced');
144
+ break;
145
+ case 'signal':
146
+ this.onData?.(msg.data);
147
+ break;
148
+ default:
149
+ break;
150
+ }
151
+ }
152
+ /**
153
+ * 被顶号:先通知上层(它要据此关掉自动重连),再关掉事件流。
154
+ *
155
+ * 顺序很重要:`onKicked` 里上层会去 `transport.close()`,而 reconnect 的判定
156
+ * 就在那条路径上 —— 先把「被顶号」这个事实告知,再关流,才不会出现「先判定为重连、后知道是顶号」。
157
+ */
158
+ handleKicked(reason) {
159
+ if (this.kicked)
160
+ return;
161
+ this.kicked = true;
162
+ this.peerId = null;
163
+ try {
164
+ this.onKicked?.({ reason });
165
+ }
166
+ finally {
167
+ this.close();
168
+ }
169
+ }
170
+ setPeer(peerId) {
171
+ if (this.peerId === peerId)
172
+ return;
173
+ this.peerId = peerId;
174
+ this.onPeer?.(peerId);
175
+ }
176
+ }
177
+ function defaultEventSourceFactory() {
178
+ return (url) => {
179
+ const ES = globalThis.EventSource;
180
+ if (!ES) {
181
+ throw new BridgeError('E_INTERNAL', '当前环境没有 EventSource,未注入 eventSourceFactory');
182
+ }
183
+ return new ES(url);
184
+ };
185
+ }
186
+ /** 从失败的 join 应答里取一句可读的原因(服务器给 `{ error }`)。 */
187
+ async function readErrorDetail(res) {
188
+ try {
189
+ const body = (await res.json());
190
+ if (typeof body?.error === 'string' && body.error)
191
+ return `(${body.error})`;
192
+ }
193
+ catch {
194
+ /* 非 JSON 应答:忽略 */
195
+ }
196
+ return '';
197
+ }
198
+ /**
199
+ * 查询一批房间的在线状态。
200
+ *
201
+ * 失败时**返回空数组而不抛错**:手机端列表的「在线状态」只是提示,
202
+ * 信令服务不可用/版本过旧(无 `/status`)时应当退化为「未知」,而不是把整个列表打挂。
203
+ */
204
+ export async function fetchRoomStatus(options) {
205
+ const rooms = options.rooms.filter((r) => typeof r === 'string' && r);
206
+ if (rooms.length === 0)
207
+ return [];
208
+ const fetchImpl = options.fetchImpl ?? (typeof fetch === 'function' ? fetch : null);
209
+ if (!fetchImpl)
210
+ return [];
211
+ try {
212
+ const res = await fetchImpl(normalizeBase(options.baseUrl) + 'status', {
213
+ method: 'POST',
214
+ headers: { 'content-type': 'application/json' },
215
+ body: JSON.stringify({ rooms }),
216
+ });
217
+ if (!res.ok)
218
+ return [];
219
+ const body = (await res.json());
220
+ return Array.isArray(body?.rooms) ? body.rooms : [];
221
+ }
222
+ catch {
223
+ return [];
224
+ }
225
+ }
226
+ /** 便利封装:`电脑 key → 在线?`(手机端列表用)。 */
227
+ export async function fetchHostOnlineMap(baseUrl, hostKeys, fetchImpl) {
228
+ const statuses = await fetchRoomStatus({ baseUrl, rooms: hostKeys.map(roomFor), ...(fetchImpl ? { fetchImpl } : {}) });
229
+ const byRoom = new Map(statuses.map((s) => [s.room, s.hostOnline]));
230
+ const out = new Map();
231
+ for (const key of hostKeys)
232
+ out.set(key, byRoom.get(roomFor(key)) ?? false);
233
+ return out;
234
+ }
235
+ //# sourceMappingURL=signaling.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"signaling.js","sourceRoot":"","sources":["../../src/transport/signaling.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Transport 抽象 —— 协议层与「字节通道实现」之间的唯一边界。
3
+ *
4
+ * 两条关键设计:
5
+ * 1. **UI 永不直接碰这里**(docs/phone-control-bridge.md §1):协议层只依赖本接口,
6
+ * 将来把 RTC 整体挪到 Rust 时,**只换一个实现**,上面全部不动。
7
+ * 2. **`bufferedAmount` 是必需的**:DataChannel 的 `send()` 不阻塞,
8
+ * 接收慢时缓冲会静默堆积到爆内存 → 协议层据此做背压(§3.2)。
9
+ */
10
+ export type TransportState = 'connecting' | 'open' | 'closed';
11
+ export interface Transport {
12
+ readonly state: TransportState;
13
+ /** 未发送出去的字节数(DataChannel 的 `bufferedAmount`;内存实现恒为 0)。 */
14
+ readonly bufferedAmount: number;
15
+ /** 发送一帧(可能被实现内部缓冲/异步投递)。链路非 open 时实现方负责丢弃。 */
16
+ send(bytes: Uint8Array): void;
17
+ /** 关闭链路。 */
18
+ close(): void;
19
+ /** 订阅「收到一帧」;返回取消订阅函数。 */
20
+ onMessage(listener: (bytes: Uint8Array) => void): () => void;
21
+ /** 订阅「链路状态变化」;返回取消订阅函数。 */
22
+ onStateChange(listener: (state: TransportState) => void): () => void;
23
+ /**
24
+ * 可选:等待链路真正可用(RTC 需要先完成信令 + ICE 才 open)。
25
+ * 内存 / Broadcast 实现创建即 open,可不实现此方法。
26
+ */
27
+ whenReady?(): Promise<void>;
28
+ /** 可选:启动链路(RTC 需要先加入信令房间并开始协商)。内存 / Broadcast 无需启动。 */
29
+ start?(): void | Promise<void>;
30
+ /**
31
+ * 可选:订阅「链路级错误」。
32
+ *
33
+ * 为什么必需(M6):**被顶号**是一种错误而不是状态 —— 它对应的动作与「掉线(closed)」
34
+ * 完全相反(掉线要自动重连,被顶号绝不能)。只有 `code === 'E_REPLACED'` 会被这样使用,
35
+ * 见 `transport/rtc.ts` 与手机端 `store/connection.ts`。
36
+ *
37
+ * 内存 / Broadcast 实现没有致命错误,可不实现。
38
+ */
39
+ onError?(listener: (error: Error) => void): () => void;
40
+ }
41
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/transport/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,MAAM,MAAM,cAAc,GAAG,YAAY,GAAG,MAAM,GAAG,QAAQ,CAAA;AAE7D,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAA;IAE9B,0DAA0D;IAC1D,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAE/B,8CAA8C;IAC9C,IAAI,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI,CAAA;IAE7B,YAAY;IACZ,KAAK,IAAI,IAAI,CAAA;IAEb,yBAAyB;IACzB,SAAS,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,GAAG,MAAM,IAAI,CAAA;IAE5D,2BAA2B;IAC3B,aAAa,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,GAAG,MAAM,IAAI,CAAA;IAEpE;;;OAGG;IACH,SAAS,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IAE3B,uDAAuD;IACvD,KAAK,CAAC,IAAI,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAE9B;;;;;;;;OAQG;IACH,OAAO,CAAC,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAAG,MAAM,IAAI,CAAA;CACvD"}
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Transport 抽象 —— 协议层与「字节通道实现」之间的唯一边界。
3
+ *
4
+ * 两条关键设计:
5
+ * 1. **UI 永不直接碰这里**(docs/phone-control-bridge.md §1):协议层只依赖本接口,
6
+ * 将来把 RTC 整体挪到 Rust 时,**只换一个实现**,上面全部不动。
7
+ * 2. **`bufferedAmount` 是必需的**:DataChannel 的 `send()` 不阻塞,
8
+ * 接收慢时缓冲会静默堆积到爆内存 → 协议层据此做背压(§3.2)。
9
+ */
10
+ export {};
11
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/transport/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG"}
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "virlen-remote",
3
+ "version": "0.1.0",
4
+ "description": "Virlen 手机控制 · 协议栈共享包 —— 帧 / RPC / 错误 / 能力协商 / 设备身份与授权凭证 / 配对载荷 / ICE 解析 / Transport(WebRTC / 内存 / Broadcast)",
5
+ "license": "MIT",
6
+ "author": "WeiChens",
7
+ "keywords": [
8
+ "virlen",
9
+ "webrtc",
10
+ "rtc",
11
+ "p2p",
12
+ "datachannel",
13
+ "signaling",
14
+ "sse",
15
+ "rpc",
16
+ "typescript",
17
+ "browser"
18
+ ],
19
+ "homepage": "https://github.com/WeiChens/virlen-remote#readme",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/WeiChens/virlen-remote.git"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/WeiChens/virlen-remote/issues"
26
+ },
27
+ "type": "module",
28
+ "sideEffects": false,
29
+ "engines": {
30
+ "node": ">=18"
31
+ },
32
+ "main": "./dist/index.js",
33
+ "module": "./dist/index.js",
34
+ "types": "./dist/index.d.ts",
35
+ "exports": {
36
+ ".": {
37
+ "types": "./dist/index.d.ts",
38
+ "default": "./dist/index.js"
39
+ },
40
+ "./testing": {
41
+ "types": "./dist/testing/index.d.ts",
42
+ "default": "./dist/testing/index.js"
43
+ },
44
+ "./package.json": "./package.json"
45
+ },
46
+ "files": [
47
+ "dist",
48
+ "src",
49
+ "README.md",
50
+ "LICENSE",
51
+ "CHANGELOG.md"
52
+ ],
53
+ "publishConfig": {
54
+ "access": "public",
55
+ "registry": "https://registry.npmjs.org/"
56
+ },
57
+ "scripts": {
58
+ "build": "node scripts/build.mjs",
59
+ "typecheck": "tsc --noEmit",
60
+ "test": "vitest run",
61
+ "test:watch": "vitest",
62
+ "pack:check": "npm pack --dry-run",
63
+ "prepublishOnly": "npm run typecheck && npm run test && npm run build"
64
+ },
65
+ "devDependencies": {
66
+ "typescript": "~7.0.2",
67
+ "vitest": "^4.1.9"
68
+ }
69
+ }
package/src/index.ts ADDED
@@ -0,0 +1,155 @@
1
+ /**
2
+ * virlen-remote —— 手机控制协议栈共享包
3
+ *
4
+ * 电脑端(virlen-app)与手机端(virlen-mobile)共用同一份实现
5
+ * (见 docs/phone-control-bridge.md §1「越往下越同构」、§6「共享包」)。
6
+ *
7
+ * 分层:
8
+ * protocol/ 与传输无关的逻辑(帧 / RPC / 错误 / 能力协商 / 方法表)
9
+ * transport/ 字节通道抽象 + 内存实现(RTC 实现属 M3)
10
+ *
11
+ * **零运行时依赖**:WebRTC 用浏览器原生 `RTCPeerConnection`;Node 侧只用 memory transport 做测试。
12
+ */
13
+
14
+ // ── protocol ──
15
+ export { PROTOCOL_VERSION, HEADER_SIZE, DEFAULT_MAX_FRAME_PAYLOAD, FrameKind } from './protocol/frame'
16
+ export { encodeHeader, decodeHeader, frameBody, encodeFrames, Reassembler } from './protocol/frame'
17
+ export type { FrameHeader, DecodedMessage } from './protocol/frame'
18
+
19
+ export { BridgeError, toBridgeError } from './protocol/errors'
20
+ export type { ErrorCode, WireError, BridgeErrorOptions } from './protocol/errors'
21
+
22
+ export { newRequestId } from './protocol/ids'
23
+
24
+ // ── 设备身份与授权凭证(M6,§30)──
25
+ export {
26
+ HOST_KEY_PREFIX,
27
+ MOBILE_KEY_PREFIX,
28
+ GRANT_PREFIX,
29
+ ROOM_PREFIX,
30
+ GRANT_TTL_MS,
31
+ GRANT_MAX_LIFETIME_MS,
32
+ randomKey,
33
+ newDeviceKey,
34
+ newGrantToken,
35
+ isDeviceKey,
36
+ roomFor,
37
+ hostKeyFromRoom,
38
+ issueGrant,
39
+ isGrantExpired,
40
+ renewGrant,
41
+ checkGrant,
42
+ describeGrantRemaining,
43
+ } from './protocol/identity'
44
+ export type { DeviceKind, GrantRecord, CredentialRejectReason, RoomStatus } from './protocol/identity'
45
+
46
+ // ── 配对载荷(二维码内容,两端同一份)──
47
+ export {
48
+ PAIRING_PAYLOAD_VERSION,
49
+ PAIRING_TICKET_TTL_MS,
50
+ buildPairingPayload,
51
+ encodePairingPayload,
52
+ parsePairingPayload,
53
+ roomOfPayload,
54
+ } from './protocol/pairing'
55
+ export type { PairingPayload } from './protocol/pairing'
56
+
57
+ export { normalizeChoiceAnswer, answerActionError, CHOICE_JOINER } from './protocol/answer'
58
+ export type { ChoiceAnswer } from './protocol/answer'
59
+
60
+ export { negotiate, intersectCapabilities } from './protocol/hello'
61
+
62
+ export { Endpoint } from './protocol/endpoint'
63
+ export type {
64
+ CallContext,
65
+ EventContext,
66
+ RpcHandler,
67
+ EventSubscriber,
68
+ CallOptions,
69
+ EndpointOptions,
70
+ } from './protocol/endpoint'
71
+
72
+ export { createCaller, createSubscriber, registerHandlers } from './protocol/api'
73
+ export { DEFAULT_CONTEXT_WINDOW_TOKENS, COMPRESS_MIN_RATIO } from './protocol/api'
74
+ export type {
75
+ ParamsOf,
76
+ ResultOf,
77
+ TypedCaller,
78
+ TypedSubscriber,
79
+ HandlerImpl,
80
+ HostApi,
81
+ MobileApi,
82
+ HostEvents,
83
+ MobileEvents,
84
+ SessionSummaryDTO,
85
+ MessageDTO,
86
+ RuntimeDTO,
87
+ InteractionDTO,
88
+ InteractionOutcome,
89
+ StreamMode,
90
+ ApprovalTier,
91
+ SendParams,
92
+ MsgPageParams,
93
+ MsgPageDTO,
94
+ AnswerParams,
95
+ AnswerAction,
96
+ AnswerResult,
97
+ AnswerRejectReason,
98
+ CreateSessionParams,
99
+ RenameSessionParams,
100
+ PinSessionParams,
101
+ DeleteSessionParams,
102
+ ModelProviderDTO,
103
+ WorkspaceOptionDTO,
104
+ ContextInfoDTO,
105
+ SetModelParams,
106
+ ContextParams,
107
+ CompressParams,
108
+ } from './protocol/api'
109
+ export type { HelloParams, HelloResult, ClientInfo, NegotiationInput, Negotiated } from './protocol/hello'
110
+
111
+ export { registerHostHandlers } from './protocol/host'
112
+ export type { HostDataSource, HostEmit, HostRegistration, RegisterHostOptions } from './protocol/host'
113
+ // ── transport ──
114
+ export type { Transport, TransportState } from './transport/types'
115
+ export { MemoryTransport, createMemoryPair } from './transport/memory'
116
+ export type { MemoryTransportMetrics } from './transport/memory'
117
+ export { BroadcastTransport, createBroadcastPair } from './transport/broadcast'
118
+ export { SseSignalingClient, fetchRoomStatus, fetchHostOnlineMap } from './transport/signaling'
119
+ export type {
120
+ SignalingChannel,
121
+ SignalingRole,
122
+ SseSignalingOptions,
123
+ EventSourceLike,
124
+ KickedInfo,
125
+ FetchRoomStatusOptions,
126
+ } from './transport/signaling'
127
+ export { RtcTransport } from './transport/rtc'
128
+ export type { RtcTransportOptions } from './transport/rtc'
129
+
130
+ // ── ICE 配置(M7,§31):客户端不再内置任何 TURN 凭证 ──
131
+ export {
132
+ ICE_CONFIG_VERSION,
133
+ ICE_API_PATH,
134
+ ICE_CUSTOM_STORAGE_KEY,
135
+ ICE_REMOTE_STORAGE_KEY,
136
+ ICE_CACHE_TTL_MS,
137
+ ICE_FETCH_TIMEOUT_MS,
138
+ sanitizeIceServers,
139
+ parseIceText,
140
+ formatIceServers,
141
+ describeIceSource,
142
+ fetchIceServers,
143
+ resolveIceServers,
144
+ readCustomIceText,
145
+ writeCustomIceText,
146
+ } from './transport/ice'
147
+ export type {
148
+ IceServerInit,
149
+ IceSource,
150
+ IceStoragePort,
151
+ ResolvedIceServers,
152
+ ParseIceResult,
153
+ FetchIceOptions,
154
+ ResolveIceOptions,
155
+ } from './transport/ice'
@@ -0,0 +1,89 @@
1
+ /**
2
+ * 应答载荷的规范化 —— **手机侧与电脑侧共用同一份**(M4)。
3
+ *
4
+ * 为什么必须共用:手机回答「AI 提问」时,最终要交给引擎的是一个 `ToolResult`
5
+ * (`{ content, uiData }`),而电脑侧 UI 早就定下了这个形态(见 `tool-ui.tsx`):
6
+ * `content` = `[selected.join(', '), customReply].filter(Boolean).join(';')`。
7
+ *
8
+ * 如果手机侧、电脑侧桥接层、mock 宿主各写一份「怎么拼」,三份必然漂移
9
+ * —— 而漂移的表现是「AI 收到了内容,但消息渲染不出来(`uiData` 缺失)」这种**看不出错在谁**的故障。
10
+ * 故:**唯一实现放这里**,其余全部调用它。
11
+ *
12
+ * 手机端允许三种输入(宽容解析,避免因 UI 形态变化而连带改协议):
13
+ * - 字符串 → 等价于「自定义回复」;
14
+ * - 字符串数组 → 等价于「选中了这些选项」;
15
+ * - 对象 `{ selected?, customReply? }` → 精确形态(推荐)。
16
+ *
17
+ * 空结果一律返回 `null`(调用方据此回 `invalid-value`)——
18
+ * 给引擎发一条空回执会让 AI 无从判断用户到底选了什么。
19
+ */
20
+ import type { AnswerAction, AnswerRejectReason, InteractionDTO } from './api'
21
+
22
+ export interface ChoiceAnswer {
23
+ content: string
24
+ uiData: { selected: string[]; customReply: string }
25
+ }
26
+
27
+ /** 分隔符:与电脑侧 UI 的 `t(';')` 一致(中文分号)。 */
28
+ export const CHOICE_JOINER = ';'
29
+
30
+ export function normalizeChoiceAnswer(value: unknown): ChoiceAnswer | null {
31
+ let selected: string[] = []
32
+ let customReply = ''
33
+
34
+ if (typeof value === 'string') {
35
+ customReply = value.trim()
36
+ } else if (Array.isArray(value)) {
37
+ selected = value
38
+ .filter((v): v is string => typeof v === 'string')
39
+ .map((v) => v.trim())
40
+ .filter(Boolean)
41
+ } else if (value && typeof value === 'object') {
42
+ const v = value as { selected?: unknown; customReply?: unknown }
43
+ if (Array.isArray(v.selected)) {
44
+ selected = v.selected
45
+ .filter((x): x is string => typeof x === 'string')
46
+ .map((x) => x.trim())
47
+ .filter(Boolean)
48
+ }
49
+ if (typeof v.customReply === 'string') customReply = v.customReply.trim()
50
+ } else {
51
+ return null
52
+ }
53
+
54
+ if (!selected.length && !customReply) return null
55
+ const parts: string[] = []
56
+ if (selected.length) parts.push(selected.join(', '))
57
+ if (customReply) parts.push(customReply)
58
+ return { content: parts.join(CHOICE_JOINER), uiData: { selected, customReply } }
59
+ }
60
+
61
+ /**
62
+ * 应答动作的**合法性校验** —— 真实电脑侧(`interaction-registry`)与 mock 宿主共用同一份。
63
+ *
64
+ * 为什么放在共享包(M4 教训延续,2026-09-27 真机反馈):动作「对某类交互是否合法」是**协议语义**,
65
+ * 不是某一端的实现细节。两端各写一份时 mock 会偏宽松 ——
66
+ * 「手机对 AI 提问点取消 → 真实电脑端回 `unsupported-by-host`」这类缺陷在手机端单测里测不出来,
67
+ * 只能等到真机才暴露。
68
+ *
69
+ * 判据(与桌面 `tool-ui.tsx` 的按钮一一对应):
70
+ * - `choice`(AI 提问):`choose` 确认 / `deny` 取消 / `shelve` 暂存;`allow` 无意义;
71
+ * - `authorization`(授权):`allow` / `deny` / `shelve`;`choose` 无意义;
72
+ * - `authorization + presentation: 'terminal'`(终端内确认):**没有暂存语义**
73
+ * (它是「命令已就绪,等你按回车」,没有可暂存的对象)。
74
+ *
75
+ * @returns 不合法时返回拒绝原因(固定 `unsupported-by-host`);合法时返回 `null`。
76
+ */
77
+ export function answerActionError(
78
+ interaction: Pick<InteractionDTO, 'kind' | 'presentation'>,
79
+ action: AnswerAction,
80
+ ): AnswerRejectReason | null {
81
+ if (interaction.kind === 'choice') {
82
+ return action === 'allow' ? 'unsupported-by-host' : null
83
+ }
84
+ if (action === 'choose') return 'unsupported-by-host'
85
+ if (action === 'shelve' && interaction.presentation === 'terminal') {
86
+ return 'unsupported-by-host'
87
+ }
88
+ return null
89
+ }