virlen-remote 0.5.0 → 0.7.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.
- package/CHANGELOG.md +373 -169
- package/README.md +389 -201
- package/dist/index.d.ts +10 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/dist/protocol/agents.d.ts +14 -0
- package/dist/protocol/agents.d.ts.map +1 -0
- package/dist/protocol/agents.js +14 -0
- package/dist/protocol/agents.js.map +1 -0
- package/dist/protocol/api.d.ts +370 -1
- package/dist/protocol/api.d.ts.map +1 -1
- package/dist/protocol/api.js.map +1 -1
- package/dist/protocol/compress.d.ts +55 -0
- package/dist/protocol/compress.d.ts.map +1 -0
- package/dist/protocol/compress.js +60 -0
- package/dist/protocol/compress.js.map +1 -0
- package/dist/protocol/files.d.ts +261 -0
- package/dist/protocol/files.d.ts.map +1 -0
- package/dist/protocol/files.js +506 -0
- package/dist/protocol/files.js.map +1 -0
- package/dist/protocol/host.d.ts +24 -1
- package/dist/protocol/host.d.ts.map +1 -1
- package/dist/protocol/host.js +9 -1
- package/dist/protocol/host.js.map +1 -1
- package/dist/protocol/message-files.d.ts +79 -0
- package/dist/protocol/message-files.d.ts.map +1 -0
- package/dist/protocol/message-files.js +118 -0
- package/dist/protocol/message-files.js.map +1 -0
- package/dist/protocol/tool-args.d.ts +95 -0
- package/dist/protocol/tool-args.d.ts.map +1 -0
- package/dist/protocol/tool-args.js +439 -0
- package/dist/protocol/tool-args.js.map +1 -0
- package/dist/testing/mock-host.d.ts +44 -1
- package/dist/testing/mock-host.d.ts.map +1 -1
- package/dist/testing/mock-host.js +439 -10
- package/dist/testing/mock-host.js.map +1 -1
- package/dist/transport/rtc.d.ts +17 -0
- package/dist/transport/rtc.d.ts.map +1 -1
- package/dist/transport/rtc.js +49 -12
- package/dist/transport/rtc.js.map +1 -1
- package/dist/transport/signaling.d.ts +12 -1
- package/dist/transport/signaling.d.ts.map +1 -1
- package/dist/transport/signaling.js +17 -3
- package/dist/transport/signaling.js.map +1 -1
- package/package.json +69 -69
- package/src/index.ts +270 -184
- package/src/protocol/agents.ts +13 -0
- package/src/protocol/api.ts +974 -615
- package/src/protocol/compress.ts +65 -0
- package/src/protocol/files.ts +544 -0
- package/src/protocol/host.ts +205 -165
- package/src/protocol/message-actions.ts +60 -60
- package/src/protocol/message-files.ts +161 -0
- package/src/protocol/pairing.ts +254 -254
- package/src/protocol/tool-args.ts +478 -0
- package/src/testing/mock-host.ts +1285 -740
- package/src/transport/link-kind.ts +222 -222
- package/src/transport/rtc.ts +381 -344
- package/src/transport/signaling.ts +331 -317
- package/src/transport/transfer-tier.ts +64 -64
package/src/transport/rtc.ts
CHANGED
|
@@ -1,344 +1,381 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* RTC transport —— 真实链路(WebRTC DataChannel)。
|
|
3
|
-
*
|
|
4
|
-
* 只依赖两个注入点,因此**可单测**(不需要真实 WebRTC):
|
|
5
|
-
* - `signaling`:信令通道(`SignalingChannel`,M3 用 SSE 实现;测试用环回实现)
|
|
6
|
-
* - `createPeerConnection`:`RTCPeerConnection` 工厂(测试用假实现)
|
|
7
|
-
*
|
|
8
|
-
* 角色(见 §8 拍板):**电脑(host) 发起 offer**,手机(guest) 应答。
|
|
9
|
-
* 电脑常驻房间,手机加入后由电脑发 offer —— 手机可能在后台切换,桌面是稳定进程。
|
|
10
|
-
*
|
|
11
|
-
* 状态映射:`connected → open`;`failed/closed → closed`;`disconnected → connecting`(等重连)。
|
|
12
|
-
*/
|
|
13
|
-
import type { Transport, TransportState } from './types'
|
|
14
|
-
import type { KickedInfo, SignalingChannel, SignalingRole } from './signaling'
|
|
15
|
-
import type { IceServerInit } from './ice'
|
|
16
|
-
import { BridgeError, toBridgeError } from '../protocol/errors'
|
|
17
|
-
|
|
18
|
-
export interface RtcTransportOptions {
|
|
19
|
-
role: SignalingRole
|
|
20
|
-
signaling: SignalingChannel
|
|
21
|
-
/**
|
|
22
|
-
* ICE 服务器列表。**不再有内置默认值**(M7,§31):两端都由
|
|
23
|
-
* `resolveIceServers()` 决定(自定义 > 服务端下发 > 缓存 > 空)。
|
|
24
|
-
*/
|
|
25
|
-
iceServers?: IceServerInit[]
|
|
26
|
-
/** 数据通道 label(对两端须一致)。 */
|
|
27
|
-
channelLabel?: string
|
|
28
|
-
/** 注入 `RTCPeerConnection` 工厂(测试用;默认取全局构造器)。 */
|
|
29
|
-
createPeerConnection?: (config: RTCConfiguration) => RTCPeerConnection
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
type SignalData =
|
|
33
|
-
| { kind: 'offer'; sdp: RTCSessionDescriptionInit }
|
|
34
|
-
| { kind: 'answer'; sdp: RTCSessionDescriptionInit }
|
|
35
|
-
| { kind: 'candidate'; cand: RTCIceCandidateInit | null }
|
|
36
|
-
|
|
37
|
-
export class RtcTransport implements Transport {
|
|
38
|
-
private _state: TransportState = 'connecting'
|
|
39
|
-
private readonly messageListeners = new Set<(bytes: Uint8Array) => void>()
|
|
40
|
-
private readonly stateListeners = new Set<(state: TransportState) => void>()
|
|
41
|
-
|
|
42
|
-
private pc: RTCPeerConnection | null = null
|
|
43
|
-
private dc: RTCDataChannel | null = null
|
|
44
|
-
private pendingCandidates: RTCIceCandidateInit[] = []
|
|
45
|
-
private started = false
|
|
46
|
-
/** 被顶号(致命):置位后一切重建/重连动作都无意义。 */
|
|
47
|
-
private replaced = false
|
|
48
|
-
|
|
49
|
-
private readonly readyPromise: Promise<void>
|
|
50
|
-
private readyResolve: (() => void) | null = null
|
|
51
|
-
private readyReject: ((error: Error) => void) | null = null
|
|
52
|
-
|
|
53
|
-
constructor(private readonly options: RtcTransportOptions) {
|
|
54
|
-
this.readyPromise = new Promise<void>((resolve, reject) => {
|
|
55
|
-
this.readyResolve = resolve
|
|
56
|
-
this.readyReject = reject
|
|
57
|
-
})
|
|
58
|
-
const sig = options.signaling
|
|
59
|
-
sig.onPeer = (peerId) => this.onPeer(peerId)
|
|
60
|
-
sig.onData = (data) => {
|
|
61
|
-
void this.handleRemote(data)
|
|
62
|
-
}
|
|
63
|
-
sig.onError = (err) => this.fail(err)
|
|
64
|
-
sig.onKicked = (info) => this.onKicked(info)
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
get state(): TransportState {
|
|
68
|
-
return this._state
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
get bufferedAmount(): number {
|
|
72
|
-
return this.dc?.bufferedAmount ?? 0
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** 链路真正可用的 Promise(DataChannel open 时 resolve);失败/关闭时 reject。 */
|
|
76
|
-
whenReady(): Promise<void> {
|
|
77
|
-
return this.readyPromise
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** 加入信令房间并开始协商。使用方创建后调用一次(不需要 await,靠状态/`whenReady` 驱动)。 */
|
|
81
|
-
async start(): Promise<void> {
|
|
82
|
-
if (this.started) return
|
|
83
|
-
this.started = true
|
|
84
|
-
try {
|
|
85
|
-
await this.options.signaling.join()
|
|
86
|
-
} catch (err) {
|
|
87
|
-
this.fail(toBridgeError(err))
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
send(bytes: Uint8Array): void {
|
|
92
|
-
const dc = this.dc
|
|
93
|
-
if (this._state !== 'open' || !dc || dc.readyState !== 'open') return
|
|
94
|
-
// 注:DOM 定义的 `send` 要求 `ArrayBufferView<ArrayBuffer>`,而 `Uint8Array` 泛型是
|
|
95
|
-
// `ArrayBufferLike`(可能含 SharedArrayBuffer)。实际数据永远是普通 buffer,安全 cast。
|
|
96
|
-
dc.send(bytes as unknown as ArrayBufferView<ArrayBuffer>)
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
close(): void {
|
|
100
|
-
if (this._state === 'closed') return
|
|
101
|
-
this.teardownPeer()
|
|
102
|
-
try {
|
|
103
|
-
this.options.signaling.close()
|
|
104
|
-
} catch {
|
|
105
|
-
/* 忽略 */
|
|
106
|
-
}
|
|
107
|
-
this.setState('closed')
|
|
108
|
-
this.readyReject?.(new Error('transport closed'))
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
onMessage(listener: (bytes: Uint8Array) => void): () => void {
|
|
112
|
-
this.messageListeners.add(listener)
|
|
113
|
-
return () => {
|
|
114
|
-
this.messageListeners.delete(listener)
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
onStateChange(listener: (state: TransportState) => void): () => void {
|
|
119
|
-
this.stateListeners.add(listener)
|
|
120
|
-
return () => {
|
|
121
|
-
this.stateListeners.delete(listener)
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// ───────────────────────── 内部:信令 → 协商 ─────────────────────────
|
|
126
|
-
|
|
127
|
-
private onPeer(peerId: string | null): void {
|
|
128
|
-
// 被顶号后信令已停用:此时任何对端变化都不该再重建 PC(重建出来也是死链路)
|
|
129
|
-
if (this.replaced) return
|
|
130
|
-
if (peerId === null) {
|
|
131
|
-
// 对端离开:清掉旧连接,回到等待态(手机重连后会重新触发)
|
|
132
|
-
this.teardownPeer()
|
|
133
|
-
this.setState('connecting')
|
|
134
|
-
return
|
|
135
|
-
}
|
|
136
|
-
// 只有 host 主动发起:guest 必须等 offer 到达才建 PC(否则会建两次 PC、上下文串味)
|
|
137
|
-
if (this.options.role === 'host') {
|
|
138
|
-
this.ensurePC()
|
|
139
|
-
void this.startOffer()
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* 被服务端顶号(同一台电脑已有别的手机接入)。
|
|
145
|
-
*
|
|
146
|
-
* 顺序是刻意的:**先把错误告知上层,再改状态**。上层(手机端 `connectionStore`)在
|
|
147
|
-
* 看到 `closed` 时会安排自动重连 —— 而重连会把刚接入的那台手机再顶掉(乒乓)。
|
|
148
|
-
* 所以「被顶号」这个事实必须先于状态变化到达。
|
|
149
|
-
*/
|
|
150
|
-
private onKicked(info: KickedInfo): void {
|
|
151
|
-
if (this.replaced) return
|
|
152
|
-
this.replaced = true
|
|
153
|
-
this.fail(
|
|
154
|
-
new BridgeError('E_REPLACED', '该电脑已被另一台手机接管连接', {
|
|
155
|
-
data: { reason: info.reason },
|
|
156
|
-
}),
|
|
157
|
-
)
|
|
158
|
-
this.teardownPeer()
|
|
159
|
-
try {
|
|
160
|
-
this.options.signaling.close()
|
|
161
|
-
} catch {
|
|
162
|
-
/* 忽略 */
|
|
163
|
-
}
|
|
164
|
-
// 状态置 closed 收尾(`_state` 为 closed 时 `close()` 也不会重复处理)
|
|
165
|
-
this.setState('closed')
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
private ensurePC(): RTCPeerConnection {
|
|
169
|
-
if (this.pc) return this.pc
|
|
170
|
-
const factory = this.options.createPeerConnection ?? defaultFactory()
|
|
171
|
-
const pc = factory({ iceServers: this.options.iceServers ?? [] })
|
|
172
|
-
this.pc = pc
|
|
173
|
-
|
|
174
|
-
pc.onicecandidate = (e) => {
|
|
175
|
-
if (!e.candidate) return
|
|
176
|
-
void this.options.signaling.send({ kind: 'candidate', cand: candidateToJson(e.candidate) })
|
|
177
|
-
}
|
|
178
|
-
pc.onconnectionstatechange = () => this.onConnectionState(pc.connectionState)
|
|
179
|
-
pc.ondatachannel = (e) => this.setupDataChannel(e.channel)
|
|
180
|
-
return pc
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
private async startOffer(): Promise<void> {
|
|
184
|
-
const pc = this.pc
|
|
185
|
-
if (!pc) return
|
|
186
|
-
if (!this.dc) {
|
|
187
|
-
this.setupDataChannel(pc.createDataChannel(this.options.channelLabel ?? 'virlen'))
|
|
188
|
-
}
|
|
189
|
-
try {
|
|
190
|
-
const offer = await pc.createOffer()
|
|
191
|
-
await pc.setLocalDescription(offer)
|
|
192
|
-
await this.options.signaling.send({ kind: 'offer', sdp: pc.localDescription ?? offer })
|
|
193
|
-
} catch (err) {
|
|
194
|
-
this.fail(toBridgeError(err))
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
private async handleRemote(data: unknown): Promise<void> {
|
|
199
|
-
const msg = data as SignalData
|
|
200
|
-
if (!msg || typeof msg !== 'object' || typeof msg.kind !== 'string') return
|
|
201
|
-
try {
|
|
202
|
-
if (msg.kind === 'offer') {
|
|
203
|
-
// 每个 offer 视作新会话:清旧上下文,避免新旧 ICE 串味(同 rtc.js)
|
|
204
|
-
if (this.pc) this.teardownPeer()
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
if (pc
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
this.
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
}
|
|
257
|
-
channel.
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
case '
|
|
275
|
-
|
|
276
|
-
this.
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
break
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
1
|
+
/**
|
|
2
|
+
* RTC transport —— 真实链路(WebRTC DataChannel)。
|
|
3
|
+
*
|
|
4
|
+
* 只依赖两个注入点,因此**可单测**(不需要真实 WebRTC):
|
|
5
|
+
* - `signaling`:信令通道(`SignalingChannel`,M3 用 SSE 实现;测试用环回实现)
|
|
6
|
+
* - `createPeerConnection`:`RTCPeerConnection` 工厂(测试用假实现)
|
|
7
|
+
*
|
|
8
|
+
* 角色(见 §8 拍板):**电脑(host) 发起 offer**,手机(guest) 应答。
|
|
9
|
+
* 电脑常驻房间,手机加入后由电脑发 offer —— 手机可能在后台切换,桌面是稳定进程。
|
|
10
|
+
*
|
|
11
|
+
* 状态映射:`connected → open`;`failed/closed → closed`;`disconnected → connecting`(等重连)。
|
|
12
|
+
*/
|
|
13
|
+
import type { Transport, TransportState } from './types'
|
|
14
|
+
import type { KickedInfo, SignalingChannel, SignalingRole } from './signaling'
|
|
15
|
+
import type { IceServerInit } from './ice'
|
|
16
|
+
import { BridgeError, toBridgeError } from '../protocol/errors'
|
|
17
|
+
|
|
18
|
+
export interface RtcTransportOptions {
|
|
19
|
+
role: SignalingRole
|
|
20
|
+
signaling: SignalingChannel
|
|
21
|
+
/**
|
|
22
|
+
* ICE 服务器列表。**不再有内置默认值**(M7,§31):两端都由
|
|
23
|
+
* `resolveIceServers()` 决定(自定义 > 服务端下发 > 缓存 > 空)。
|
|
24
|
+
*/
|
|
25
|
+
iceServers?: IceServerInit[]
|
|
26
|
+
/** 数据通道 label(对两端须一致)。 */
|
|
27
|
+
channelLabel?: string
|
|
28
|
+
/** 注入 `RTCPeerConnection` 工厂(测试用;默认取全局构造器)。 */
|
|
29
|
+
createPeerConnection?: (config: RTCConfiguration) => RTCPeerConnection
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type SignalData =
|
|
33
|
+
| { kind: 'offer'; sdp: RTCSessionDescriptionInit }
|
|
34
|
+
| { kind: 'answer'; sdp: RTCSessionDescriptionInit }
|
|
35
|
+
| { kind: 'candidate'; cand: RTCIceCandidateInit | null }
|
|
36
|
+
|
|
37
|
+
export class RtcTransport implements Transport {
|
|
38
|
+
private _state: TransportState = 'connecting'
|
|
39
|
+
private readonly messageListeners = new Set<(bytes: Uint8Array) => void>()
|
|
40
|
+
private readonly stateListeners = new Set<(state: TransportState) => void>()
|
|
41
|
+
|
|
42
|
+
private pc: RTCPeerConnection | null = null
|
|
43
|
+
private dc: RTCDataChannel | null = null
|
|
44
|
+
private pendingCandidates: RTCIceCandidateInit[] = []
|
|
45
|
+
private started = false
|
|
46
|
+
/** 被顶号(致命):置位后一切重建/重连动作都无意义。 */
|
|
47
|
+
private replaced = false
|
|
48
|
+
|
|
49
|
+
private readonly readyPromise: Promise<void>
|
|
50
|
+
private readyResolve: (() => void) | null = null
|
|
51
|
+
private readyReject: ((error: Error) => void) | null = null
|
|
52
|
+
|
|
53
|
+
constructor(private readonly options: RtcTransportOptions) {
|
|
54
|
+
this.readyPromise = new Promise<void>((resolve, reject) => {
|
|
55
|
+
this.readyResolve = resolve
|
|
56
|
+
this.readyReject = reject
|
|
57
|
+
})
|
|
58
|
+
const sig = options.signaling
|
|
59
|
+
sig.onPeer = (peerId) => this.onPeer(peerId)
|
|
60
|
+
sig.onData = (data) => {
|
|
61
|
+
void this.handleRemote(data)
|
|
62
|
+
}
|
|
63
|
+
sig.onError = (err) => this.fail(err)
|
|
64
|
+
sig.onKicked = (info) => this.onKicked(info)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
get state(): TransportState {
|
|
68
|
+
return this._state
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get bufferedAmount(): number {
|
|
72
|
+
return this.dc?.bufferedAmount ?? 0
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 链路真正可用的 Promise(DataChannel open 时 resolve);失败/关闭时 reject。 */
|
|
76
|
+
whenReady(): Promise<void> {
|
|
77
|
+
return this.readyPromise
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 加入信令房间并开始协商。使用方创建后调用一次(不需要 await,靠状态/`whenReady` 驱动)。 */
|
|
81
|
+
async start(): Promise<void> {
|
|
82
|
+
if (this.started) return
|
|
83
|
+
this.started = true
|
|
84
|
+
try {
|
|
85
|
+
await this.options.signaling.join()
|
|
86
|
+
} catch (err) {
|
|
87
|
+
this.fail(toBridgeError(err))
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
send(bytes: Uint8Array): void {
|
|
92
|
+
const dc = this.dc
|
|
93
|
+
if (this._state !== 'open' || !dc || dc.readyState !== 'open') return
|
|
94
|
+
// 注:DOM 定义的 `send` 要求 `ArrayBufferView<ArrayBuffer>`,而 `Uint8Array` 泛型是
|
|
95
|
+
// `ArrayBufferLike`(可能含 SharedArrayBuffer)。实际数据永远是普通 buffer,安全 cast。
|
|
96
|
+
dc.send(bytes as unknown as ArrayBufferView<ArrayBuffer>)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
close(): void {
|
|
100
|
+
if (this._state === 'closed') return
|
|
101
|
+
this.teardownPeer()
|
|
102
|
+
try {
|
|
103
|
+
this.options.signaling.close()
|
|
104
|
+
} catch {
|
|
105
|
+
/* 忽略 */
|
|
106
|
+
}
|
|
107
|
+
this.setState('closed')
|
|
108
|
+
this.readyReject?.(new Error('transport closed'))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
onMessage(listener: (bytes: Uint8Array) => void): () => void {
|
|
112
|
+
this.messageListeners.add(listener)
|
|
113
|
+
return () => {
|
|
114
|
+
this.messageListeners.delete(listener)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
onStateChange(listener: (state: TransportState) => void): () => void {
|
|
119
|
+
this.stateListeners.add(listener)
|
|
120
|
+
return () => {
|
|
121
|
+
this.stateListeners.delete(listener)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ───────────────────────── 内部:信令 → 协商 ─────────────────────────
|
|
126
|
+
|
|
127
|
+
private onPeer(peerId: string | null): void {
|
|
128
|
+
// 被顶号后信令已停用:此时任何对端变化都不该再重建 PC(重建出来也是死链路)
|
|
129
|
+
if (this.replaced) return
|
|
130
|
+
if (peerId === null) {
|
|
131
|
+
// 对端离开:清掉旧连接,回到等待态(手机重连后会重新触发)
|
|
132
|
+
this.teardownPeer()
|
|
133
|
+
this.setState('connecting')
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
// 只有 host 主动发起:guest 必须等 offer 到达才建 PC(否则会建两次 PC、上下文串味)
|
|
137
|
+
if (this.options.role === 'host') {
|
|
138
|
+
this.ensurePC()
|
|
139
|
+
void this.startOffer()
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 被服务端顶号(同一台电脑已有别的手机接入)。
|
|
145
|
+
*
|
|
146
|
+
* 顺序是刻意的:**先把错误告知上层,再改状态**。上层(手机端 `connectionStore`)在
|
|
147
|
+
* 看到 `closed` 时会安排自动重连 —— 而重连会把刚接入的那台手机再顶掉(乒乓)。
|
|
148
|
+
* 所以「被顶号」这个事实必须先于状态变化到达。
|
|
149
|
+
*/
|
|
150
|
+
private onKicked(info: KickedInfo): void {
|
|
151
|
+
if (this.replaced) return
|
|
152
|
+
this.replaced = true
|
|
153
|
+
this.fail(
|
|
154
|
+
new BridgeError('E_REPLACED', '该电脑已被另一台手机接管连接', {
|
|
155
|
+
data: { reason: info.reason },
|
|
156
|
+
}),
|
|
157
|
+
)
|
|
158
|
+
this.teardownPeer()
|
|
159
|
+
try {
|
|
160
|
+
this.options.signaling.close()
|
|
161
|
+
} catch {
|
|
162
|
+
/* 忽略 */
|
|
163
|
+
}
|
|
164
|
+
// 状态置 closed 收尾(`_state` 为 closed 时 `close()` 也不会重复处理)
|
|
165
|
+
this.setState('closed')
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private ensurePC(): RTCPeerConnection {
|
|
169
|
+
if (this.pc) return this.pc
|
|
170
|
+
const factory = this.options.createPeerConnection ?? defaultFactory()
|
|
171
|
+
const pc = factory({ iceServers: this.options.iceServers ?? [] })
|
|
172
|
+
this.pc = pc
|
|
173
|
+
|
|
174
|
+
pc.onicecandidate = (e) => {
|
|
175
|
+
if (!e.candidate) return
|
|
176
|
+
void this.options.signaling.send({ kind: 'candidate', cand: candidateToJson(e.candidate) })
|
|
177
|
+
}
|
|
178
|
+
pc.onconnectionstatechange = () => this.onConnectionState(pc.connectionState)
|
|
179
|
+
pc.ondatachannel = (e) => this.setupDataChannel(e.channel)
|
|
180
|
+
return pc
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private async startOffer(): Promise<void> {
|
|
184
|
+
const pc = this.pc
|
|
185
|
+
if (!pc) return
|
|
186
|
+
if (!this.dc) {
|
|
187
|
+
this.setupDataChannel(pc.createDataChannel(this.options.channelLabel ?? 'virlen'))
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
const offer = await pc.createOffer()
|
|
191
|
+
await pc.setLocalDescription(offer)
|
|
192
|
+
await this.options.signaling.send({ kind: 'offer', sdp: pc.localDescription ?? offer })
|
|
193
|
+
} catch (err) {
|
|
194
|
+
this.fail(toBridgeError(err))
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private async handleRemote(data: unknown): Promise<void> {
|
|
199
|
+
const msg = data as SignalData
|
|
200
|
+
if (!msg || typeof msg !== 'object' || typeof msg.kind !== 'string') return
|
|
201
|
+
try {
|
|
202
|
+
if (msg.kind === 'offer') {
|
|
203
|
+
// 每个 offer 视作新会话:清旧上下文,避免新旧 ICE 串味(同 rtc.js)
|
|
204
|
+
if (this.pc) this.teardownPeer()
|
|
205
|
+
/*
|
|
206
|
+
* 换对端 = **代际更替**:旧通道已经拆掉,新通道 `open` 之前这条链路不可用。
|
|
207
|
+
* 这句以前由「被关掉的旧 dc 迟到的那条 `onclose`」代劳(真实浏览器里 close 事件是
|
|
208
|
+
* 异步投递的),而 `teardownPeer` 现在主动静默(见那里的说明)—— 故必须显式补上:
|
|
209
|
+
* 上层(电脑端 `PhoneControlService` / 手机端 `connectionStore`)靠这条事件把
|
|
210
|
+
* 「授权」作废,回到 `open` 后重新 `hello`。少了它,新链路会带着旧链路的授权假在线。
|
|
211
|
+
*/
|
|
212
|
+
this.setState('connecting')
|
|
213
|
+
const pc = this.ensurePC()
|
|
214
|
+
await pc.setRemoteDescription(msg.sdp)
|
|
215
|
+
await this.flushCandidates()
|
|
216
|
+
const answer = await pc.createAnswer()
|
|
217
|
+
await pc.setLocalDescription(answer)
|
|
218
|
+
await this.options.signaling.send({ kind: 'answer', sdp: pc.localDescription ?? answer })
|
|
219
|
+
} else if (msg.kind === 'answer') {
|
|
220
|
+
const pc = this.pc
|
|
221
|
+
if (!pc) return
|
|
222
|
+
if (pc.signalingState === 'have-local-offer') {
|
|
223
|
+
await pc.setRemoteDescription(msg.sdp)
|
|
224
|
+
await this.flushCandidates()
|
|
225
|
+
}
|
|
226
|
+
} else if (msg.kind === 'candidate') {
|
|
227
|
+
const pc = this.pc
|
|
228
|
+
if (!pc || !msg.cand) return
|
|
229
|
+
if (pc.remoteDescription && pc.remoteDescription.type) {
|
|
230
|
+
await pc.addIceCandidate(msg.cand).catch(() => {})
|
|
231
|
+
} else {
|
|
232
|
+
this.pendingCandidates.push(msg.cand)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
} catch (err) {
|
|
236
|
+
this.fail(toBridgeError(err))
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private async flushCandidates(): Promise<void> {
|
|
241
|
+
const list = this.pendingCandidates
|
|
242
|
+
this.pendingCandidates = []
|
|
243
|
+
const pc = this.pc
|
|
244
|
+
if (!pc) return
|
|
245
|
+
for (const cand of list) {
|
|
246
|
+
await pc.addIceCandidate(cand).catch(() => {})
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private setupDataChannel(channel: RTCDataChannel): void {
|
|
251
|
+
this.dc = channel
|
|
252
|
+
try {
|
|
253
|
+
channel.binaryType = 'arraybuffer'
|
|
254
|
+
} catch {
|
|
255
|
+
/* 某些实现只读 */
|
|
256
|
+
}
|
|
257
|
+
channel.onopen = () => {
|
|
258
|
+
this.setState('open')
|
|
259
|
+
this.readyResolve?.()
|
|
260
|
+
}
|
|
261
|
+
channel.onclose = () => {
|
|
262
|
+
// 通道关闭:等待对端重连(不永久 closed)
|
|
263
|
+
this.setState('connecting')
|
|
264
|
+
}
|
|
265
|
+
channel.onmessage = (e: MessageEvent) => {
|
|
266
|
+
const bytes = toBytes(e.data)
|
|
267
|
+
if (!bytes) return
|
|
268
|
+
for (const listener of [...this.messageListeners]) listener(bytes)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private onConnectionState(connState: RTCPeerConnectionState): void {
|
|
273
|
+
switch (connState) {
|
|
274
|
+
case 'connected':
|
|
275
|
+
// ⚠️ **不在这里置 open**:`connectionState` 可能**早于** DataChannel 的 `open` 触发。
|
|
276
|
+
// 尤其 guest(手机)侧:dc 是 `ondatachannel` **异步交付**的,此刻 `this.dc` 可能还是 null
|
|
277
|
+
// → 上层 `whenReady` 已 resolve、`transport.state==='open'`,但 `send()` 因 dc 未就绪而
|
|
278
|
+
// **静默丢弃**(2026-09 真机踩过:手机“连接超时”,电脑端日志 open→4s→connecting)。
|
|
279
|
+
// 统一以 `dc.onopen` 为准 —— 回归用例在 rtc-transport.test.ts。
|
|
280
|
+
break
|
|
281
|
+
case 'failed':
|
|
282
|
+
case 'closed':
|
|
283
|
+
this.setState('closed')
|
|
284
|
+
this.readyReject?.(new Error(`peer connection ${connState}`))
|
|
285
|
+
break
|
|
286
|
+
case 'disconnected':
|
|
287
|
+
this.setState('connecting')
|
|
288
|
+
break
|
|
289
|
+
default:
|
|
290
|
+
break
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* 拆掉当前的 PeerConnection / DataChannel。
|
|
296
|
+
*
|
|
297
|
+
* ⚠️ **先摘监听器,再关**。真实浏览器里 `close()` 的收尾事件(`dc.onclose` /
|
|
298
|
+
* `pc.onconnectionstatechange`)是**异步投递**的(进任务队列),而拆链的调用点往往紧接着就要
|
|
299
|
+
* 宣告一个**终态**(`close()` 与 `onKicked()` 都置 `closed`)。留着监听器,那条迟到的事件就会在
|
|
300
|
+
* 终态之后补一个 `connecting` —— 上层会据此认为「链路还能自己回来」,把已经排好的原地重开
|
|
301
|
+
* 撤销掉:电脑端就此停在「等待手机连接…」,而信令房间早已没有它(手机端因此显示
|
|
302
|
+
* 「电脑不在线」,且再也连不回来。2026-10 真机反馈,见 `rtc-transport.test.ts` 的被顶号用例)。
|
|
303
|
+
*
|
|
304
|
+
* 所以:**主动拆除 = 静默**。这条链路此刻是什么结论,一律由调用点紧接着显式 `setState` 说明
|
|
305
|
+
* (`close` / `onKicked` → `closed`;对端离开 → `connecting`;收到新 offer 换对端 → `connecting`)。
|
|
306
|
+
*
|
|
307
|
+
* 顺带一处:`pc.close()` 也会让 `connectionState` 变 `closed` 并回调同一个监听器 —— 摘掉它,
|
|
308
|
+
* 「手机主动走开」这类**主动拆除**才不会被记成「链路故障」(否则每次对端离开,电脑端都会白闪
|
|
309
|
+
* 一次「出错(链路已关闭)」,再靠 3 秒的自愈重建一条本来不必重建的链路)。
|
|
310
|
+
*/
|
|
311
|
+
private teardownPeer(): void {
|
|
312
|
+
this.pendingCandidates = []
|
|
313
|
+
const dc = this.dc
|
|
314
|
+
const pc = this.pc
|
|
315
|
+
this.dc = null
|
|
316
|
+
this.pc = null
|
|
317
|
+
if (dc) {
|
|
318
|
+
dc.onopen = null
|
|
319
|
+
dc.onclose = null
|
|
320
|
+
dc.onmessage = null
|
|
321
|
+
try {
|
|
322
|
+
dc.close()
|
|
323
|
+
} catch {
|
|
324
|
+
/* 忽略 */
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (pc) {
|
|
328
|
+
pc.onicecandidate = null
|
|
329
|
+
pc.onconnectionstatechange = null
|
|
330
|
+
pc.ondatachannel = null
|
|
331
|
+
try {
|
|
332
|
+
pc.close()
|
|
333
|
+
} catch {
|
|
334
|
+
/* 忽略 */
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private fail(error: Error): void {
|
|
340
|
+
this.readyReject?.(error)
|
|
341
|
+
this.onErrorListeners?.(error)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private onErrorListeners: ((error: Error) => void) | null = null
|
|
345
|
+
|
|
346
|
+
/** 订阅「链路级错误」(区别于状态变化);返回取消订阅函数。 */
|
|
347
|
+
onError(listener: (error: Error) => void): () => void {
|
|
348
|
+
this.onErrorListeners = listener
|
|
349
|
+
return () => {
|
|
350
|
+
if (this.onErrorListeners === listener) this.onErrorListeners = null
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private setState(state: TransportState): void {
|
|
355
|
+
if (this._state === state) return
|
|
356
|
+
this._state = state
|
|
357
|
+
for (const listener of [...this.stateListeners]) listener(state)
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function defaultFactory(): (config: RTCConfiguration) => RTCPeerConnection {
|
|
362
|
+
return (config: RTCConfiguration) => {
|
|
363
|
+
const Ctor = (globalThis as { RTCPeerConnection?: new (c: RTCConfiguration) => RTCPeerConnection })
|
|
364
|
+
.RTCPeerConnection
|
|
365
|
+
if (!Ctor) {
|
|
366
|
+
throw new Error('当前环境没有 RTCPeerConnection,未注入 createPeerConnection')
|
|
367
|
+
}
|
|
368
|
+
return new Ctor(config)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function candidateToJson(candidate: RTCIceCandidate): RTCIceCandidateInit {
|
|
373
|
+
const withJson = candidate as RTCIceCandidate & { toJSON?: () => RTCIceCandidateInit }
|
|
374
|
+
return typeof withJson.toJSON === 'function' ? withJson.toJSON() : (candidate as unknown as RTCIceCandidateInit)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function toBytes(data: unknown): Uint8Array | null {
|
|
378
|
+
if (data instanceof Uint8Array) return data
|
|
379
|
+
if (data instanceof ArrayBuffer) return new Uint8Array(data)
|
|
380
|
+
return null
|
|
381
|
+
}
|