openrtc 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.
- package/README.md +106 -0
- package/dist/OpenRTC.d.ts +71 -0
- package/dist/OpenRTC.js +130 -0
- package/dist/api/MediaTransport.d.ts +7 -0
- package/dist/api/MediaTransport.js +262 -0
- package/dist/api/PlutoPeerConnection.d.ts +50 -0
- package/dist/api/PlutoPeerConnection.js +434 -0
- package/dist/api/PlutoWebSocket.d.ts +24 -0
- package/dist/api/PlutoWebSocket.js +113 -0
- package/dist/api/PlutoWebTransport.d.ts +29 -0
- package/dist/api/PlutoWebTransport.js +105 -0
- package/dist/auth/host.d.ts +73 -0
- package/dist/auth/host.js +513 -0
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/index.js +1 -0
- package/dist/auth/internal.d.ts +1 -0
- package/dist/auth/internal.js +1 -0
- package/dist/compat/index.d.ts +20 -0
- package/dist/compat/index.js +20 -0
- package/dist/core/Client.d.ts +352 -0
- package/dist/core/Client.js +3689 -0
- package/dist/core/Connection.d.ts +67 -0
- package/dist/core/Connection.js +404 -0
- package/dist/core/IEngineBridge.d.ts +18 -0
- package/dist/core/IEngineBridge.js +1 -0
- package/dist/core/ISignalingTransport.d.ts +13 -0
- package/dist/core/ISignalingTransport.js +1 -0
- package/dist/core/PeerLifecycleManager.d.ts +43 -0
- package/dist/core/PeerLifecycleManager.js +330 -0
- package/dist/core/PeerProjectionStore.d.ts +43 -0
- package/dist/core/PeerProjectionStore.js +327 -0
- package/dist/core/PlutoRTC.d.ts +13 -0
- package/dist/core/PlutoRTC.js +26 -0
- package/dist/core/Room.d.ts +47 -0
- package/dist/core/Room.js +376 -0
- package/dist/core/Signaling.d.ts +39 -0
- package/dist/core/Signaling.js +147 -0
- package/dist/core/bridges/BridgeCodecs.d.ts +19 -0
- package/dist/core/bridges/BridgeCodecs.js +317 -0
- package/dist/core/bridges/BrowserFirestoreRooms.d.ts +36 -0
- package/dist/core/bridges/BrowserFirestoreRooms.js +169 -0
- package/dist/core/bridges/BrowserFirestoreSignaling.d.ts +111 -0
- package/dist/core/bridges/BrowserFirestoreSignaling.js +1010 -0
- package/dist/core/bridges/NativeIpcBridge.d.ts +156 -0
- package/dist/core/bridges/NativeIpcBridge.js +1472 -0
- package/dist/core/bridges/RtdbPresence.d.ts +122 -0
- package/dist/core/bridges/RtdbPresence.js +201 -0
- package/dist/core/bridges/TauriBridge.d.ts +8 -0
- package/dist/core/bridges/TauriBridge.js +7 -0
- package/dist/core/bridges/WasmBridge.d.ts +233 -0
- package/dist/core/bridges/WasmBridge.js +1811 -0
- package/dist/core/config.d.ts +31 -0
- package/dist/core/config.js +138 -0
- package/dist/core/transports/ITransport.d.ts +12 -0
- package/dist/core/transports/ITransport.js +1 -0
- package/dist/core/transports/LocalDaemonBridge.d.ts +41 -0
- package/dist/core/transports/LocalDaemonBridge.js +227 -0
- package/dist/core/transports/TauriSignaling.d.ts +28 -0
- package/dist/core/transports/TauriSignaling.js +373 -0
- package/dist/core/transports/TransportFactory.d.ts +9 -0
- package/dist/core/transports/TransportFactory.js +59 -0
- package/dist/core/transports/WasmSignaling.d.ts +78 -0
- package/dist/core/transports/WasmSignaling.js +522 -0
- package/dist/core/transports/WebRTCTransport.d.ts +23 -0
- package/dist/core/transports/WebRTCTransport.js +132 -0
- package/dist/core/trust.d.ts +23 -0
- package/dist/core/trust.js +99 -0
- package/dist/core/types.d.ts +559 -0
- package/dist/core/types.js +19 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/moq/MoQMediaTransport.d.ts +6 -0
- package/dist/moq/MoQMediaTransport.js +106 -0
- package/dist/moq/MoQPublisher.d.ts +22 -0
- package/dist/moq/MoQPublisher.js +179 -0
- package/dist/moq/MoQSession.d.ts +35 -0
- package/dist/moq/MoQSession.js +166 -0
- package/dist/moq/MoQSubscriber.d.ts +19 -0
- package/dist/moq/MoQSubscriber.js +235 -0
- package/dist/moq/common.d.ts +47 -0
- package/dist/moq/common.js +304 -0
- package/dist/moq/messages.d.ts +105 -0
- package/dist/moq/messages.js +367 -0
- package/dist/presets/plutoHosted.d.ts +13 -0
- package/dist/presets/plutoHosted.js +18 -0
- package/dist/runtime/DelegatingRuntimeAdapter.d.ts +68 -0
- package/dist/runtime/DelegatingRuntimeAdapter.js +250 -0
- package/dist/runtime/IpcRuntimeAdapter.d.ts +9 -0
- package/dist/runtime/IpcRuntimeAdapter.js +13 -0
- package/dist/runtime/RuntimeClient.d.ts +257 -0
- package/dist/runtime/RuntimeClient.js +1484 -0
- package/dist/runtime/TauriRuntimeAdapter.d.ts +1 -0
- package/dist/runtime/TauriRuntimeAdapter.js +1 -0
- package/dist/runtime/WasmRuntimeAdapter.d.ts +6 -0
- package/dist/runtime/WasmRuntimeAdapter.js +11 -0
- package/dist/runtime/factories.d.ts +24 -0
- package/dist/runtime/factories.js +66 -0
- package/dist/runtime/index.d.ts +6 -0
- package/dist/runtime/index.js +5 -0
- package/dist/runtime/ipc.d.ts +8 -0
- package/dist/runtime/ipc.js +65 -0
- package/dist/runtime/ipcContract.d.ts +46 -0
- package/dist/runtime/ipcContract.js +1 -0
- package/dist/runtime/tauri.d.ts +16 -0
- package/dist/runtime/tauri.js +22 -0
- package/dist/runtime/types.d.ts +110 -0
- package/dist/runtime/types.js +1 -0
- package/dist/transports/TauriTransport.d.ts +17 -0
- package/dist/transports/TauriTransport.js +49 -0
- package/dist/transports/WebTransport.d.ts +23 -0
- package/dist/transports/WebTransport.js +71 -0
- package/dist/utils/debug.d.ts +4 -0
- package/dist/utils/debug.js +33 -0
- package/dist/utils/runtime.d.ts +1 -0
- package/dist/utils/runtime.js +10 -0
- package/package.json +97 -0
- package/wasm/README.md +64 -0
- package/wasm/openrtc.d.ts +284 -0
- package/wasm/openrtc.js +2132 -0
- package/wasm/openrtc_bg.wasm +0 -0
- package/wasm/openrtc_bg.wasm.d.ts +110 -0
- package/wasm/package.json +16 -0
|
@@ -0,0 +1,3689 @@
|
|
|
1
|
+
import { Connection } from './Connection';
|
|
2
|
+
import { Signaling } from './Signaling';
|
|
3
|
+
import { RoomManager } from './Room';
|
|
4
|
+
import { MoQSession } from '../moq/MoQSession';
|
|
5
|
+
import { TransportFactory } from './transports/TransportFactory';
|
|
6
|
+
import { PeerProjectionStore } from './PeerProjectionStore';
|
|
7
|
+
import { getAuthHost } from '../auth/host';
|
|
8
|
+
import { openRtcDebug, openRtcDebugInfo, openRtcDebugLog } from '../utils/debug';
|
|
9
|
+
import { createTransportTrustChallenge, getLocalTrustedIdentity, parseTrustedDeviceMetadata, signTransportTrustChallenge, verifyTransportTrustChallenge, } from './trust';
|
|
10
|
+
// Import the generated OpenRTC WASM package from ../../wasm/openrtc.js
|
|
11
|
+
// @ts-ignore
|
|
12
|
+
import initWasm, { WasmClient, start } from '../../wasm/openrtc.js';
|
|
13
|
+
function splitCompoundTicket(rawTicket) {
|
|
14
|
+
const dotIndex = rawTicket.lastIndexOf('.');
|
|
15
|
+
return {
|
|
16
|
+
irohTicket: dotIndex >= 0 ? rawTicket.slice(0, dotIndex) : rawTicket,
|
|
17
|
+
tokenSuffix: dotIndex >= 0 ? rawTicket.slice(dotIndex + 1) : null,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function decodeCompoundTicketPayload(tokenSuffix) {
|
|
21
|
+
try {
|
|
22
|
+
const padded = tokenSuffix.replace(/-/g, '+').replace(/_/g, '/');
|
|
23
|
+
const paddingLen = (4 - (padded.length % 4)) % 4;
|
|
24
|
+
return JSON.parse(atob(padded + '='.repeat(paddingLen)));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// Ensure WebRTC and other configs exist
|
|
31
|
+
import { DEFAULT_FREE_LIMITS, DEFAULT_WEBRTC_CONFIG, DEFAULT_MOQ_CONFIG, DEFAULT_ICE_SERVERS, } from './types';
|
|
32
|
+
import { deriveSpaceNamespace, resolveAppTag, resolveAuthMode, resolveProjectId, resolveStorageNamespace } from './config';
|
|
33
|
+
function withTimeout(promise, timeoutMs) {
|
|
34
|
+
return Promise.race([
|
|
35
|
+
promise,
|
|
36
|
+
new Promise((_, reject) => {
|
|
37
|
+
setTimeout(() => reject(new Error(`timeout after ${timeoutMs}ms`)), timeoutMs);
|
|
38
|
+
}),
|
|
39
|
+
]);
|
|
40
|
+
}
|
|
41
|
+
async function pollUntilReady(check, attempts, intervalMs) {
|
|
42
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
43
|
+
const ready = await check().catch(() => false);
|
|
44
|
+
if (ready) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
if (attempt < attempts - 1) {
|
|
48
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
class SharedIncomingStreamHub {
|
|
54
|
+
constructor(wasmClient) {
|
|
55
|
+
this.wasmClient = wasmClient;
|
|
56
|
+
this.clients = new Set();
|
|
57
|
+
this.listenLoopTask = null;
|
|
58
|
+
}
|
|
59
|
+
addClient(client) {
|
|
60
|
+
if (this.clients.has(client)) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
this.clients.add(client);
|
|
64
|
+
this.ensureListening();
|
|
65
|
+
}
|
|
66
|
+
removeClient(client) {
|
|
67
|
+
this.clients.delete(client);
|
|
68
|
+
}
|
|
69
|
+
ensureListening() {
|
|
70
|
+
if (this.listenLoopTask) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
this.listenLoopTask = this.listenLoop().finally(() => {
|
|
74
|
+
this.listenLoopTask = null;
|
|
75
|
+
if (this.clients.size > 0) {
|
|
76
|
+
this.ensureListening();
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
nextRetryDelayMs(attempt) {
|
|
81
|
+
const backoff = SharedIncomingStreamHub.RETRY_MIN_DELAY_MS * Math.pow(2, Math.max(0, attempt - 1));
|
|
82
|
+
return Math.min(SharedIncomingStreamHub.RETRY_MAX_DELAY_MS, backoff);
|
|
83
|
+
}
|
|
84
|
+
getPrimaryClient() {
|
|
85
|
+
const iterator = this.clients.values().next();
|
|
86
|
+
return iterator.done ? null : iterator.value;
|
|
87
|
+
}
|
|
88
|
+
getChannelTargetClients(channelId) {
|
|
89
|
+
return Array.from(this.clients)
|
|
90
|
+
.reverse()
|
|
91
|
+
.filter((client) => client.hasIncomingChannelListener(channelId));
|
|
92
|
+
}
|
|
93
|
+
async dispatchIncoming(rawIncoming) {
|
|
94
|
+
const parserClient = this.getPrimaryClient();
|
|
95
|
+
if (!parserClient) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const inspectChannelEnvelope = Array.from(this.clients).some((client) => Boolean(client.hasAnyIncomingChannelListeners()));
|
|
99
|
+
const listenerEvent = await parserClient.normalizeIncomingStreamEvent(rawIncoming, {
|
|
100
|
+
inspectChannelEnvelope,
|
|
101
|
+
});
|
|
102
|
+
const channelId = listenerEvent.channel?.channelId?.trim();
|
|
103
|
+
if (channelId) {
|
|
104
|
+
for (const client of this.getChannelTargetClients(channelId)) {
|
|
105
|
+
const handled = Boolean(client.dispatchIncomingChannelListeners(listenerEvent));
|
|
106
|
+
if (handled) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const primaryClient = this.getPrimaryClient();
|
|
112
|
+
if (!primaryClient) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const handledByGenericListeners = Boolean(primaryClient.dispatchIncomingGenericListeners(listenerEvent));
|
|
116
|
+
if (handledByGenericListeners) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
await primaryClient.handleUnhandledIncomingStreamEvent(listenerEvent);
|
|
120
|
+
}
|
|
121
|
+
async listenLoop() {
|
|
122
|
+
let attempt = 0;
|
|
123
|
+
while (true) {
|
|
124
|
+
if (this.clients.size === 0) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
let reader = null;
|
|
128
|
+
try {
|
|
129
|
+
const streams = await this.wasmClient.incoming_streams();
|
|
130
|
+
const activeReader = streams.getReader();
|
|
131
|
+
reader = activeReader;
|
|
132
|
+
attempt = 0;
|
|
133
|
+
while (this.clients.size > 0) {
|
|
134
|
+
const { done, value } = await activeReader.read();
|
|
135
|
+
if (done) {
|
|
136
|
+
throw new Error('incoming stream reader closed unexpectedly');
|
|
137
|
+
}
|
|
138
|
+
if (!value) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
console.debug('[Client][INCOMING] Received stream from WASM node', {
|
|
142
|
+
type: value?.type || 'unknown',
|
|
143
|
+
endpointId: value?.endpointId || value?.endpoint_id || 'unknown',
|
|
144
|
+
sharedHubClients: this.clients.size,
|
|
145
|
+
});
|
|
146
|
+
try {
|
|
147
|
+
await this.dispatchIncoming(value);
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
const incoming = value;
|
|
151
|
+
const streamType = typeof incoming.type === 'string' ? incoming.type : 'bi';
|
|
152
|
+
const remoteNodeId = typeof incoming.endpointId === 'string'
|
|
153
|
+
? incoming.endpointId
|
|
154
|
+
: typeof incoming.endpoint_id === 'string'
|
|
155
|
+
? incoming.endpoint_id
|
|
156
|
+
: 'unknown';
|
|
157
|
+
console.error('[Client][INCOMING] Failed to handle incoming stream via shared hub', {
|
|
158
|
+
remoteNodeId,
|
|
159
|
+
type: streamType,
|
|
160
|
+
error,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
if (this.clients.size === 0) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
attempt += 1;
|
|
170
|
+
const delayMs = this.nextRetryDelayMs(attempt);
|
|
171
|
+
console.warn('[Client][INCOMING] Shared listen loop restarting after stream failure', {
|
|
172
|
+
attempt,
|
|
173
|
+
delayMs,
|
|
174
|
+
error,
|
|
175
|
+
});
|
|
176
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
if (reader) {
|
|
180
|
+
try {
|
|
181
|
+
reader.releaseLock();
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// no-op
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
SharedIncomingStreamHub.RETRY_MIN_DELAY_MS = 250;
|
|
192
|
+
SharedIncomingStreamHub.RETRY_MAX_DELAY_MS = 2000;
|
|
193
|
+
export class Client {
|
|
194
|
+
constructor(options) {
|
|
195
|
+
this.wasmLoaded = false;
|
|
196
|
+
this.wasmClient = null;
|
|
197
|
+
this.initPromise = null;
|
|
198
|
+
// node is kept as a dummy for legacy compat if needed, but pointed to null
|
|
199
|
+
this.node = null;
|
|
200
|
+
this.connections = new Map();
|
|
201
|
+
this.connectionListeners = [];
|
|
202
|
+
this.disconnectionListeners = [];
|
|
203
|
+
this.messageListeners = [];
|
|
204
|
+
this.roomRequestListeners = [];
|
|
205
|
+
this.streamListeners = [];
|
|
206
|
+
this.channelStreamListeners = new Map();
|
|
207
|
+
this.registeredChannels = new Map();
|
|
208
|
+
this.announcedConnections = new Set();
|
|
209
|
+
this.peerLifecycle = new PeerProjectionStore();
|
|
210
|
+
this.knownDevicesById = new Map();
|
|
211
|
+
this.knownDeviceIdsByNodeId = new Map();
|
|
212
|
+
this.connectionTrustStates = new Map();
|
|
213
|
+
this.connectionChannelIds = new Map();
|
|
214
|
+
this.managedPresenceGrant = null;
|
|
215
|
+
/** Token extracted from a compound ticket during connect(), keyed by remote node ID. */
|
|
216
|
+
this.pendingTokenByNodeId = new Map();
|
|
217
|
+
this.localNodeId = '';
|
|
218
|
+
this.listening = false;
|
|
219
|
+
this.listenLoopTask = null;
|
|
220
|
+
this.appLimits = { ...DEFAULT_FREE_LIMITS };
|
|
221
|
+
// Prevent GC
|
|
222
|
+
this.pendingConnection = null;
|
|
223
|
+
this.authSyncUnsubscribe = null;
|
|
224
|
+
this.backendConnectionStateUnsubscribe = null;
|
|
225
|
+
this.authRefreshInterval = null;
|
|
226
|
+
this.deviceId = null;
|
|
227
|
+
this.lastWasmAuthSyncUserId = null;
|
|
228
|
+
this.lastWasmAuthSyncToken = null;
|
|
229
|
+
// Presence loop
|
|
230
|
+
this.presenceInterval = null;
|
|
231
|
+
this.hostedAppSettingsLoaded = false;
|
|
232
|
+
// --- Shared MoQ Session ---
|
|
233
|
+
this._moqSession = null;
|
|
234
|
+
this._moqState = 'none';
|
|
235
|
+
this.moqReadyListeners = [];
|
|
236
|
+
this.moqFailedListeners = [];
|
|
237
|
+
this.moqObjectListeners = [];
|
|
238
|
+
this.roomCreationMode = 'client-open';
|
|
239
|
+
const appTag = resolveAppTag(options);
|
|
240
|
+
const authMode = resolveAuthMode(options);
|
|
241
|
+
this.options = {
|
|
242
|
+
strictMode: true,
|
|
243
|
+
disableIrohFallback: false,
|
|
244
|
+
transportPriority: ['webrtc', 'moq', 'iroh'],
|
|
245
|
+
...options,
|
|
246
|
+
authMode,
|
|
247
|
+
};
|
|
248
|
+
this.effectiveRoomBackendTag = appTag;
|
|
249
|
+
this.roomCreationMode = (this.options.discoveryMode ?? 'personal') === 'personal'
|
|
250
|
+
? 'client-open'
|
|
251
|
+
: 'client-auth';
|
|
252
|
+
// Normalizing Transport Configs
|
|
253
|
+
if (this.options.transports) {
|
|
254
|
+
// ... (transport normalization logic remains same)
|
|
255
|
+
// WebRTC Logic
|
|
256
|
+
if (this.options.transports.webrtc === true) {
|
|
257
|
+
this.options.transports.webrtc = { ...DEFAULT_WEBRTC_CONFIG };
|
|
258
|
+
}
|
|
259
|
+
else if (this.options.transports.webrtc === false) {
|
|
260
|
+
this.options.transports.webrtc = undefined;
|
|
261
|
+
}
|
|
262
|
+
// MoQ Logic
|
|
263
|
+
if (this.options.transports.moq === true) {
|
|
264
|
+
this.options.transports.moq = { ...DEFAULT_MOQ_CONFIG };
|
|
265
|
+
}
|
|
266
|
+
else if (this.options.transports.moq === false) {
|
|
267
|
+
this.options.transports.moq = undefined;
|
|
268
|
+
}
|
|
269
|
+
// Iroh Logic
|
|
270
|
+
if (this.options.transports.iroh === true) {
|
|
271
|
+
this.options.transports.iroh = {};
|
|
272
|
+
}
|
|
273
|
+
else if (this.options.transports.iroh === false) {
|
|
274
|
+
this.options.transports.iroh = undefined;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const irohTransport = this.options.transports?.iroh;
|
|
278
|
+
const configuredPersistenceMode = this.options.nodeIdPersistence ??
|
|
279
|
+
(typeof irohTransport === 'object' ? irohTransport.persistenceMode : undefined) ??
|
|
280
|
+
'persistent';
|
|
281
|
+
// Initialize TS wrappers that will use the loaded WASM underneath
|
|
282
|
+
const sigOptions = {
|
|
283
|
+
apiKey: this.options.apiKey,
|
|
284
|
+
allowAnonymousHostedDefaults: this.options.allowAnonymousHostedDefaults,
|
|
285
|
+
authMode: this.options.authMode,
|
|
286
|
+
projectId: this.options.projectId,
|
|
287
|
+
storagePrefix: this.options.storagePrefix,
|
|
288
|
+
nodeIdPersistence: configuredPersistenceMode,
|
|
289
|
+
};
|
|
290
|
+
let backend = null;
|
|
291
|
+
// Placeholder backend until the host app injects a concrete runtime bridge.
|
|
292
|
+
const proxyBackend = new Proxy({}, {
|
|
293
|
+
get(target, prop) {
|
|
294
|
+
if (backend)
|
|
295
|
+
return backend[prop];
|
|
296
|
+
return undefined;
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
this.signaling = new Signaling(sigOptions, proxyBackend);
|
|
300
|
+
this.rooms = new RoomManager(this, this.signaling);
|
|
301
|
+
this.registerChannel({
|
|
302
|
+
id: Client.DEFAULT_PEER_CHANNEL_ID,
|
|
303
|
+
kind: 'system',
|
|
304
|
+
ownership: 'shared',
|
|
305
|
+
peerModel: 'device-first',
|
|
306
|
+
routing: 'session-default',
|
|
307
|
+
readiness: 'settled-peer',
|
|
308
|
+
promotionPolicy: 'eligible',
|
|
309
|
+
signalingPolicy: 'hosted',
|
|
310
|
+
description: 'Default trusted device/runtime traffic',
|
|
311
|
+
});
|
|
312
|
+
this.registerChannel({
|
|
313
|
+
id: Client.SHARE_FILE_CHANNEL_ID,
|
|
314
|
+
kind: 'share',
|
|
315
|
+
ownership: 'shared',
|
|
316
|
+
peerModel: 'anonymous-ticket',
|
|
317
|
+
routing: 'stream-envelope',
|
|
318
|
+
readiness: 'transport-only',
|
|
319
|
+
promotionPolicy: 'never',
|
|
320
|
+
signalingPolicy: 'ticket-only',
|
|
321
|
+
description: 'Explicit file transfer channel for share/ticket traffic',
|
|
322
|
+
});
|
|
323
|
+
this.registerChannel({
|
|
324
|
+
id: Client.SHARE_CONTROL_CHANNEL_ID,
|
|
325
|
+
kind: 'share',
|
|
326
|
+
ownership: 'shared',
|
|
327
|
+
peerModel: 'anonymous-ticket',
|
|
328
|
+
routing: 'stream-envelope',
|
|
329
|
+
readiness: 'transport-only',
|
|
330
|
+
promotionPolicy: 'never',
|
|
331
|
+
signalingPolicy: 'ticket-only',
|
|
332
|
+
description: 'Explicit control channel for share/ticket traffic',
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
sleep(ms) {
|
|
336
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
337
|
+
}
|
|
338
|
+
get apiKey() {
|
|
339
|
+
return this.options.apiKey;
|
|
340
|
+
}
|
|
341
|
+
get discoveryMode() {
|
|
342
|
+
return this.options.discoveryMode ?? 'personal';
|
|
343
|
+
}
|
|
344
|
+
usesTicketOnlySignalingMode() {
|
|
345
|
+
return this.options.signalingMode === 'ticket-only';
|
|
346
|
+
}
|
|
347
|
+
usesDirectRoomBackend() {
|
|
348
|
+
return this.effectiveRoomBackendTag.startsWith('space::')
|
|
349
|
+
|| ((this.options.discoveryMode === undefined || this.options.discoveryMode === 'personal')
|
|
350
|
+
&& typeof this.options.spaceKey === 'string'
|
|
351
|
+
&& this.options.spaceKey.trim().length > 0);
|
|
352
|
+
}
|
|
353
|
+
getRoomBackendTag() {
|
|
354
|
+
return this.effectiveRoomBackendTag;
|
|
355
|
+
}
|
|
356
|
+
async refreshHostedAppSettings(force = false) {
|
|
357
|
+
if (this.usesTicketOnlySignalingMode()) {
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (this.hostedAppSettingsLoaded && !force) {
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
try {
|
|
364
|
+
const { getFunctions, httpsCallable } = await import('firebase/functions');
|
|
365
|
+
const functions = getFunctions(getAuthHost().getApp());
|
|
366
|
+
const validate = httpsCallable(functions, 'validateApiKey');
|
|
367
|
+
const result = await validate({ apiKey: this.apiKey });
|
|
368
|
+
const data = result.data;
|
|
369
|
+
if (data.warning) {
|
|
370
|
+
console.warn('[pluto-rtc]', data.warning);
|
|
371
|
+
}
|
|
372
|
+
this.appLimits = data.limits;
|
|
373
|
+
if (data.discoveryMode) {
|
|
374
|
+
this.options.discoveryMode = data.discoveryMode;
|
|
375
|
+
}
|
|
376
|
+
this.roomCreationMode = data.roomCreationMode
|
|
377
|
+
?? ((this.options.discoveryMode ?? 'personal') === 'personal' ? 'client-open' : 'client-auth');
|
|
378
|
+
this.hostedAppSettingsLoaded = true;
|
|
379
|
+
}
|
|
380
|
+
catch (e) {
|
|
381
|
+
console.warn('[pluto-rtc] Could not fetch hosted app settings, using local defaults.', e);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// --- Dependency Injection Point ---
|
|
385
|
+
// The wrapper (PlutoRTC) or the main app will call this upon instantiation
|
|
386
|
+
// to provide the correctly environment-gated backend implementation.
|
|
387
|
+
setSignalingBackend(backend) {
|
|
388
|
+
// Here we can swap the proxy out or reset the signaling instance
|
|
389
|
+
this.backendConnectionStateUnsubscribe?.();
|
|
390
|
+
this.backendConnectionStateUnsubscribe = null;
|
|
391
|
+
this.signaling = new Signaling(this.options, backend);
|
|
392
|
+
this.rooms = new RoomManager(this, this.signaling);
|
|
393
|
+
// Check for SSO token after the real backend is injected
|
|
394
|
+
backend.checkForSSOToken();
|
|
395
|
+
openRtcDebug('[Client] subscribing to backend connection state changes', {
|
|
396
|
+
backendType: backend?.constructor?.name ?? 'unknown',
|
|
397
|
+
hasGetConnectionStates: typeof backend.getConnectionStates === 'function',
|
|
398
|
+
hasOnConnectionStateChange: typeof backend.onConnectionStateChange === 'function',
|
|
399
|
+
});
|
|
400
|
+
const maybeUnsubscribe = this.signaling.onConnectionStateChange((state) => {
|
|
401
|
+
this.applyBackendConnectionState(state);
|
|
402
|
+
});
|
|
403
|
+
if (typeof maybeUnsubscribe === 'function') {
|
|
404
|
+
this.backendConnectionStateUnsubscribe = maybeUnsubscribe;
|
|
405
|
+
openRtcDebug('[Client] backend connection state subscription established synchronously');
|
|
406
|
+
}
|
|
407
|
+
else if (maybeUnsubscribe && typeof maybeUnsubscribe.then === 'function') {
|
|
408
|
+
void maybeUnsubscribe
|
|
409
|
+
.then((unsubscribe) => {
|
|
410
|
+
this.backendConnectionStateUnsubscribe = unsubscribe;
|
|
411
|
+
openRtcDebug('[Client] backend connection state subscription established asynchronously');
|
|
412
|
+
})
|
|
413
|
+
.catch((error) => {
|
|
414
|
+
console.warn('[Client] Failed to subscribe to backend connection state changes:', error);
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
// --- Helpers ---
|
|
419
|
+
get currentUser() {
|
|
420
|
+
return this.signaling.currentUser;
|
|
421
|
+
}
|
|
422
|
+
onAuthChange(callback) {
|
|
423
|
+
return this.signaling.onAuthChange(callback);
|
|
424
|
+
}
|
|
425
|
+
async signInAnonymously() {
|
|
426
|
+
return this.signaling.signInAnonymously();
|
|
427
|
+
}
|
|
428
|
+
async signInWithPluto() {
|
|
429
|
+
return this.signaling.signInWithPluto();
|
|
430
|
+
}
|
|
431
|
+
async signOut() {
|
|
432
|
+
this.listening = false;
|
|
433
|
+
await this.signaling.stopAuthScopedActivity();
|
|
434
|
+
return this.signaling.signOut();
|
|
435
|
+
}
|
|
436
|
+
getSignalingTelemetry() {
|
|
437
|
+
return {
|
|
438
|
+
transport: 'runtime-bridge',
|
|
439
|
+
wasmLoaded: this.wasmLoaded,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
getProtocolCapabilities() {
|
|
443
|
+
return TransportFactory.getProtocolCapabilities();
|
|
444
|
+
}
|
|
445
|
+
async getRuntimeStatus() {
|
|
446
|
+
let runtime = 'unknown';
|
|
447
|
+
if (typeof window !== 'undefined') {
|
|
448
|
+
runtime = window.__TAURI__ ? 'tauri' : 'browser';
|
|
449
|
+
}
|
|
450
|
+
return {
|
|
451
|
+
runtime,
|
|
452
|
+
wasmLoaded: this.wasmLoaded,
|
|
453
|
+
localNodeId: this.localNodeId || undefined,
|
|
454
|
+
userId: this.signaling.currentUser?.id,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
async ensureWasmRuntimeForP2P() {
|
|
458
|
+
if (this.wasmClient && this.localNodeId) {
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
await this.init();
|
|
462
|
+
if (!this.wasmClient || !this.localNodeId) {
|
|
463
|
+
throw new Error('Node not initialized');
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
async ensureSignalingReadyForDiscovery(reason) {
|
|
467
|
+
if (this.usesTicketOnlySignalingMode()) {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (!this.wasmLoaded) {
|
|
471
|
+
await this.init();
|
|
472
|
+
}
|
|
473
|
+
try {
|
|
474
|
+
await this.ensureAuthenticated('discovery');
|
|
475
|
+
}
|
|
476
|
+
catch (error) {
|
|
477
|
+
console.warn(`[Client][AUTH] ensureAuthenticated failed for ${reason}:`, error);
|
|
478
|
+
}
|
|
479
|
+
try {
|
|
480
|
+
if (this.wasmClient) {
|
|
481
|
+
await this.syncAuthTokenToWasm('auth-change');
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
catch (error) {
|
|
485
|
+
console.warn(`[Client][WASM-AUTH] Token sync failed for ${reason}:`, error);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
requiresManagedTransportTrust() {
|
|
489
|
+
return !this.usesTicketOnlySignalingMode()
|
|
490
|
+
&& this.discoveryMode !== 'personal'
|
|
491
|
+
&& !!this.currentUser?.id;
|
|
492
|
+
}
|
|
493
|
+
getRegisteredChannel(channelId) {
|
|
494
|
+
const normalized = typeof channelId === 'string' ? channelId.trim() : '';
|
|
495
|
+
if (!normalized) {
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
return this.registeredChannels.get(normalized) ?? null;
|
|
499
|
+
}
|
|
500
|
+
isChannelPromotionEligible(channelId) {
|
|
501
|
+
const descriptor = this.getRegisteredChannel(channelId);
|
|
502
|
+
if (!descriptor) {
|
|
503
|
+
return true;
|
|
504
|
+
}
|
|
505
|
+
return descriptor.promotionPolicy !== 'never';
|
|
506
|
+
}
|
|
507
|
+
getConnectionChannelId(connectionId) {
|
|
508
|
+
const normalized = typeof connectionId === 'string' ? connectionId.trim() : '';
|
|
509
|
+
if (!normalized) {
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
return this.connectionChannelIds.get(normalized) ?? null;
|
|
513
|
+
}
|
|
514
|
+
resolvePromotionEligibility(identifiers) {
|
|
515
|
+
const directChannelId = this.getConnectionChannelId(identifiers.connectionId);
|
|
516
|
+
if (directChannelId) {
|
|
517
|
+
return this.isChannelPromotionEligible(directChannelId);
|
|
518
|
+
}
|
|
519
|
+
const normalizedNodeId = typeof identifiers.nodeId === 'string' ? identifiers.nodeId.trim() : '';
|
|
520
|
+
for (const connection of this.connections.values()) {
|
|
521
|
+
if (identifiers.connectionId && connection.id === identifiers.connectionId) {
|
|
522
|
+
return this.isChannelPromotionEligible(this.getConnectionChannelId(connection.id));
|
|
523
|
+
}
|
|
524
|
+
if (normalizedNodeId && connection.remoteNodeId === normalizedNodeId) {
|
|
525
|
+
return this.isChannelPromotionEligible(this.getConnectionChannelId(connection.id));
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return true;
|
|
529
|
+
}
|
|
530
|
+
assignConnectionChannel(connection, channelId) {
|
|
531
|
+
const normalizedChannelId = typeof channelId === 'string' ? channelId.trim() : '';
|
|
532
|
+
if (!normalizedChannelId) {
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
this.connectionChannelIds.set(connection.id, normalizedChannelId);
|
|
536
|
+
const trustState = this.connectionTrustStates.get(connection.id);
|
|
537
|
+
if (trustState && !this.isChannelPromotionEligible(normalizedChannelId)) {
|
|
538
|
+
trustState.required = false;
|
|
539
|
+
trustState.verified = true;
|
|
540
|
+
trustState.failed = false;
|
|
541
|
+
trustState.failureReason = undefined;
|
|
542
|
+
trustState.issuedChallenge = null;
|
|
543
|
+
if (!trustState.verifiedDeviceId) {
|
|
544
|
+
trustState.verifiedDeviceId =
|
|
545
|
+
trustState.expectedDeviceId
|
|
546
|
+
|| this.knownDeviceIdsByNodeId.get(connection.remoteNodeId)
|
|
547
|
+
|| null;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (!this.hasRustPeerLifecycleProjection()) {
|
|
551
|
+
const existing = this.connections.get(connection.id);
|
|
552
|
+
if (existing) {
|
|
553
|
+
this.applyConnectionTrustProjection(existing);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
void this.refreshPeerSnapshot().catch(() => { });
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
rememberKnownDevice(device) {
|
|
561
|
+
if (!device?.deviceId) {
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
this.knownDevicesById.set(device.deviceId, device);
|
|
565
|
+
if (typeof device.nodeId === 'string' && device.nodeId.trim()) {
|
|
566
|
+
this.knownDeviceIdsByNodeId.set(device.nodeId.trim(), device.deviceId);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
getKnownDevice(deviceId, remoteNodeId) {
|
|
570
|
+
const directDeviceId = typeof deviceId === 'string' ? deviceId.trim() : '';
|
|
571
|
+
if (directDeviceId) {
|
|
572
|
+
const direct = this.knownDevicesById.get(directDeviceId);
|
|
573
|
+
if (direct) {
|
|
574
|
+
return direct;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
const normalizedNodeId = typeof remoteNodeId === 'string' ? remoteNodeId.trim() : '';
|
|
578
|
+
if (normalizedNodeId) {
|
|
579
|
+
const mappedDeviceId = this.knownDeviceIdsByNodeId.get(normalizedNodeId);
|
|
580
|
+
if (mappedDeviceId) {
|
|
581
|
+
return this.knownDevicesById.get(mappedDeviceId) ?? null;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
async refreshKnownDeviceCache() {
|
|
587
|
+
if (!this.requiresManagedTransportTrust()) {
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
const devices = await this.searchDevices().catch(() => []);
|
|
591
|
+
devices.forEach((device) => this.rememberKnownDevice(device));
|
|
592
|
+
}
|
|
593
|
+
initializeConnectionTrust(connection, expectedDeviceId) {
|
|
594
|
+
const existing = this.connectionTrustStates.get(connection.id);
|
|
595
|
+
if (existing) {
|
|
596
|
+
if (expectedDeviceId && !existing.expectedDeviceId) {
|
|
597
|
+
existing.expectedDeviceId = expectedDeviceId;
|
|
598
|
+
}
|
|
599
|
+
return existing;
|
|
600
|
+
}
|
|
601
|
+
const promotionEligible = this.resolvePromotionEligibility({
|
|
602
|
+
connectionId: connection.id,
|
|
603
|
+
nodeId: connection.remoteNodeId,
|
|
604
|
+
deviceId: expectedDeviceId ?? undefined,
|
|
605
|
+
});
|
|
606
|
+
const required = promotionEligible && this.requiresManagedTransportTrust();
|
|
607
|
+
const initialState = {
|
|
608
|
+
required,
|
|
609
|
+
verified: !required,
|
|
610
|
+
failed: false,
|
|
611
|
+
expectedDeviceId: expectedDeviceId
|
|
612
|
+
|| this.knownDeviceIdsByNodeId.get(connection.remoteNodeId)
|
|
613
|
+
|| null,
|
|
614
|
+
verifiedDeviceId: !required
|
|
615
|
+
? expectedDeviceId || this.knownDeviceIdsByNodeId.get(connection.remoteNodeId) || null
|
|
616
|
+
: null,
|
|
617
|
+
issuedChallenge: required ? createTransportTrustChallenge() : null,
|
|
618
|
+
};
|
|
619
|
+
this.connectionTrustStates.set(connection.id, initialState);
|
|
620
|
+
return initialState;
|
|
621
|
+
}
|
|
622
|
+
getConnectionTrustState(identifiers) {
|
|
623
|
+
const directConnectionId = typeof identifiers.connectionId === 'string' ? identifiers.connectionId.trim() : '';
|
|
624
|
+
if (directConnectionId) {
|
|
625
|
+
return this.connectionTrustStates.get(directConnectionId) ?? null;
|
|
626
|
+
}
|
|
627
|
+
for (const [connectionId, state] of this.connectionTrustStates.entries()) {
|
|
628
|
+
if ((state.verifiedDeviceId
|
|
629
|
+
&& (state.verifiedDeviceId === identifiers.deviceId
|
|
630
|
+
|| state.verifiedDeviceId === identifiers.deviceIdHint))
|
|
631
|
+
|| (state.expectedDeviceId
|
|
632
|
+
&& (state.expectedDeviceId === identifiers.deviceId
|
|
633
|
+
|| state.expectedDeviceId === identifiers.deviceIdHint))) {
|
|
634
|
+
return this.connectionTrustStates.get(connectionId) ?? null;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return null;
|
|
638
|
+
}
|
|
639
|
+
getConnectionTrustProjection(identifiers) {
|
|
640
|
+
const state = this.getConnectionTrustState(identifiers);
|
|
641
|
+
if (!state || !state.required) {
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
const verified = state.verified && !state.failed;
|
|
645
|
+
if (verified) {
|
|
646
|
+
return {
|
|
647
|
+
routable: true,
|
|
648
|
+
protocolState: 'routable',
|
|
649
|
+
health: 'healthy',
|
|
650
|
+
deviceId: state.verifiedDeviceId ?? state.expectedDeviceId ?? identifiers.deviceId ?? null,
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
return {
|
|
654
|
+
routable: false,
|
|
655
|
+
protocolState: 'unverified',
|
|
656
|
+
health: state.failed ? 'stale' : 'unknown',
|
|
657
|
+
error: state.failureReason ?? 'trusted-device-proof-pending',
|
|
658
|
+
deviceId: state.expectedDeviceId ?? identifiers.deviceId ?? identifiers.deviceIdHint ?? null,
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
applyConnectionTrustProjection(connection) {
|
|
662
|
+
if (this.hasRustPeerLifecycleProjection()) {
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
const promotionEligible = this.resolvePromotionEligibility({
|
|
666
|
+
connectionId: connection.id,
|
|
667
|
+
nodeId: connection.remoteNodeId,
|
|
668
|
+
});
|
|
669
|
+
const trustProjection = this.getConnectionTrustProjection({
|
|
670
|
+
connectionId: connection.id,
|
|
671
|
+
nodeId: connection.remoteNodeId,
|
|
672
|
+
});
|
|
673
|
+
if (!trustProjection) {
|
|
674
|
+
this.syncConnectionPeerLifecycle(connection);
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
this.peerLifecycle.upsert({
|
|
678
|
+
peerId: trustProjection.deviceId ?? undefined,
|
|
679
|
+
deviceId: trustProjection.deviceId ?? undefined,
|
|
680
|
+
deviceIdHint: trustProjection.deviceId ?? undefined,
|
|
681
|
+
nodeId: connection.deviceId,
|
|
682
|
+
connectionId: connection.id,
|
|
683
|
+
status: connection.isClosed ? 'closed' : 'connected',
|
|
684
|
+
health: trustProjection.health,
|
|
685
|
+
transportState: connection.isClosed ? 'closed' : 'connected',
|
|
686
|
+
protocolState: trustProjection.protocolState,
|
|
687
|
+
routable: trustProjection.routable,
|
|
688
|
+
promotionEligible,
|
|
689
|
+
error: trustProjection.error,
|
|
690
|
+
lastSeenAt: Date.now(),
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
async buildLocalTransportTrustPayload(connection) {
|
|
694
|
+
const identity = await getLocalTrustedIdentity().catch(() => null);
|
|
695
|
+
const localDeviceId = (await this.getLocalDeviceId().catch(() => null))
|
|
696
|
+
|| this.deviceId
|
|
697
|
+
|| this.options.localDeviceId
|
|
698
|
+
|| null;
|
|
699
|
+
if (!identity || !localDeviceId) {
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
const trustState = this.initializeConnectionTrust(connection);
|
|
703
|
+
return {
|
|
704
|
+
version: 1,
|
|
705
|
+
deviceId: localDeviceId,
|
|
706
|
+
identityFingerprint: identity.fingerprint,
|
|
707
|
+
challenge: trustState.issuedChallenge ?? undefined,
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
async sendTransportHandshake(connection, action, challengeToSign) {
|
|
711
|
+
// Attach session token on the outgoing 'hello' if we have one for this peer.
|
|
712
|
+
const sessionToken = action === 'hello'
|
|
713
|
+
? (this.pendingTokenByNodeId.get(connection.remoteNodeId) ?? undefined)
|
|
714
|
+
: undefined;
|
|
715
|
+
if (sessionToken) {
|
|
716
|
+
// Token has been picked up — remove from pending map.
|
|
717
|
+
this.pendingTokenByNodeId.delete(connection.remoteNodeId);
|
|
718
|
+
}
|
|
719
|
+
const localPayload = await this.buildLocalTransportTrustPayload(connection);
|
|
720
|
+
if (!localPayload) {
|
|
721
|
+
if (this.requiresManagedTransportTrust()) {
|
|
722
|
+
const state = this.initializeConnectionTrust(connection);
|
|
723
|
+
state.failed = true;
|
|
724
|
+
state.failureReason = 'local-trusted-identity-unavailable';
|
|
725
|
+
}
|
|
726
|
+
await connection.send({
|
|
727
|
+
type: 'handshake',
|
|
728
|
+
action,
|
|
729
|
+
moq: this.moqState === 'ready',
|
|
730
|
+
channelId: this.getConnectionChannelId(connection.id) ?? undefined,
|
|
731
|
+
...(sessionToken ? { sessionToken } : {}),
|
|
732
|
+
});
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
if (challengeToSign) {
|
|
736
|
+
const identity = await getLocalTrustedIdentity().catch(() => null);
|
|
737
|
+
if (!identity) {
|
|
738
|
+
throw new Error('Local trusted identity unavailable for handshake proof');
|
|
739
|
+
}
|
|
740
|
+
localPayload.signedChallenge = signTransportTrustChallenge(challengeToSign, identity.secretKey);
|
|
741
|
+
}
|
|
742
|
+
await connection.send({
|
|
743
|
+
type: 'handshake',
|
|
744
|
+
action,
|
|
745
|
+
moq: this.moqState === 'ready',
|
|
746
|
+
channelId: this.getConnectionChannelId(connection.id) ?? undefined,
|
|
747
|
+
transportTrust: localPayload,
|
|
748
|
+
...(sessionToken ? { sessionToken } : {}),
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
async verifyRemoteTransportTrust(connection, payload) {
|
|
752
|
+
const trustState = this.initializeConnectionTrust(connection);
|
|
753
|
+
if (!trustState.required) {
|
|
754
|
+
return {
|
|
755
|
+
verified: true,
|
|
756
|
+
deviceId: payload?.deviceId ?? trustState.expectedDeviceId ?? null,
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
if (!payload) {
|
|
760
|
+
return { verified: false, reason: 'missing-transport-trust-payload' };
|
|
761
|
+
}
|
|
762
|
+
const claimedDeviceId = typeof payload.deviceId === 'string' ? payload.deviceId.trim() : '';
|
|
763
|
+
if (!claimedDeviceId) {
|
|
764
|
+
return { verified: false, reason: 'missing-remote-device-id' };
|
|
765
|
+
}
|
|
766
|
+
if (trustState.expectedDeviceId && trustState.expectedDeviceId !== claimedDeviceId) {
|
|
767
|
+
return {
|
|
768
|
+
verified: false,
|
|
769
|
+
reason: `expected-device-mismatch:${trustState.expectedDeviceId}:${claimedDeviceId}`,
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
let knownDevice = this.getKnownDevice(claimedDeviceId, connection.remoteNodeId);
|
|
773
|
+
if (!knownDevice) {
|
|
774
|
+
await this.refreshKnownDeviceCache();
|
|
775
|
+
knownDevice = this.getKnownDevice(claimedDeviceId, connection.remoteNodeId);
|
|
776
|
+
}
|
|
777
|
+
if (!knownDevice) {
|
|
778
|
+
return { verified: false, reason: `unknown-trusted-device:${claimedDeviceId}` };
|
|
779
|
+
}
|
|
780
|
+
const trustedMetadata = parseTrustedDeviceMetadata(knownDevice.metadata);
|
|
781
|
+
if (!trustedMetadata) {
|
|
782
|
+
return { verified: false, reason: `missing-trusted-device-metadata:${claimedDeviceId}` };
|
|
783
|
+
}
|
|
784
|
+
if (payload.identityFingerprint
|
|
785
|
+
&& trustedMetadata.identityFingerprint !== payload.identityFingerprint) {
|
|
786
|
+
return {
|
|
787
|
+
verified: false,
|
|
788
|
+
reason: `trusted-device-fingerprint-mismatch:${claimedDeviceId}`,
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
const expectedChallenge = trustState.issuedChallenge;
|
|
792
|
+
if (!expectedChallenge || !payload.signedChallenge) {
|
|
793
|
+
return { verified: false, reason: 'missing-signed-transport-challenge' };
|
|
794
|
+
}
|
|
795
|
+
const verified = verifyTransportTrustChallenge(expectedChallenge, payload.signedChallenge, trustedMetadata.identityPublicKey);
|
|
796
|
+
if (!verified) {
|
|
797
|
+
return { verified: false, reason: `invalid-transport-proof:${claimedDeviceId}` };
|
|
798
|
+
}
|
|
799
|
+
return { verified: true, deviceId: claimedDeviceId };
|
|
800
|
+
}
|
|
801
|
+
async enforceConnectionTrustFailure(connection, reason) {
|
|
802
|
+
const trustState = this.initializeConnectionTrust(connection);
|
|
803
|
+
trustState.failed = true;
|
|
804
|
+
trustState.verified = false;
|
|
805
|
+
trustState.failureReason = reason;
|
|
806
|
+
this.applyConnectionTrustProjection(connection);
|
|
807
|
+
console.warn('[Client][TRUST] Disconnecting unverified connection', {
|
|
808
|
+
connectionId: connection.id,
|
|
809
|
+
remoteNodeId: connection.remoteNodeId,
|
|
810
|
+
reason,
|
|
811
|
+
});
|
|
812
|
+
await Promise.allSettled([
|
|
813
|
+
connection.disconnect(),
|
|
814
|
+
this.disconnectNode(connection.remoteNodeId).catch(() => undefined),
|
|
815
|
+
]);
|
|
816
|
+
}
|
|
817
|
+
markConnectionTrustVerified(connection, verifiedDeviceId) {
|
|
818
|
+
const trustState = this.initializeConnectionTrust(connection, verifiedDeviceId);
|
|
819
|
+
trustState.verified = true;
|
|
820
|
+
trustState.failed = false;
|
|
821
|
+
trustState.failureReason = undefined;
|
|
822
|
+
trustState.verifiedDeviceId =
|
|
823
|
+
verifiedDeviceId
|
|
824
|
+
|| trustState.verifiedDeviceId
|
|
825
|
+
|| trustState.expectedDeviceId
|
|
826
|
+
|| null;
|
|
827
|
+
if (verifiedDeviceId && !trustState.expectedDeviceId) {
|
|
828
|
+
trustState.expectedDeviceId = verifiedDeviceId;
|
|
829
|
+
}
|
|
830
|
+
this.applyConnectionTrustProjection(connection);
|
|
831
|
+
}
|
|
832
|
+
async searchDevices() {
|
|
833
|
+
await this.ensureSignalingReadyForDiscovery('search');
|
|
834
|
+
const devices = await this.signaling.searchDevices();
|
|
835
|
+
devices.forEach((device) => {
|
|
836
|
+
this.rememberKnownDevice(device);
|
|
837
|
+
this.peerLifecycle.upsertDevice(device);
|
|
838
|
+
});
|
|
839
|
+
return devices;
|
|
840
|
+
}
|
|
841
|
+
async updateDevice(deviceId, updates) {
|
|
842
|
+
await this.ensureSignalingReadyForDiscovery('search');
|
|
843
|
+
await this.signaling.updateDevice(deviceId, updates);
|
|
844
|
+
}
|
|
845
|
+
async deleteDevice(deviceId) {
|
|
846
|
+
await this.ensureSignalingReadyForDiscovery('search');
|
|
847
|
+
await this.signaling.deleteDevice(deviceId);
|
|
848
|
+
}
|
|
849
|
+
onDevicesChange(callback) {
|
|
850
|
+
let active = true;
|
|
851
|
+
let innerUnsubscribe = null;
|
|
852
|
+
void (async () => {
|
|
853
|
+
await this.ensureSignalingReadyForDiscovery('watch');
|
|
854
|
+
if (!active) {
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
innerUnsubscribe = this.signaling.onDevicesChange((devices) => {
|
|
858
|
+
devices.forEach((device) => {
|
|
859
|
+
this.rememberKnownDevice(device);
|
|
860
|
+
this.peerLifecycle.upsertDevice(device);
|
|
861
|
+
});
|
|
862
|
+
callback(devices);
|
|
863
|
+
});
|
|
864
|
+
})().catch((error) => {
|
|
865
|
+
console.warn('[Client] Failed to initialize onDevicesChange listener:', error);
|
|
866
|
+
if (active) {
|
|
867
|
+
callback([]);
|
|
868
|
+
}
|
|
869
|
+
});
|
|
870
|
+
return () => {
|
|
871
|
+
active = false;
|
|
872
|
+
if (innerUnsubscribe) {
|
|
873
|
+
innerUnsubscribe();
|
|
874
|
+
innerUnsubscribe = null;
|
|
875
|
+
}
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
async subscribeSessions(localDeviceId, callback) {
|
|
879
|
+
await this.ensureSignalingReadyForDiscovery('sessions');
|
|
880
|
+
return this.signaling.subscribeSessions(localDeviceId, callback);
|
|
881
|
+
}
|
|
882
|
+
async startScanningLoop(options) {
|
|
883
|
+
const autoConnect = options.autoConnect ?? false;
|
|
884
|
+
const handledSessions = new Set();
|
|
885
|
+
let stopped = false;
|
|
886
|
+
let unsubscribeDevices = null;
|
|
887
|
+
let unsubscribeSessions = null;
|
|
888
|
+
let effectiveLocalDeviceId = options.localDeviceId;
|
|
889
|
+
try {
|
|
890
|
+
const resolvedLocalDeviceId = await this.signaling.getLocalDeviceId();
|
|
891
|
+
if (resolvedLocalDeviceId) {
|
|
892
|
+
effectiveLocalDeviceId = resolvedLocalDeviceId;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
catch {
|
|
896
|
+
// Fall back to caller-provided localDeviceId.
|
|
897
|
+
}
|
|
898
|
+
unsubscribeDevices = this.onDevicesChange((devices) => {
|
|
899
|
+
if (stopped)
|
|
900
|
+
return;
|
|
901
|
+
options.onDevices?.(devices);
|
|
902
|
+
});
|
|
903
|
+
if (options.onIncomingSession) {
|
|
904
|
+
unsubscribeSessions = await this.subscribeSessions(effectiveLocalDeviceId, async (session) => {
|
|
905
|
+
if (stopped)
|
|
906
|
+
return;
|
|
907
|
+
const sessionId = session.sessionId || session.connectionId;
|
|
908
|
+
if (!sessionId || handledSessions.has(sessionId))
|
|
909
|
+
return;
|
|
910
|
+
if (session.targetDeviceId !== effectiveLocalDeviceId)
|
|
911
|
+
return;
|
|
912
|
+
if (session.initiatorDeviceId === effectiveLocalDeviceId)
|
|
913
|
+
return;
|
|
914
|
+
if (session.state === 'connected' || session.state === 'closed' || session.state === 'failed')
|
|
915
|
+
return;
|
|
916
|
+
handledSessions.add(sessionId);
|
|
917
|
+
try {
|
|
918
|
+
if (options.onIncomingSession) {
|
|
919
|
+
await options.onIncomingSession(session);
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
const runtimeConnected = this.getPeerState(session.initiatorDeviceId)?.status === 'connected';
|
|
923
|
+
if (runtimeConnected || options.isConnected?.(session.initiatorDeviceId)) {
|
|
924
|
+
const isProtected = this.peerLifecycle.hasScopes(session.initiatorDeviceId) ||
|
|
925
|
+
(options.hasProtectedConnectionIntent?.(session.initiatorDeviceId) ?? false);
|
|
926
|
+
if (!isProtected) {
|
|
927
|
+
await this.forceDisconnectPeer(session.initiatorDeviceId).catch(async () => {
|
|
928
|
+
await options.cleanupStaleConnection?.(session.initiatorDeviceId);
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
if (session.initiatorNodeId) {
|
|
933
|
+
await options.allowIncomingNodeId?.(session.initiatorNodeId);
|
|
934
|
+
}
|
|
935
|
+
const result = await options.acceptIncomingSession?.(sessionId, session.initiatorDeviceId);
|
|
936
|
+
if (!result?.success) {
|
|
937
|
+
throw new Error(result?.error || 'Failed to accept incoming signaling session');
|
|
938
|
+
}
|
|
939
|
+
await options.onIncomingSessionAccepted?.(session);
|
|
940
|
+
}
|
|
941
|
+
catch (error) {
|
|
942
|
+
handledSessions.delete(sessionId);
|
|
943
|
+
throw error;
|
|
944
|
+
}
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
return {
|
|
948
|
+
stop: () => {
|
|
949
|
+
stopped = true;
|
|
950
|
+
if (unsubscribeDevices) {
|
|
951
|
+
unsubscribeDevices();
|
|
952
|
+
unsubscribeDevices = null;
|
|
953
|
+
}
|
|
954
|
+
if (unsubscribeSessions) {
|
|
955
|
+
unsubscribeSessions();
|
|
956
|
+
unsubscribeSessions = null;
|
|
957
|
+
}
|
|
958
|
+
},
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
async connectDeviceWithRetry(device, hooks, options) {
|
|
962
|
+
if (!options.localDeviceId) {
|
|
963
|
+
return {
|
|
964
|
+
success: false,
|
|
965
|
+
skipped: 'missing-local-device-id',
|
|
966
|
+
attempts: 0,
|
|
967
|
+
error: 'Missing local device ID',
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
const maxRetries = options.maxRetries ?? 3;
|
|
971
|
+
const initialBackoffMs = options.initialBackoffMs ?? 2000;
|
|
972
|
+
const connectionState = await hooks.getConnectionState?.(device.deviceId);
|
|
973
|
+
if (connectionState === 'connecting') {
|
|
974
|
+
return {
|
|
975
|
+
success: true,
|
|
976
|
+
skipped: 'already-connecting',
|
|
977
|
+
attempts: 0,
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
if (connectionState === 'connected' && hooks.forceDisconnect) {
|
|
981
|
+
await hooks.forceDisconnect(device.deviceId);
|
|
982
|
+
await hooks.refreshState?.();
|
|
983
|
+
}
|
|
984
|
+
const attemptConnect = async () => {
|
|
985
|
+
const hasDirectIdentity = !!(typeof device.nodeId === 'string' && device.nodeId.trim().length > 0) ||
|
|
986
|
+
!!(typeof device.ticket === 'string' && device.ticket.trim().length > 0);
|
|
987
|
+
if (hasDirectIdentity) {
|
|
988
|
+
await hooks.directConnect(device);
|
|
989
|
+
await hooks.onConnected?.(device);
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
const signalingResult = await hooks.signalConnect({
|
|
993
|
+
localDeviceId: options.localDeviceId,
|
|
994
|
+
targetDeviceId: device.deviceId,
|
|
995
|
+
targetUserId: options.targetUserId,
|
|
996
|
+
device,
|
|
997
|
+
});
|
|
998
|
+
if (!signalingResult.success) {
|
|
999
|
+
throw new Error(signalingResult.error || 'Signaling failed');
|
|
1000
|
+
}
|
|
1001
|
+
};
|
|
1002
|
+
let attempt = 0;
|
|
1003
|
+
let lastError = undefined;
|
|
1004
|
+
while (attempt <= maxRetries) {
|
|
1005
|
+
try {
|
|
1006
|
+
await attemptConnect();
|
|
1007
|
+
return {
|
|
1008
|
+
success: true,
|
|
1009
|
+
attempts: attempt,
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
catch (error) {
|
|
1013
|
+
lastError = error;
|
|
1014
|
+
if (attempt === maxRetries) {
|
|
1015
|
+
break;
|
|
1016
|
+
}
|
|
1017
|
+
const delayMs = initialBackoffMs * Math.pow(2, attempt);
|
|
1018
|
+
hooks.onRetry?.(device, attempt + 1, delayMs);
|
|
1019
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
1020
|
+
attempt += 1;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
return {
|
|
1024
|
+
success: false,
|
|
1025
|
+
attempts: maxRetries + 1,
|
|
1026
|
+
error: lastError instanceof Error ? lastError.message : 'Connection failed',
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
async bootstrapRuntime(options) {
|
|
1030
|
+
const initializeClient = options.initializeClient ?? true;
|
|
1031
|
+
if (options.platform === 'web') {
|
|
1032
|
+
await options.initializeWeb?.();
|
|
1033
|
+
if (initializeClient) {
|
|
1034
|
+
await this.init();
|
|
1035
|
+
}
|
|
1036
|
+
return { ready: true, mode: 'web' };
|
|
1037
|
+
}
|
|
1038
|
+
const quickPollAttempts = options.quickPollAttempts ?? 5;
|
|
1039
|
+
const quickPollIntervalMs = options.quickPollIntervalMs ?? 300;
|
|
1040
|
+
const retryPollAttempts = options.retryPollAttempts ?? 8;
|
|
1041
|
+
const retryPollIntervalMs = options.retryPollIntervalMs ?? 300;
|
|
1042
|
+
const nativeInitEventTimeoutMs = options.nativeInitEventTimeoutMs ?? 4000;
|
|
1043
|
+
if (!options.isNativeReady) {
|
|
1044
|
+
console.warn('[Client][INIT] No isNativeReady callback provided for native platform; treating runtime as not ready');
|
|
1045
|
+
return { ready: false, mode: 'native' };
|
|
1046
|
+
}
|
|
1047
|
+
const isReadyCallable = options.isNativeReady;
|
|
1048
|
+
const quickReady = await pollUntilReady(isReadyCallable, quickPollAttempts, quickPollIntervalMs);
|
|
1049
|
+
if (!quickReady) {
|
|
1050
|
+
await options.retryNativeInit?.();
|
|
1051
|
+
const [eventReady, pollReadyAfterRetry] = await Promise.all([
|
|
1052
|
+
options.waitForNativeInitEvent?.(nativeInitEventTimeoutMs) ?? Promise.resolve(false),
|
|
1053
|
+
pollUntilReady(isReadyCallable, retryPollAttempts, retryPollIntervalMs),
|
|
1054
|
+
]);
|
|
1055
|
+
const ready = eventReady || pollReadyAfterRetry;
|
|
1056
|
+
if (!ready) {
|
|
1057
|
+
return { ready: false, mode: 'native' };
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
if (initializeClient) {
|
|
1061
|
+
await this.init();
|
|
1062
|
+
}
|
|
1063
|
+
return { ready: true, mode: 'native' };
|
|
1064
|
+
}
|
|
1065
|
+
updateTransportConfig(transports) {
|
|
1066
|
+
if (transports) {
|
|
1067
|
+
// Merge with existing
|
|
1068
|
+
this.options.transports = { ...(this.options.transports || {}), ...transports };
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* Bootstraps the WASM module and Rust signaling.
|
|
1073
|
+
* Must be called before connect or createRoom.
|
|
1074
|
+
*/
|
|
1075
|
+
async init() {
|
|
1076
|
+
if (this.initPromise)
|
|
1077
|
+
return this.initPromise;
|
|
1078
|
+
if (this.wasmLoaded && this.wasmClient && this.localNodeId)
|
|
1079
|
+
return;
|
|
1080
|
+
// Note: The specific transport implementations (like TauriBridge)
|
|
1081
|
+
// may bypass this entirely if they rely strictly on IPC.
|
|
1082
|
+
this.initPromise = (async () => {
|
|
1083
|
+
try {
|
|
1084
|
+
// Initialize WASM
|
|
1085
|
+
openRtcDebugLog('[Client][INIT] Loading WASM module...');
|
|
1086
|
+
await initWasm();
|
|
1087
|
+
start();
|
|
1088
|
+
this.wasmLoaded = true;
|
|
1089
|
+
openRtcDebugLog('[Client][INIT] WASM module loaded');
|
|
1090
|
+
// Instantiate the Rust WasmClient.
|
|
1091
|
+
// In Personal Mode (apiKey + spaceKey), encode the space namespace into the appTag
|
|
1092
|
+
// using a "space::{hash}" prefix so the Rust room backend routes to spaces/{hash}/rooms/.
|
|
1093
|
+
const projectId = resolveProjectId(this.options);
|
|
1094
|
+
const apiKey = typeof this.options.apiKey === 'string' ? this.options.apiKey.trim() : '';
|
|
1095
|
+
const spaceKey = typeof this.options.spaceKey === 'string' ? this.options.spaceKey.trim() : '';
|
|
1096
|
+
const isPersonalMode = (this.options.discoveryMode === undefined || this.options.discoveryMode === 'personal') && !!apiKey && !!spaceKey;
|
|
1097
|
+
let wasmAppTag = resolveAppTag(this.options);
|
|
1098
|
+
if (isPersonalMode) {
|
|
1099
|
+
const spaceNamespaceId = await deriveSpaceNamespace(apiKey, spaceKey);
|
|
1100
|
+
wasmAppTag = `space::${spaceNamespaceId}`;
|
|
1101
|
+
openRtcDebugLog(`[Client][INIT] Personal Mode — WASM rooms routed to spaces/${spaceNamespaceId.slice(0, 8)}…`);
|
|
1102
|
+
}
|
|
1103
|
+
this.effectiveRoomBackendTag = wasmAppTag;
|
|
1104
|
+
this.wasmClient = new WasmClient(projectId, wasmAppTag);
|
|
1105
|
+
openRtcDebugLog('PlutoRTC WASM Core Initialized');
|
|
1106
|
+
this.signaling.setWasmClient(this.wasmClient);
|
|
1107
|
+
if (!this.usesTicketOnlySignalingMode()) {
|
|
1108
|
+
await this.ensureAuthenticated('init');
|
|
1109
|
+
await this.syncAuthTokenToWasm('init');
|
|
1110
|
+
this.installAuthTokenSync();
|
|
1111
|
+
}
|
|
1112
|
+
const storageKey = `iroh_secret_key_${resolveStorageNamespace(this.options)}`;
|
|
1113
|
+
let secretKeyBytes = undefined;
|
|
1114
|
+
const persistenceMode = this.options.nodeIdPersistence ??
|
|
1115
|
+
(typeof this.options.transports?.iroh === 'object'
|
|
1116
|
+
? this.options.transports.iroh.persistenceMode
|
|
1117
|
+
: undefined) ??
|
|
1118
|
+
'persistent';
|
|
1119
|
+
// 1. Manual User Specified Key
|
|
1120
|
+
if (this.options.secretKey) {
|
|
1121
|
+
const match = this.options.secretKey.match(/.{1,2}/g);
|
|
1122
|
+
if (match) {
|
|
1123
|
+
secretKeyBytes = new Uint8Array(match.map(byte => parseInt(byte, 16)));
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
// 2. Persistent Mode (Default) - Try Load
|
|
1127
|
+
else if (persistenceMode === 'persistent') {
|
|
1128
|
+
const storedKeyHex = typeof localStorage !== 'undefined' ? localStorage.getItem(storageKey) : null;
|
|
1129
|
+
if (storedKeyHex) {
|
|
1130
|
+
const match = storedKeyHex.match(/.{1,2}/g);
|
|
1131
|
+
if (match) {
|
|
1132
|
+
secretKeyBytes = new Uint8Array(match.map(byte => parseInt(byte, 16)));
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
// 3. Ephemeral Mode - secretKeyBytes remains undefined (generates new)
|
|
1137
|
+
// Canonical device identity is always sourced from signaling bridge state.
|
|
1138
|
+
this.deviceId = await this.signaling.getLocalDeviceId().catch(() => null);
|
|
1139
|
+
const initIrohStartedAt = Date.now();
|
|
1140
|
+
openRtcDebugLog(`[Client][INIT][IROH] start persistence=${persistenceMode} hasSecretKey=${!!secretKeyBytes} secretKeyLen=${secretKeyBytes?.length || 0} hasStoredDeviceId=${!!this.deviceId}`);
|
|
1141
|
+
const pendingTimer = setInterval(() => {
|
|
1142
|
+
const elapsed = Date.now() - initIrohStartedAt;
|
|
1143
|
+
console.warn(`[Client][INIT][IROH] pending elapsedMs=${elapsed} hasWasmClient=${!!this.wasmClient}`);
|
|
1144
|
+
}, 5000);
|
|
1145
|
+
try {
|
|
1146
|
+
this.localNodeId = await withTimeout(this.wasmClient.init_iroh(secretKeyBytes), 20000);
|
|
1147
|
+
}
|
|
1148
|
+
catch (error) {
|
|
1149
|
+
const elapsed = Date.now() - initIrohStartedAt;
|
|
1150
|
+
throw new Error(`[Client] init_iroh stalled or failed: ${error?.message || error}; elapsed_ms=${elapsed}; persistence=${persistenceMode}; has_secret_key=${!!secretKeyBytes}`);
|
|
1151
|
+
}
|
|
1152
|
+
finally {
|
|
1153
|
+
clearInterval(pendingTimer);
|
|
1154
|
+
}
|
|
1155
|
+
openRtcDebugLog(`[Client][INIT][IROH] complete elapsedMs=${Date.now() - initIrohStartedAt} nodeId=${this.localNodeId}`);
|
|
1156
|
+
if (!this.localNodeId) {
|
|
1157
|
+
throw new Error('[Client] init_iroh returned empty node id');
|
|
1158
|
+
}
|
|
1159
|
+
if (!this.deviceId) {
|
|
1160
|
+
this.deviceId = await this.signaling.getLocalDeviceId().catch(() => null);
|
|
1161
|
+
}
|
|
1162
|
+
this.node = { initialized: true, localNodeId: this.localNodeId };
|
|
1163
|
+
if (this.localNodeId) {
|
|
1164
|
+
if (persistenceMode === 'persistent' && !this.options.secretKey && typeof localStorage !== 'undefined') {
|
|
1165
|
+
const keyToPersist = secretKeyBytes
|
|
1166
|
+
?? (typeof this.wasmClient?.iroh_secret_key === 'function'
|
|
1167
|
+
? await this.wasmClient.iroh_secret_key().catch(() => undefined)
|
|
1168
|
+
: undefined);
|
|
1169
|
+
if (keyToPersist && keyToPersist.length > 0) {
|
|
1170
|
+
const hex = Array.from(keyToPersist)
|
|
1171
|
+
.map((byte) => Number(byte).toString(16).padStart(2, '0'))
|
|
1172
|
+
.join('');
|
|
1173
|
+
localStorage.setItem(storageKey, hex);
|
|
1174
|
+
openRtcDebugLog(`[Client][INIT][IROH] persisted secret key bytes=${keyToPersist.length} nodeId=${this.localNodeId}`);
|
|
1175
|
+
}
|
|
1176
|
+
else {
|
|
1177
|
+
console.warn(`[Client][INIT][IROH] unable to persist secret key for nodeId=${this.localNodeId}`);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
// Initialize shared MoQ session if enabled
|
|
1182
|
+
if (this.options.transports?.moq) {
|
|
1183
|
+
this.initMoQ();
|
|
1184
|
+
}
|
|
1185
|
+
// Automatic TURN credentials
|
|
1186
|
+
// Trigger if: useTurn OR privacyMode OR strictMode
|
|
1187
|
+
// Note: Constructor normalizes `webrtc: true` to an object, so casting is safe.
|
|
1188
|
+
const webrtcConfig = this.options.transports?.webrtc || DEFAULT_WEBRTC_CONFIG;
|
|
1189
|
+
const shouldFetchTurn = webrtcConfig?.useTurn ||
|
|
1190
|
+
webrtcConfig?.privacyMode ||
|
|
1191
|
+
this.options.strictMode;
|
|
1192
|
+
if (shouldFetchTurn) {
|
|
1193
|
+
try {
|
|
1194
|
+
const turnData = await this.signaling.getTurnCredentials();
|
|
1195
|
+
if (turnData && turnData.iceServers) {
|
|
1196
|
+
let servers = Array.isArray(turnData.iceServers) ? turnData.iceServers : [turnData.iceServers];
|
|
1197
|
+
// Filter STUN if privacyMode OR strictMode is enabled
|
|
1198
|
+
// Use the webrtcConfig reference we cast earlier
|
|
1199
|
+
if (webrtcConfig?.privacyMode || this.options.strictMode) {
|
|
1200
|
+
servers = servers.map((server) => {
|
|
1201
|
+
if (typeof server.urls === 'string') {
|
|
1202
|
+
return server.urls.startsWith('stun:') ? null : server;
|
|
1203
|
+
}
|
|
1204
|
+
else if (Array.isArray(server.urls)) {
|
|
1205
|
+
const filteredUrls = server.urls.filter((url) => !url.startsWith('stun:'));
|
|
1206
|
+
return filteredUrls.length > 0 ? { ...server, urls: filteredUrls } : null;
|
|
1207
|
+
}
|
|
1208
|
+
return server;
|
|
1209
|
+
}).filter((s) => s !== null);
|
|
1210
|
+
openRtcDebugLog(`[Client] Strict / Privacy Mode: Filtered STUN servers. Remaining=${servers.length}`);
|
|
1211
|
+
}
|
|
1212
|
+
else {
|
|
1213
|
+
openRtcDebugLog(`[Client] Automatically fetched ${servers.length} TURN servers.`);
|
|
1214
|
+
}
|
|
1215
|
+
// Merge into existing config
|
|
1216
|
+
if (!this.options.transports)
|
|
1217
|
+
this.options.transports = {};
|
|
1218
|
+
if (!this.options.transports.webrtc)
|
|
1219
|
+
this.options.transports.webrtc = {};
|
|
1220
|
+
// Ensure webrtc is an object (it is if we are here, but TS might complain)
|
|
1221
|
+
// It should be normalized by constructor.
|
|
1222
|
+
const currentConfig = this.options.transports.webrtc || DEFAULT_WEBRTC_CONFIG;
|
|
1223
|
+
if (!currentConfig.iceServers) {
|
|
1224
|
+
currentConfig.iceServers = [];
|
|
1225
|
+
}
|
|
1226
|
+
// If Strict Mode, we must ALSO filter the user-provided servers!
|
|
1227
|
+
if (this.options.strictMode) {
|
|
1228
|
+
currentConfig.iceServers = currentConfig.iceServers
|
|
1229
|
+
.map((server) => {
|
|
1230
|
+
if (typeof server.urls === 'string') {
|
|
1231
|
+
return server.urls.startsWith('stun:') ? null : server;
|
|
1232
|
+
}
|
|
1233
|
+
else if (Array.isArray(server.urls)) {
|
|
1234
|
+
const filteredUrls = server.urls.filter((url) => !url.startsWith('stun:'));
|
|
1235
|
+
return filteredUrls.length > 0 ? { ...server, urls: filteredUrls } : null;
|
|
1236
|
+
}
|
|
1237
|
+
return server;
|
|
1238
|
+
}).filter(Boolean);
|
|
1239
|
+
}
|
|
1240
|
+
// RTCConfiguration.iceServers is technically RTCIceServer[], so we can push
|
|
1241
|
+
currentConfig.iceServers.push(...servers);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
catch (e) {
|
|
1245
|
+
console.warn("[Client] Failed to auto-fetch TURN credentials:", e);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
// Fallback to Default STUN if no config provided and not in strict/privacy mode
|
|
1249
|
+
if (!this.options.strictMode && !webrtcConfig?.privacyMode) {
|
|
1250
|
+
// Only proceed if transports.webrtc is actually enabled/defined
|
|
1251
|
+
const config = this.options.transports?.webrtc;
|
|
1252
|
+
if (config) {
|
|
1253
|
+
// Check if iceServers is missing or empty
|
|
1254
|
+
if (!config.iceServers || (Array.isArray(config.iceServers) && config.iceServers.length === 0)) {
|
|
1255
|
+
openRtcDebugLog('[Client] No ICE servers provided. Using default public STUN servers.');
|
|
1256
|
+
config.iceServers = [...DEFAULT_ICE_SERVERS];
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
catch (e) {
|
|
1262
|
+
console.error("Failed to initialize PlutoRTC:", e);
|
|
1263
|
+
throw e;
|
|
1264
|
+
}
|
|
1265
|
+
})();
|
|
1266
|
+
return this.initPromise;
|
|
1267
|
+
}
|
|
1268
|
+
async ensureAuthenticated(reason) {
|
|
1269
|
+
if (this.usesTicketOnlySignalingMode()) {
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
const authMode = (this.options.authMode ?? 'anonymous');
|
|
1273
|
+
const shouldLog = reason !== 'discovery';
|
|
1274
|
+
// Personal Mode with spaceKey is auth-free: discovery uses sha256(apiKey+spaceKey) namespace,
|
|
1275
|
+
// not Firebase user identity. Skip all auth checks entirely.
|
|
1276
|
+
const isPersonalWithSpaceKey = (this.options.discoveryMode === undefined || this.options.discoveryMode === 'personal') &&
|
|
1277
|
+
typeof this.options.spaceKey === 'string' &&
|
|
1278
|
+
this.options.spaceKey.trim().length > 0;
|
|
1279
|
+
if (isPersonalWithSpaceKey) {
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
try {
|
|
1283
|
+
await withTimeout(this.signaling.waitForAuth(), 5000);
|
|
1284
|
+
}
|
|
1285
|
+
catch (error) {
|
|
1286
|
+
if (shouldLog) {
|
|
1287
|
+
console.warn(`[Client][AUTH] waitForAuth timed out (${reason})`, error);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
if (this.signaling.currentUser) {
|
|
1291
|
+
if (shouldLog) {
|
|
1292
|
+
openRtcDebugLog(`[Client][AUTH] Authenticated (${reason}) as ${this.signaling.currentUser.id}`);
|
|
1293
|
+
}
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
if (authMode === 'external' || authMode === 'required') {
|
|
1297
|
+
throw new Error(`[Client][AUTH] ${authMode} auth mode requires host-provided auth before ${reason}`);
|
|
1298
|
+
}
|
|
1299
|
+
if (shouldLog) {
|
|
1300
|
+
openRtcDebugLog(`[Client][AUTH] No user (${reason}); attempting anonymous sign-in...`);
|
|
1301
|
+
}
|
|
1302
|
+
await withTimeout(this.signaling.signInAnonymously(), 8000);
|
|
1303
|
+
try {
|
|
1304
|
+
await withTimeout(this.signaling.waitForAuth(), 5000);
|
|
1305
|
+
}
|
|
1306
|
+
catch {
|
|
1307
|
+
// If auth state listener is slow, we still verify currentUser below.
|
|
1308
|
+
}
|
|
1309
|
+
if (!this.signaling.currentUser) {
|
|
1310
|
+
throw new Error('[Client][AUTH] Anonymous sign-in did not produce an authenticated user');
|
|
1311
|
+
}
|
|
1312
|
+
if (shouldLog) {
|
|
1313
|
+
openRtcDebugLog(`[Client][AUTH] Anonymous sign-in success (${reason})`);
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
installAuthTokenSync() {
|
|
1317
|
+
if (this.authSyncUnsubscribe) {
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
this.authSyncUnsubscribe = this.signaling.onAuthChange(() => {
|
|
1321
|
+
this.syncAuthTokenToWasm('auth-change').catch((error) => {
|
|
1322
|
+
console.warn('[Client][WASM-AUTH] Failed syncing auth token on auth change:', error);
|
|
1323
|
+
});
|
|
1324
|
+
});
|
|
1325
|
+
if (!this.authRefreshInterval) {
|
|
1326
|
+
this.authRefreshInterval = setInterval(() => {
|
|
1327
|
+
this.syncAuthTokenToWasm('periodic-refresh', true).catch((error) => {
|
|
1328
|
+
console.warn('[Client][WASM-AUTH] Periodic auth refresh sync failed:', error);
|
|
1329
|
+
});
|
|
1330
|
+
}, 10 * 60 * 1000);
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
async syncAuthTokenToWasm(reason, forceRefresh = false) {
|
|
1334
|
+
if (!this.wasmClient || typeof this.wasmClient.set_auth_token !== 'function') {
|
|
1335
|
+
console.warn('[Client][WASM-AUTH] WasmClient.set_auth_token is unavailable');
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
const user = this.signaling.currentUser;
|
|
1339
|
+
const token = user && user.getToken ? await user.getToken(forceRefresh) : null;
|
|
1340
|
+
const hasToken = !!token;
|
|
1341
|
+
const tokenLen = token?.length || 0;
|
|
1342
|
+
const userId = user?.id || null;
|
|
1343
|
+
if (this.lastWasmAuthSyncUserId === userId && this.lastWasmAuthSyncToken === token) {
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
this.lastWasmAuthSyncUserId = userId;
|
|
1347
|
+
this.lastWasmAuthSyncToken = token;
|
|
1348
|
+
openRtcDebugLog(`[Client][WASM-AUTH] Sync token (${reason}) hasToken=${hasToken} tokenLen=${tokenLen} uid=${userId || 'none'} forceRefresh=${forceRefresh}`);
|
|
1349
|
+
this.wasmClient.set_auth_token(token);
|
|
1350
|
+
}
|
|
1351
|
+
// --- Shared MoQ Session ---
|
|
1352
|
+
get moqState() { return this._moqState; }
|
|
1353
|
+
get moq() {
|
|
1354
|
+
if (this._moqState !== 'ready' || !this._moqSession)
|
|
1355
|
+
return null;
|
|
1356
|
+
return {
|
|
1357
|
+
session: this._moqSession,
|
|
1358
|
+
publisher: this._moqSession.publisher,
|
|
1359
|
+
subscriber: this._moqSession.subscriber
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
onMoQReady(cb) { this.moqReadyListeners.push(cb); }
|
|
1363
|
+
onMoQFailed(cb) { this.moqFailedListeners.push(cb); }
|
|
1364
|
+
onMoQObject(callback) {
|
|
1365
|
+
this.moqObjectListeners.push(callback);
|
|
1366
|
+
}
|
|
1367
|
+
async initMoQ() {
|
|
1368
|
+
if (this._moqState !== 'none')
|
|
1369
|
+
return;
|
|
1370
|
+
this._moqState = 'connecting';
|
|
1371
|
+
const moqConfig = this.options.transports?.moq || DEFAULT_MOQ_CONFIG;
|
|
1372
|
+
const relayUrl = moqConfig.relayUrl;
|
|
1373
|
+
try {
|
|
1374
|
+
// @ts-ignore — WebTransport may not be in all TS lib targets
|
|
1375
|
+
const wt = new WebTransport(relayUrl);
|
|
1376
|
+
await wt.ready;
|
|
1377
|
+
this._moqSession = new MoQSession(wt, (ns, name, obj, meta) => {
|
|
1378
|
+
// 1. Dispatch to internal listeners/watchers
|
|
1379
|
+
this.moqObjectListeners.forEach(cb => cb(ns, name, obj, meta));
|
|
1380
|
+
// 2. Dispatch to Connection (for Chat/Signaling/Data)
|
|
1381
|
+
// Only for 'data/' tracks to avoid injecting raw media into Connection frame parser
|
|
1382
|
+
if (name.startsWith('data/') && this.localNodeId) {
|
|
1383
|
+
const connId = this.getDeterministicConnectionId(ns);
|
|
1384
|
+
const conn = this.connections.get(connId);
|
|
1385
|
+
if (conn) {
|
|
1386
|
+
conn.injectData(obj);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
});
|
|
1390
|
+
await this._moqSession.init();
|
|
1391
|
+
await this._moqSession.waitForSetup();
|
|
1392
|
+
// Announce our namespace once
|
|
1393
|
+
await this._moqSession.publisher.announce(this.localNodeId);
|
|
1394
|
+
// Create Data Tracks
|
|
1395
|
+
await this.createMoQDataTracks();
|
|
1396
|
+
this._moqState = 'ready';
|
|
1397
|
+
openRtcDebugLog(`[Client] MoQ Session Ready: Pub/Sub via ${relayUrl}`);
|
|
1398
|
+
this.moqReadyListeners.forEach(cb => cb());
|
|
1399
|
+
}
|
|
1400
|
+
catch (e) {
|
|
1401
|
+
console.warn("[Client] MoQ init failed:", e);
|
|
1402
|
+
this._moqState = 'failed';
|
|
1403
|
+
this.moqFailedListeners.forEach(cb => cb());
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
async connect(ticket, timeoutMs = 20000, expectedDeviceId, channelId) {
|
|
1407
|
+
await this.ensureWasmRuntimeForP2P();
|
|
1408
|
+
if (!ticket || !ticket.trim()) {
|
|
1409
|
+
throw new Error('connect requires a non-empty endpoint ticket');
|
|
1410
|
+
}
|
|
1411
|
+
// Split compound ticket: "<iroh_ticket>.<base64url_token_payload>"
|
|
1412
|
+
// We must split on the LAST `.` because endpoint tickets can contain
|
|
1413
|
+
// dots (for example through embedded relay URL data).
|
|
1414
|
+
const rawTicket = ticket.trim();
|
|
1415
|
+
const { irohTicket, tokenSuffix } = splitCompoundTicket(rawTicket);
|
|
1416
|
+
let nodeId;
|
|
1417
|
+
try {
|
|
1418
|
+
nodeId = await this.wasmClient.endpoint_id_from_ticket(irohTicket);
|
|
1419
|
+
}
|
|
1420
|
+
catch (error) {
|
|
1421
|
+
throw new Error(`connect requires a valid endpoint ticket: ${error?.message || error}`);
|
|
1422
|
+
}
|
|
1423
|
+
// Store extracted token so sendTransportHandshake can include it.
|
|
1424
|
+
let compoundTicketToken = null;
|
|
1425
|
+
if (tokenSuffix) {
|
|
1426
|
+
const decoded = decodeCompoundTicketPayload(tokenSuffix);
|
|
1427
|
+
if (typeof decoded?.t === 'string') {
|
|
1428
|
+
compoundTicketToken = decoded.t;
|
|
1429
|
+
this.pendingTokenByNodeId.set(nodeId, decoded.t);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
// Check if already connected
|
|
1433
|
+
const connId = this.getDeterministicConnectionId(nodeId);
|
|
1434
|
+
const existing = this.connections.get(connId);
|
|
1435
|
+
if (existing) {
|
|
1436
|
+
if (expectedDeviceId) {
|
|
1437
|
+
this.initializeConnectionTrust(existing, expectedDeviceId);
|
|
1438
|
+
}
|
|
1439
|
+
return existing;
|
|
1440
|
+
}
|
|
1441
|
+
// Initiate connection
|
|
1442
|
+
try {
|
|
1443
|
+
openRtcDebugLog(`Initiating connection to ${nodeId}...`);
|
|
1444
|
+
this.pendingConnection = await this.wasmClient.connect(irohTicket);
|
|
1445
|
+
}
|
|
1446
|
+
catch (e) {
|
|
1447
|
+
console.warn("Connect trigger failed:", e);
|
|
1448
|
+
}
|
|
1449
|
+
// Retry open_bi loop
|
|
1450
|
+
let biStream = null;
|
|
1451
|
+
let retries = 0;
|
|
1452
|
+
const interval = 500;
|
|
1453
|
+
const maxRetries = Math.ceil(timeoutMs / interval);
|
|
1454
|
+
if (compoundTicketToken
|
|
1455
|
+
&& this.wasmClient
|
|
1456
|
+
&& typeof this.wasmClient.present_session_token_to_host === 'function') {
|
|
1457
|
+
let presented = false;
|
|
1458
|
+
let presentationError = null;
|
|
1459
|
+
let presentationRetries = 0;
|
|
1460
|
+
let approvedScope = null;
|
|
1461
|
+
while (presentationRetries < maxRetries) {
|
|
1462
|
+
try {
|
|
1463
|
+
approvedScope = await this.wasmClient.present_session_token_to_host(nodeId, compoundTicketToken);
|
|
1464
|
+
console.info('[Client][AUTH] host approved session token', {
|
|
1465
|
+
nodeId,
|
|
1466
|
+
approvalScope: approvedScope || null,
|
|
1467
|
+
nextStep: 'open-main-stream-and-register-connection',
|
|
1468
|
+
});
|
|
1469
|
+
presented = true;
|
|
1470
|
+
break;
|
|
1471
|
+
}
|
|
1472
|
+
catch (error) {
|
|
1473
|
+
presentationError = error;
|
|
1474
|
+
presentationRetries++;
|
|
1475
|
+
await new Promise(r => setTimeout(r, interval));
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
if (!presented) {
|
|
1479
|
+
throw new Error(`Failed to present session token to ${nodeId} after ${timeoutMs} ms: ${presentationError?.message || presentationError}`);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
while (retries < maxRetries) {
|
|
1483
|
+
try {
|
|
1484
|
+
biStream = await this.wasmClient.open_bi(nodeId);
|
|
1485
|
+
break;
|
|
1486
|
+
}
|
|
1487
|
+
catch (e) {
|
|
1488
|
+
retries++;
|
|
1489
|
+
await new Promise(r => setTimeout(r, interval));
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
if (!biStream) {
|
|
1493
|
+
throw new Error(`Failed to establish connection to ${nodeId} after ${timeoutMs} ms`);
|
|
1494
|
+
}
|
|
1495
|
+
const connection = new Connection(connId, this.localNodeId, // Local
|
|
1496
|
+
nodeId, // Remote
|
|
1497
|
+
biStream.send.getWriter(), biStream.recv.getReader(), {
|
|
1498
|
+
rtcConfig: this.getRTCConfig(),
|
|
1499
|
+
transportContext: {
|
|
1500
|
+
priorities: this.options.transportPriority || ['webrtc', 'moq', 'iroh'],
|
|
1501
|
+
disableIrohFallback: this.options.disableIrohFallback || false,
|
|
1502
|
+
isMoQReady: () => this.moqState === 'ready',
|
|
1503
|
+
sendMoQ: (data) => this.sendMoQData(nodeId, data)
|
|
1504
|
+
}
|
|
1505
|
+
});
|
|
1506
|
+
this.assignConnectionChannel(connection, channelId);
|
|
1507
|
+
this.initializeConnectionTrust(connection, expectedDeviceId);
|
|
1508
|
+
this.registerConnection(connection);
|
|
1509
|
+
if (compoundTicketToken) {
|
|
1510
|
+
console.info('[Client][AUTH] token-approved connection registered locally', {
|
|
1511
|
+
nodeId,
|
|
1512
|
+
connectionId: connection.id,
|
|
1513
|
+
localNodeId: connection.localNodeId,
|
|
1514
|
+
remoteNodeId: connection.remoteNodeId,
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
this.announceConnectionWhenStable(connection);
|
|
1518
|
+
void this.sendTransportHandshake(connection, 'hello').catch((error) => {
|
|
1519
|
+
console.warn('[Client][TRUST] Failed to send outgoing handshake', {
|
|
1520
|
+
connectionId: connection.id,
|
|
1521
|
+
remoteNodeId: connection.remoteNodeId,
|
|
1522
|
+
error,
|
|
1523
|
+
});
|
|
1524
|
+
});
|
|
1525
|
+
return connection;
|
|
1526
|
+
}
|
|
1527
|
+
/**
|
|
1528
|
+
* Low-level dial helper.
|
|
1529
|
+
* Ensures the ticket is connected and returns only connection identity metadata.
|
|
1530
|
+
*/
|
|
1531
|
+
async connectRaw(ticket, timeoutMs = 20000) {
|
|
1532
|
+
const connection = await this.connect(ticket, timeoutMs);
|
|
1533
|
+
return {
|
|
1534
|
+
connectionId: connection.id,
|
|
1535
|
+
localNodeId: connection.localNodeId,
|
|
1536
|
+
remoteNodeId: connection.deviceId,
|
|
1537
|
+
};
|
|
1538
|
+
}
|
|
1539
|
+
/**
|
|
1540
|
+
* Start core-managed auto-connect for the authenticated user.
|
|
1541
|
+
* This is intentionally low-level: callers decide when to start/stop and
|
|
1542
|
+
* what higher-level protocol to run on top of the resulting connection.
|
|
1543
|
+
*/
|
|
1544
|
+
async startAutoConnect(localDeviceId) {
|
|
1545
|
+
await this.ensureSignalingReadyForDiscovery('watch');
|
|
1546
|
+
const userId = this.currentUser?.id;
|
|
1547
|
+
if (!userId) {
|
|
1548
|
+
throw new Error('startAutoConnect requires an authenticated user');
|
|
1549
|
+
}
|
|
1550
|
+
const effectiveLocalDeviceId = localDeviceId ||
|
|
1551
|
+
(await this.getLocalDeviceId()) ||
|
|
1552
|
+
this.deviceId;
|
|
1553
|
+
if (!effectiveLocalDeviceId) {
|
|
1554
|
+
throw new Error('startAutoConnect requires a local device identity');
|
|
1555
|
+
}
|
|
1556
|
+
this.signaling.startAutoConnect(userId, effectiveLocalDeviceId);
|
|
1557
|
+
}
|
|
1558
|
+
// --- Multiplexing API ---
|
|
1559
|
+
async openBi(peerId) {
|
|
1560
|
+
await this.ensureWasmRuntimeForP2P();
|
|
1561
|
+
const bi = typeof this.wasmClient.open_peer_bi === 'function'
|
|
1562
|
+
? await this.wasmClient.open_peer_bi(peerId, 5000)
|
|
1563
|
+
: await this.wasmClient.open_bi(peerId);
|
|
1564
|
+
return {
|
|
1565
|
+
readable: bi.recv,
|
|
1566
|
+
writable: bi.send
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1569
|
+
async openUni(peerId) {
|
|
1570
|
+
await this.ensureWasmRuntimeForP2P();
|
|
1571
|
+
if (typeof this.wasmClient.open_peer_uni === 'function') {
|
|
1572
|
+
return await this.wasmClient.open_peer_uni(peerId, 5000);
|
|
1573
|
+
}
|
|
1574
|
+
return await this.wasmClient.open_uni(peerId);
|
|
1575
|
+
}
|
|
1576
|
+
// --- Listener Management ---
|
|
1577
|
+
getConnections() {
|
|
1578
|
+
return Array.from(this.connections.values());
|
|
1579
|
+
}
|
|
1580
|
+
watchPeerStates(callback) {
|
|
1581
|
+
return this.peerLifecycle.watch(callback);
|
|
1582
|
+
}
|
|
1583
|
+
getPeerState(id) {
|
|
1584
|
+
return this.peerLifecycle.get(id);
|
|
1585
|
+
}
|
|
1586
|
+
listConnectedPeers() {
|
|
1587
|
+
return this.peerLifecycle.listConnected();
|
|
1588
|
+
}
|
|
1589
|
+
isPeerPromotionEligible(identifiers) {
|
|
1590
|
+
return this.resolvePromotionEligibility(identifiers);
|
|
1591
|
+
}
|
|
1592
|
+
addPeerScope(id, scope = 'persistent') {
|
|
1593
|
+
if (this.wasmClient && typeof this.wasmClient.add_peer_scope === 'function') {
|
|
1594
|
+
void this.wasmClient.add_peer_scope(id, scope).then(() => {
|
|
1595
|
+
void this.refreshPeerSnapshot();
|
|
1596
|
+
}).catch(() => { });
|
|
1597
|
+
return this.peerLifecycle.get(id);
|
|
1598
|
+
}
|
|
1599
|
+
return this.peerLifecycle.addScope(id, scope);
|
|
1600
|
+
}
|
|
1601
|
+
releasePeerScope(id, scope) {
|
|
1602
|
+
if (this.wasmClient && typeof this.wasmClient.release_peer_scope === 'function') {
|
|
1603
|
+
void this.wasmClient.release_peer_scope(id, scope ?? null).then(() => {
|
|
1604
|
+
void this.refreshPeerSnapshot();
|
|
1605
|
+
}).catch(() => { });
|
|
1606
|
+
return this.peerLifecycle.get(id);
|
|
1607
|
+
}
|
|
1608
|
+
return this.peerLifecycle.releaseScope(id, scope);
|
|
1609
|
+
}
|
|
1610
|
+
getPeerScopes(id) {
|
|
1611
|
+
return this.peerLifecycle.get(id)?.scopes ?? [];
|
|
1612
|
+
}
|
|
1613
|
+
isSamePeer(a, b) {
|
|
1614
|
+
const left = this.peerLifecycle.get(a);
|
|
1615
|
+
const right = this.peerLifecycle.get(b);
|
|
1616
|
+
if (left && right) {
|
|
1617
|
+
return left.peerId === right.peerId;
|
|
1618
|
+
}
|
|
1619
|
+
return false;
|
|
1620
|
+
}
|
|
1621
|
+
async refreshPeerSnapshot() {
|
|
1622
|
+
const hasRustProjection = this.hasRustPeerLifecycleProjection();
|
|
1623
|
+
let hasRustPeerSnapshots = false;
|
|
1624
|
+
if (this.wasmClient && typeof this.wasmClient.peer_sessions === 'function') {
|
|
1625
|
+
const sessions = (await this.wasmClient.peer_sessions());
|
|
1626
|
+
if (Array.isArray(sessions)) {
|
|
1627
|
+
hasRustPeerSnapshots = true;
|
|
1628
|
+
sessions.forEach((session) => {
|
|
1629
|
+
const activeConnectionId = session.activeConnectionId || session.candidateConnectionIds?.[0];
|
|
1630
|
+
const promotionEligible = this.resolvePromotionEligibility({
|
|
1631
|
+
connectionId: activeConnectionId || undefined,
|
|
1632
|
+
deviceId: session.deviceId ?? undefined,
|
|
1633
|
+
deviceIdHint: session.deviceIdHint ?? undefined,
|
|
1634
|
+
nodeId: session.nodeId ?? undefined,
|
|
1635
|
+
});
|
|
1636
|
+
const trustProjection = this.getConnectionTrustProjection({
|
|
1637
|
+
connectionId: activeConnectionId || undefined,
|
|
1638
|
+
deviceId: session.deviceId ?? undefined,
|
|
1639
|
+
deviceIdHint: session.deviceIdHint ?? undefined,
|
|
1640
|
+
nodeId: session.nodeId ?? undefined,
|
|
1641
|
+
});
|
|
1642
|
+
const settledReady = trustProjection
|
|
1643
|
+
? trustProjection.routable
|
|
1644
|
+
: !!session.settledReady;
|
|
1645
|
+
this.peerLifecycle.upsert({
|
|
1646
|
+
deviceId: (trustProjection?.deviceId ?? session.deviceId) ?? undefined,
|
|
1647
|
+
deviceIdHint: session.deviceIdHint,
|
|
1648
|
+
nodeId: session.nodeId,
|
|
1649
|
+
connectionId: activeConnectionId || undefined,
|
|
1650
|
+
status: this.mapRustPeerStatus(session.status),
|
|
1651
|
+
health: trustProjection?.health ?? this.mapRustPeerHealth(session.health),
|
|
1652
|
+
transportState: activeConnectionId
|
|
1653
|
+
? 'connected'
|
|
1654
|
+
: session.status === 'connecting'
|
|
1655
|
+
? 'connecting'
|
|
1656
|
+
: 'closed',
|
|
1657
|
+
protocolState: trustProjection?.protocolState
|
|
1658
|
+
?? (settledReady
|
|
1659
|
+
? 'routable'
|
|
1660
|
+
: activeConnectionId
|
|
1661
|
+
? 'transport-only'
|
|
1662
|
+
: session.status === 'connecting'
|
|
1663
|
+
? 'connecting'
|
|
1664
|
+
: 'closed'),
|
|
1665
|
+
routable: settledReady,
|
|
1666
|
+
promotionEligible,
|
|
1667
|
+
scopes: session.scopes ?? [],
|
|
1668
|
+
error: trustProjection?.error ?? session.error ?? undefined,
|
|
1669
|
+
lastSeenAt: session.lastSeenAtMs,
|
|
1670
|
+
});
|
|
1671
|
+
});
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
else if (this.wasmClient && typeof this.wasmClient.peer_snapshots === 'function') {
|
|
1675
|
+
const snapshots = (await this.wasmClient.peer_snapshots());
|
|
1676
|
+
if (Array.isArray(snapshots)) {
|
|
1677
|
+
hasRustPeerSnapshots = true;
|
|
1678
|
+
snapshots.forEach((snapshot) => {
|
|
1679
|
+
const promotionEligible = this.resolvePromotionEligibility({
|
|
1680
|
+
connectionId: snapshot.connectionIds?.[0],
|
|
1681
|
+
deviceId: snapshot.deviceId ?? undefined,
|
|
1682
|
+
deviceIdHint: snapshot.deviceIdHint ?? undefined,
|
|
1683
|
+
nodeId: snapshot.nodeId ?? undefined,
|
|
1684
|
+
});
|
|
1685
|
+
this.peerLifecycle.upsert({
|
|
1686
|
+
deviceId: snapshot.deviceId,
|
|
1687
|
+
deviceIdHint: snapshot.deviceIdHint,
|
|
1688
|
+
nodeId: snapshot.nodeId,
|
|
1689
|
+
connectionId: snapshot.connectionIds?.[0],
|
|
1690
|
+
status: this.mapRustPeerStatus(snapshot.status),
|
|
1691
|
+
health: this.mapRustPeerHealth(snapshot.health),
|
|
1692
|
+
promotionEligible,
|
|
1693
|
+
scopes: snapshot.scopes ?? [],
|
|
1694
|
+
error: snapshot.error ?? undefined,
|
|
1695
|
+
lastSeenAt: snapshot.lastSeenAtMs,
|
|
1696
|
+
});
|
|
1697
|
+
});
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
const devices = await this.searchDevices().catch(() => []);
|
|
1701
|
+
devices.forEach((device) => {
|
|
1702
|
+
this.peerLifecycle.upsertDevice(device);
|
|
1703
|
+
});
|
|
1704
|
+
if (!hasRustProjection) {
|
|
1705
|
+
this.connections.forEach((connection) => {
|
|
1706
|
+
this.syncConnectionPeerLifecycle(connection);
|
|
1707
|
+
});
|
|
1708
|
+
}
|
|
1709
|
+
const backendStates = await this.signaling.getConnectionStates().catch(() => []);
|
|
1710
|
+
backendStates.forEach((state) => {
|
|
1711
|
+
this.applyBackendConnectionState(state);
|
|
1712
|
+
});
|
|
1713
|
+
if (hasRustProjection && !hasRustPeerSnapshots && backendStates.length === 0) {
|
|
1714
|
+
return this.peerLifecycle.list();
|
|
1715
|
+
}
|
|
1716
|
+
return this.peerLifecycle.list();
|
|
1717
|
+
}
|
|
1718
|
+
async connectPeer(options) {
|
|
1719
|
+
const scope = options.scope ?? 'persistent';
|
|
1720
|
+
if (!options.ticket) {
|
|
1721
|
+
throw new Error('connectPeer requires a ticket.');
|
|
1722
|
+
}
|
|
1723
|
+
const connection = await this.connect(options.ticket, 20000, options.deviceId, options.channelId);
|
|
1724
|
+
const promotionEligible = this.resolvePromotionEligibility({
|
|
1725
|
+
connectionId: connection.id,
|
|
1726
|
+
nodeId: connection.remoteNodeId,
|
|
1727
|
+
deviceId: options.deviceId ?? undefined,
|
|
1728
|
+
});
|
|
1729
|
+
const peerId = options.deviceId ||
|
|
1730
|
+
connection.remoteNodeId ||
|
|
1731
|
+
connection.deviceId ||
|
|
1732
|
+
connection.id;
|
|
1733
|
+
if (this.hasRustPeerLifecycleProjection()) {
|
|
1734
|
+
void this.refreshPeerSnapshot().catch(() => { });
|
|
1735
|
+
}
|
|
1736
|
+
else {
|
|
1737
|
+
this.peerLifecycle.upsert({
|
|
1738
|
+
peerId,
|
|
1739
|
+
deviceId: options.deviceId,
|
|
1740
|
+
nodeId: connection.deviceId,
|
|
1741
|
+
connectionId: connection.id,
|
|
1742
|
+
ticket: options.ticket,
|
|
1743
|
+
status: 'connected',
|
|
1744
|
+
health: 'healthy',
|
|
1745
|
+
promotionEligible,
|
|
1746
|
+
scopes: [scope],
|
|
1747
|
+
lastSeenAt: Date.now(),
|
|
1748
|
+
});
|
|
1749
|
+
}
|
|
1750
|
+
void this.addPeerScope(peerId, scope);
|
|
1751
|
+
return connection;
|
|
1752
|
+
}
|
|
1753
|
+
async disconnectPeer(id, scope = 'persistent') {
|
|
1754
|
+
const nextState = this.peerLifecycle.releaseScope(id, scope);
|
|
1755
|
+
if (nextState && nextState.scopes.length > 0) {
|
|
1756
|
+
return;
|
|
1757
|
+
}
|
|
1758
|
+
await this.forceDisconnectPeer(id);
|
|
1759
|
+
}
|
|
1760
|
+
async forceDisconnectPeer(id) {
|
|
1761
|
+
const peer = this.peerLifecycle.releaseScope(id);
|
|
1762
|
+
const candidates = this.getPeerCandidates(peer?.peerId || id);
|
|
1763
|
+
await Promise.allSettled(Array.from(candidates).map(async (candidate) => {
|
|
1764
|
+
const connectionMatches = Array.from(this.connections.values()).filter((connection) => {
|
|
1765
|
+
const connectionId = connection.id.trim().toLowerCase();
|
|
1766
|
+
const remoteNodeId = connection.deviceId.trim().toLowerCase();
|
|
1767
|
+
return candidate === remoteNodeId || candidate === connectionId || connectionId.includes(candidate);
|
|
1768
|
+
});
|
|
1769
|
+
await Promise.allSettled(connectionMatches.map((connection) => connection.disconnect()));
|
|
1770
|
+
const nodeId = peer?.nodeId || (connectionMatches[0]?.deviceId ?? null);
|
|
1771
|
+
if (nodeId && candidate === nodeId.trim().toLowerCase()) {
|
|
1772
|
+
await this.disconnectNode(nodeId).catch(() => { });
|
|
1773
|
+
}
|
|
1774
|
+
}));
|
|
1775
|
+
if (this.hasRustPeerLifecycleProjection()) {
|
|
1776
|
+
await this.refreshPeerSnapshot().catch(() => { });
|
|
1777
|
+
}
|
|
1778
|
+
else {
|
|
1779
|
+
this.peerLifecycle.updateHealth(id, 'stale', 'closed');
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
async getPeerHealth(id) {
|
|
1783
|
+
const peer = this.peerLifecycle.get(id);
|
|
1784
|
+
if (!peer) {
|
|
1785
|
+
return 'unknown';
|
|
1786
|
+
}
|
|
1787
|
+
return peer.health;
|
|
1788
|
+
}
|
|
1789
|
+
mapRustPeerStatus(status) {
|
|
1790
|
+
switch ((status || '').toLowerCase()) {
|
|
1791
|
+
case 'connected':
|
|
1792
|
+
return 'connected';
|
|
1793
|
+
case 'connecting':
|
|
1794
|
+
case 'pending':
|
|
1795
|
+
return 'connecting';
|
|
1796
|
+
case 'failed':
|
|
1797
|
+
return 'failed';
|
|
1798
|
+
case 'closing':
|
|
1799
|
+
case 'closed':
|
|
1800
|
+
return 'closed';
|
|
1801
|
+
default:
|
|
1802
|
+
return 'disconnected';
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
mapRustPeerHealth(health) {
|
|
1806
|
+
switch ((health || '').toLowerCase()) {
|
|
1807
|
+
case 'healthy':
|
|
1808
|
+
return 'healthy';
|
|
1809
|
+
case 'suspect':
|
|
1810
|
+
return 'suspect';
|
|
1811
|
+
case 'stale':
|
|
1812
|
+
return 'stale';
|
|
1813
|
+
default:
|
|
1814
|
+
return 'unknown';
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
async getTicket() {
|
|
1818
|
+
await this.ensureWasmRuntimeForP2P();
|
|
1819
|
+
const config = this.getRTCConfig();
|
|
1820
|
+
const irohConfig = this.options.transports?.iroh;
|
|
1821
|
+
// Use safe ticket (Relay Only) if:
|
|
1822
|
+
// 1. Strict Mode is enabled
|
|
1823
|
+
// 2. WebRTC Privacy Mode is enabled (Legacy/Compat)
|
|
1824
|
+
// 3. Iroh is explicitly configured with relayOnly: true
|
|
1825
|
+
const irohRelayOnly = typeof irohConfig === 'object' && irohConfig.relayOnly;
|
|
1826
|
+
if (this.options.strictMode || config?.privacyMode || irohRelayOnly) {
|
|
1827
|
+
return await this.wasmClient.endpoint_ticket();
|
|
1828
|
+
}
|
|
1829
|
+
return await this.wasmClient.endpoint_ticket();
|
|
1830
|
+
}
|
|
1831
|
+
async getNodeId() {
|
|
1832
|
+
await this.ensureWasmRuntimeForP2P();
|
|
1833
|
+
return this.localNodeId;
|
|
1834
|
+
}
|
|
1835
|
+
async getNodeIdFromTicket(ticket) {
|
|
1836
|
+
const trimmed = ticket.trim();
|
|
1837
|
+
if (!trimmed)
|
|
1838
|
+
return null;
|
|
1839
|
+
// Strip only the compound-ticket suffix; raw iroh tickets can contain dots.
|
|
1840
|
+
const { irohTicket } = splitCompoundTicket(trimmed);
|
|
1841
|
+
if (!this.wasmClient || typeof this.wasmClient.endpoint_id_from_ticket !== 'function') {
|
|
1842
|
+
return null;
|
|
1843
|
+
}
|
|
1844
|
+
try {
|
|
1845
|
+
return await this.wasmClient.endpoint_id_from_ticket(irohTicket);
|
|
1846
|
+
}
|
|
1847
|
+
catch {
|
|
1848
|
+
return null;
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
// -----------------------------------------------------------------------
|
|
1852
|
+
// Session grant helpers — the Rust wasm client owns token registration,
|
|
1853
|
+
// validation, and revoke semantics for short-lived share-style admission.
|
|
1854
|
+
// -----------------------------------------------------------------------
|
|
1855
|
+
/**
|
|
1856
|
+
* Generate a random session token, register it, and return a compound
|
|
1857
|
+
* ticket string with the token suffix appended.
|
|
1858
|
+
*
|
|
1859
|
+
* @param scope Logical label (e.g. "share"). Used for bulk-revoke.
|
|
1860
|
+
* @param maxConnections 0 = unlimited.
|
|
1861
|
+
*/
|
|
1862
|
+
async getEndpointTicketWithToken(grantScope, maxConnections = 0) {
|
|
1863
|
+
await this.ensureWasmRuntimeForP2P();
|
|
1864
|
+
if (this.wasmClient && typeof this.wasmClient.endpoint_ticket_with_token === 'function') {
|
|
1865
|
+
return await this.wasmClient.endpoint_ticket_with_token(grantScope, maxConnections);
|
|
1866
|
+
}
|
|
1867
|
+
const irohTicket = await this.getTicket();
|
|
1868
|
+
return this.buildCompoundTicketWithToken(irohTicket, grantScope, maxConnections);
|
|
1869
|
+
}
|
|
1870
|
+
/**
|
|
1871
|
+
* Build a compound ticket from an externally-provided iroh ticket string.
|
|
1872
|
+
* Generates a session token, registers it with the Rust wasm client when available,
|
|
1873
|
+
* and appends the token
|
|
1874
|
+
* payload as a base64url suffix separated by `.`.
|
|
1875
|
+
*
|
|
1876
|
+
* Used by RuntimeClient.getTicketWithToken() on native desktop where the
|
|
1877
|
+
* iroh ticket comes from the Rust backend (via Tauri IPC) rather than the
|
|
1878
|
+
* WASM node running in the webview.
|
|
1879
|
+
*/
|
|
1880
|
+
buildCompoundTicketWithToken(irohTicket, grantScope, maxConnections = 0) {
|
|
1881
|
+
const token = Client.generateSessionToken();
|
|
1882
|
+
if (this.wasmClient && typeof this.wasmClient.register_session_token === 'function') {
|
|
1883
|
+
this.wasmClient.register_session_token(token, grantScope, maxConnections);
|
|
1884
|
+
}
|
|
1885
|
+
return Client.buildCompoundTicketWithPayload(irohTicket, token, grantScope, maxConnections);
|
|
1886
|
+
}
|
|
1887
|
+
setManagedPresenceTicket(ticket) {
|
|
1888
|
+
const trimmed = ticket?.trim() || '';
|
|
1889
|
+
if (!trimmed) {
|
|
1890
|
+
this.managedPresenceGrant = null;
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
const { irohTicket, tokenSuffix } = splitCompoundTicket(trimmed);
|
|
1894
|
+
const payload = tokenSuffix ? decodeCompoundTicketPayload(tokenSuffix) : null;
|
|
1895
|
+
if (!payload?.t || !payload?.s) {
|
|
1896
|
+
throw new Error('Managed presence ticket must be a compound ticket with an embedded token payload');
|
|
1897
|
+
}
|
|
1898
|
+
this.managedPresenceGrant = {
|
|
1899
|
+
scope: payload.s,
|
|
1900
|
+
token: payload.t,
|
|
1901
|
+
maxConnections: typeof payload.m === 'number' ? payload.m : 0,
|
|
1902
|
+
compoundTicket: trimmed,
|
|
1903
|
+
irohTicket,
|
|
1904
|
+
};
|
|
1905
|
+
if (this.wasmClient && typeof this.wasmClient.register_session_token === 'function') {
|
|
1906
|
+
this.wasmClient.register_session_token(payload.t, payload.s, typeof payload.m === 'number' ? payload.m : 0);
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
clearManagedPresenceTicket() {
|
|
1910
|
+
this.managedPresenceGrant = null;
|
|
1911
|
+
}
|
|
1912
|
+
/** Register an externally-generated session token with the Rust wasm client. */
|
|
1913
|
+
registerSessionToken(token, grantScope, maxConnections) {
|
|
1914
|
+
if (this.wasmClient && typeof this.wasmClient.register_session_token === 'function') {
|
|
1915
|
+
this.wasmClient.register_session_token(token, grantScope, maxConnections);
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
/** Revoke a specific session token. */
|
|
1919
|
+
revokeSessionToken(token) {
|
|
1920
|
+
if (this.wasmClient && typeof this.wasmClient.revoke_session_token === 'function') {
|
|
1921
|
+
this.wasmClient.revoke_session_token(token);
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
/** Revoke all tokens that match the given scope. */
|
|
1925
|
+
async revokeTokensByScope(grantScope) {
|
|
1926
|
+
await this.ensureWasmRuntimeForP2P();
|
|
1927
|
+
if (!this.wasmClient || typeof this.wasmClient.revoke_tokens_by_scope !== 'function') {
|
|
1928
|
+
return [];
|
|
1929
|
+
}
|
|
1930
|
+
const affectedConnectionIds = await this.wasmClient.revoke_tokens_by_scope(grantScope);
|
|
1931
|
+
if (!Array.isArray(affectedConnectionIds) || affectedConnectionIds.length === 0) {
|
|
1932
|
+
return [];
|
|
1933
|
+
}
|
|
1934
|
+
const liveConnections = affectedConnectionIds
|
|
1935
|
+
.map((connectionId) => this.connections.get(connectionId))
|
|
1936
|
+
.filter((connection) => !!connection);
|
|
1937
|
+
await Promise.allSettled(liveConnections.map((connection) => connection.disconnect()));
|
|
1938
|
+
return affectedConnectionIds;
|
|
1939
|
+
}
|
|
1940
|
+
static generateSessionToken() {
|
|
1941
|
+
const bytes = new Uint8Array(24);
|
|
1942
|
+
crypto.getRandomValues(bytes);
|
|
1943
|
+
// base64url, no padding
|
|
1944
|
+
return btoa(String.fromCharCode(...bytes))
|
|
1945
|
+
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
1946
|
+
}
|
|
1947
|
+
static encodeSessionTokenPayload(token, grantScope, maxConnections) {
|
|
1948
|
+
return btoa(JSON.stringify({ t: token, s: grantScope, m: maxConnections }))
|
|
1949
|
+
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
1950
|
+
}
|
|
1951
|
+
static buildCompoundTicketWithPayload(irohTicket, token, grantScope, maxConnections) {
|
|
1952
|
+
return `${irohTicket}.${Client.encodeSessionTokenPayload(token, grantScope, maxConnections)}`;
|
|
1953
|
+
}
|
|
1954
|
+
async clearSessionTokens() {
|
|
1955
|
+
await this.ensureWasmRuntimeForP2P();
|
|
1956
|
+
this.clearManagedPresenceTicket();
|
|
1957
|
+
if (this.wasmClient && typeof this.wasmClient.clear_session_tokens === 'function') {
|
|
1958
|
+
this.wasmClient.clear_session_tokens();
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
async resolvePresenceTicket(ticketOverride) {
|
|
1962
|
+
const explicitTicket = ticketOverride?.trim();
|
|
1963
|
+
if (explicitTicket) {
|
|
1964
|
+
return explicitTicket;
|
|
1965
|
+
}
|
|
1966
|
+
if (this.managedPresenceGrant) {
|
|
1967
|
+
return this.refreshManagedPresenceTicketIfNeeded();
|
|
1968
|
+
}
|
|
1969
|
+
const configuredTicket = this.options.ticket?.trim();
|
|
1970
|
+
if (configuredTicket) {
|
|
1971
|
+
return configuredTicket;
|
|
1972
|
+
}
|
|
1973
|
+
return this.getTicket();
|
|
1974
|
+
}
|
|
1975
|
+
async refreshManagedPresenceTicketIfNeeded() {
|
|
1976
|
+
const grant = this.managedPresenceGrant;
|
|
1977
|
+
if (!grant) {
|
|
1978
|
+
return this.getTicket();
|
|
1979
|
+
}
|
|
1980
|
+
let nextIrohTicket = grant.irohTicket;
|
|
1981
|
+
try {
|
|
1982
|
+
nextIrohTicket = await this.getTicket();
|
|
1983
|
+
}
|
|
1984
|
+
catch {
|
|
1985
|
+
return grant.compoundTicket;
|
|
1986
|
+
}
|
|
1987
|
+
if (nextIrohTicket === grant.irohTicket) {
|
|
1988
|
+
return grant.compoundTicket;
|
|
1989
|
+
}
|
|
1990
|
+
grant.irohTicket = nextIrohTicket;
|
|
1991
|
+
grant.compoundTicket = Client.buildCompoundTicketWithPayload(nextIrohTicket, grant.token, grant.scope, grant.maxConnections);
|
|
1992
|
+
console.info('[Client][PRESENCE] rebuilt managed compound ticket after endpoint change', {
|
|
1993
|
+
grantScope: grant.scope,
|
|
1994
|
+
});
|
|
1995
|
+
return grant.compoundTicket;
|
|
1996
|
+
}
|
|
1997
|
+
async validateIncomingSessionToken(token, connectionId) {
|
|
1998
|
+
if (!this.wasmClient || typeof this.wasmClient.validate_session_token !== 'function') {
|
|
1999
|
+
return { ok: true };
|
|
2000
|
+
}
|
|
2001
|
+
try {
|
|
2002
|
+
let scope = '';
|
|
2003
|
+
if (connectionId &&
|
|
2004
|
+
typeof this.wasmClient.validate_session_token_for_connection === 'function') {
|
|
2005
|
+
scope = await this.wasmClient.validate_session_token_for_connection(token, connectionId);
|
|
2006
|
+
}
|
|
2007
|
+
else {
|
|
2008
|
+
scope = this.wasmClient.validate_session_token(token);
|
|
2009
|
+
}
|
|
2010
|
+
return { ok: true, scope: scope || undefined };
|
|
2011
|
+
}
|
|
2012
|
+
catch (error) {
|
|
2013
|
+
return {
|
|
2014
|
+
ok: false,
|
|
2015
|
+
reason: error?.message || `${error}`,
|
|
2016
|
+
};
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
/**
|
|
2020
|
+
* Start the signaling-backed presence loop owned by the Rust core.
|
|
2021
|
+
* A ticket can be provided explicitly, otherwise the current endpoint ticket is used.
|
|
2022
|
+
*/
|
|
2023
|
+
async startPresenceLoop(options) {
|
|
2024
|
+
await this.ensureWasmRuntimeForP2P();
|
|
2025
|
+
if (!this.localNodeId) {
|
|
2026
|
+
throw new Error('startPresenceLoop requires an initialized node');
|
|
2027
|
+
}
|
|
2028
|
+
const isPersonalWithSpaceKey = (this.options.discoveryMode === undefined || this.options.discoveryMode === 'personal') &&
|
|
2029
|
+
typeof this.options.spaceKey === 'string' &&
|
|
2030
|
+
this.options.spaceKey.trim().length > 0;
|
|
2031
|
+
// In personal mode there's no Firebase user — use the node ID as the
|
|
2032
|
+
// synthetic scope id so the Rust core can publish into the shared
|
|
2033
|
+
// spaces/{namespaceId}/devices collection without auth.
|
|
2034
|
+
const userId = this.currentUser?.id ?? (isPersonalWithSpaceKey ? this.localNodeId : null);
|
|
2035
|
+
if (!userId) {
|
|
2036
|
+
throw new Error('startPresenceLoop requires an authenticated user');
|
|
2037
|
+
}
|
|
2038
|
+
const ticket = await this.resolvePresenceTicket(options?.ticket);
|
|
2039
|
+
if (!ticket || !ticket.trim()) {
|
|
2040
|
+
throw new Error('startPresenceLoop requires a valid endpoint ticket');
|
|
2041
|
+
}
|
|
2042
|
+
this.signaling.startPresenceLoop(userId, this.localNodeId, options?.deviceName || this.options.deviceName || 'Unknown Device', ticket, options?.metadata);
|
|
2043
|
+
}
|
|
2044
|
+
/**
|
|
2045
|
+
* Mark local presence offline in the signaling backend.
|
|
2046
|
+
*/
|
|
2047
|
+
async setOffline() {
|
|
2048
|
+
if (!this.localNodeId) {
|
|
2049
|
+
return;
|
|
2050
|
+
}
|
|
2051
|
+
await this.signaling.setOffline(this.localNodeId);
|
|
2052
|
+
}
|
|
2053
|
+
/**
|
|
2054
|
+
* Check raw connectivity against a remote node id.
|
|
2055
|
+
*/
|
|
2056
|
+
async isConnected(nodeId) {
|
|
2057
|
+
await this.ensureWasmRuntimeForP2P();
|
|
2058
|
+
if (!nodeId || !nodeId.trim()) {
|
|
2059
|
+
return false;
|
|
2060
|
+
}
|
|
2061
|
+
if (this.wasmClient && typeof this.wasmClient.is_connected === 'function') {
|
|
2062
|
+
return this.wasmClient.is_connected(nodeId);
|
|
2063
|
+
}
|
|
2064
|
+
return this.signaling.isConnected(nodeId);
|
|
2065
|
+
}
|
|
2066
|
+
/**
|
|
2067
|
+
* Disconnect a remote node from the local connection manager.
|
|
2068
|
+
*/
|
|
2069
|
+
async disconnectNode(nodeId) {
|
|
2070
|
+
if (!nodeId || !nodeId.trim()) {
|
|
2071
|
+
return;
|
|
2072
|
+
}
|
|
2073
|
+
const matchingConnections = Array.from(this.connections.values()).filter((connection) => connection.deviceId === nodeId);
|
|
2074
|
+
await Promise.allSettled(matchingConnections.map((connection) => connection.disconnect()));
|
|
2075
|
+
if (this.wasmClient && typeof this.wasmClient.disconnect === 'function') {
|
|
2076
|
+
try {
|
|
2077
|
+
await this.wasmClient.disconnect(nodeId);
|
|
2078
|
+
}
|
|
2079
|
+
catch {
|
|
2080
|
+
// Best-effort at core-level disconnect; local manager disconnect already applied.
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
this.signaling.notifyDisconnectRequested(nodeId);
|
|
2084
|
+
}
|
|
2085
|
+
async getLocalDeviceId() {
|
|
2086
|
+
return this.signaling.getLocalDeviceId();
|
|
2087
|
+
}
|
|
2088
|
+
/**
|
|
2089
|
+
* Route a raw message received on a native iroh connection through token
|
|
2090
|
+
* validation. Called by RuntimeClient when NativeIpcBridge fires an
|
|
2091
|
+
* `iroh://message` event for an incoming native connection.
|
|
2092
|
+
*
|
|
2093
|
+
* If we have a TS Connection object for this peer (e.g. the client also
|
|
2094
|
+
* called connect()), route through handleMessage. Otherwise, for connections
|
|
2095
|
+
* that were accepted purely by the Rust protocol handler, perform token
|
|
2096
|
+
* validation directly.
|
|
2097
|
+
*
|
|
2098
|
+
* @returns true if the connection should be rejected (caller should disconnect)
|
|
2099
|
+
*/
|
|
2100
|
+
async acceptNativeMessage(connectionId, remoteNodeId, msg) {
|
|
2101
|
+
if (!remoteNodeId)
|
|
2102
|
+
return false;
|
|
2103
|
+
// Try to find an existing TS Connection object (bidirectional case).
|
|
2104
|
+
const connId = this.getDeterministicConnectionId(remoteNodeId);
|
|
2105
|
+
const conn = this.connections.get(connId);
|
|
2106
|
+
if (conn) {
|
|
2107
|
+
void this.handleMessage(conn, msg);
|
|
2108
|
+
return false;
|
|
2109
|
+
}
|
|
2110
|
+
// No TS Connection — this is an incoming-only native connection.
|
|
2111
|
+
// Validate incoming grant material if the runtime exposes a validator.
|
|
2112
|
+
if (msg &&
|
|
2113
|
+
typeof msg === 'object' &&
|
|
2114
|
+
msg.type === 'handshake' &&
|
|
2115
|
+
(msg.action === 'hello' || !msg.action)) {
|
|
2116
|
+
const result = await this.validateIncomingSessionToken(msg.sessionToken ?? '', connectionId ?? undefined);
|
|
2117
|
+
if (!result.ok) {
|
|
2118
|
+
console.warn('[Client][SESSION-TOKEN] Rejecting native connection — invalid session token', {
|
|
2119
|
+
remoteNodeId,
|
|
2120
|
+
reason: result.reason,
|
|
2121
|
+
hasToken: !!msg.sessionToken,
|
|
2122
|
+
});
|
|
2123
|
+
return true; // caller should disconnect
|
|
2124
|
+
}
|
|
2125
|
+
// Valid token — Rust already accepted; nothing more to do here.
|
|
2126
|
+
}
|
|
2127
|
+
return false;
|
|
2128
|
+
}
|
|
2129
|
+
async startListening() {
|
|
2130
|
+
if (this.listening)
|
|
2131
|
+
return;
|
|
2132
|
+
// Always ensure runtime is ready for listening/discovery
|
|
2133
|
+
await this.ensureWasmRuntimeForP2P();
|
|
2134
|
+
this.listening = true;
|
|
2135
|
+
const sharedHub = this.getSharedIncomingStreamHub();
|
|
2136
|
+
if (sharedHub) {
|
|
2137
|
+
sharedHub.addClient(this);
|
|
2138
|
+
}
|
|
2139
|
+
else if (!this.listenLoopTask) {
|
|
2140
|
+
this.listenLoopTask = this.listenLoop().finally(() => {
|
|
2141
|
+
this.listenLoopTask = null;
|
|
2142
|
+
});
|
|
2143
|
+
}
|
|
2144
|
+
const userId = this.signaling.currentUser?.id;
|
|
2145
|
+
if (userId && this.localNodeId) {
|
|
2146
|
+
const ticket = await this.resolvePresenceTicket();
|
|
2147
|
+
this.signaling.startPresenceLoop(userId, this.localNodeId, this.options.deviceName || 'Unknown Device', ticket, '' // metadata
|
|
2148
|
+
);
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
stopListening() {
|
|
2152
|
+
this.listening = false;
|
|
2153
|
+
this.getSharedIncomingStreamHub()?.removeClient(this);
|
|
2154
|
+
if (this.localNodeId) {
|
|
2155
|
+
this.signaling.setOffline(this.localNodeId);
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
getSharedIncomingStreamHub() {
|
|
2159
|
+
if (!this.wasmClient || typeof this.wasmClient.incoming_streams !== 'function') {
|
|
2160
|
+
return null;
|
|
2161
|
+
}
|
|
2162
|
+
const cacheKey = this.wasmClient;
|
|
2163
|
+
let hub = Client.incomingStreamHubs.get(cacheKey);
|
|
2164
|
+
if (!hub) {
|
|
2165
|
+
hub = new SharedIncomingStreamHub(this.wasmClient);
|
|
2166
|
+
Client.incomingStreamHubs.set(cacheKey, hub);
|
|
2167
|
+
}
|
|
2168
|
+
return hub;
|
|
2169
|
+
}
|
|
2170
|
+
nextIncomingStreamRetryDelayMs(attempt) {
|
|
2171
|
+
const backoff = Client.INCOMING_STREAM_RETRY_MIN_DELAY_MS * Math.pow(2, Math.max(0, attempt - 1));
|
|
2172
|
+
return Math.min(Client.INCOMING_STREAM_RETRY_MAX_DELAY_MS, backoff);
|
|
2173
|
+
}
|
|
2174
|
+
/**
|
|
2175
|
+
* Force an immediate presence update and reconnect snapshot.
|
|
2176
|
+
*/
|
|
2177
|
+
forceReconnectSnapshot() {
|
|
2178
|
+
this.signaling.forceReconnectSnapshot();
|
|
2179
|
+
}
|
|
2180
|
+
async updatePresenceInternal() {
|
|
2181
|
+
if (this.localNodeId) {
|
|
2182
|
+
const ticket = await this.resolvePresenceTicket();
|
|
2183
|
+
await this.updatePresence(true, this.options.deviceTTL, undefined, ticket);
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
async listenLoop() {
|
|
2187
|
+
let attempt = 0;
|
|
2188
|
+
while (this.listening) {
|
|
2189
|
+
if (!this.wasmClient || typeof this.wasmClient.incoming_streams !== 'function') {
|
|
2190
|
+
console.warn('[Client][INCOMING] incoming_streams is unavailable; stopping listen loop');
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2193
|
+
let reader = null;
|
|
2194
|
+
try {
|
|
2195
|
+
const streams = await this.wasmClient.incoming_streams();
|
|
2196
|
+
reader = streams.getReader();
|
|
2197
|
+
const activeReader = reader;
|
|
2198
|
+
attempt = 0;
|
|
2199
|
+
while (this.listening) {
|
|
2200
|
+
const { done, value } = await activeReader.read();
|
|
2201
|
+
if (done) {
|
|
2202
|
+
throw new Error('incoming stream reader closed unexpectedly');
|
|
2203
|
+
}
|
|
2204
|
+
if (!value) {
|
|
2205
|
+
continue;
|
|
2206
|
+
}
|
|
2207
|
+
console.debug('[Client][INCOMING] Received stream from WASM node', {
|
|
2208
|
+
type: value?.type || 'unknown',
|
|
2209
|
+
endpointId: value?.endpointId || value?.endpoint_id || 'unknown',
|
|
2210
|
+
});
|
|
2211
|
+
try {
|
|
2212
|
+
await this.handleIncomingStream(value);
|
|
2213
|
+
}
|
|
2214
|
+
catch (error) {
|
|
2215
|
+
const incoming = value;
|
|
2216
|
+
const streamType = typeof incoming.type === 'string' ? incoming.type : 'bi';
|
|
2217
|
+
const remoteNodeId = typeof incoming.endpointId === 'string'
|
|
2218
|
+
? incoming.endpointId
|
|
2219
|
+
: typeof incoming.endpoint_id === 'string'
|
|
2220
|
+
? incoming.endpoint_id
|
|
2221
|
+
: 'unknown';
|
|
2222
|
+
console.error('[Client][INCOMING] Failed to handle incoming stream', {
|
|
2223
|
+
remoteNodeId,
|
|
2224
|
+
type: streamType,
|
|
2225
|
+
error,
|
|
2226
|
+
});
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
catch (error) {
|
|
2231
|
+
if (!this.listening) {
|
|
2232
|
+
break;
|
|
2233
|
+
}
|
|
2234
|
+
attempt += 1;
|
|
2235
|
+
const delayMs = this.nextIncomingStreamRetryDelayMs(attempt);
|
|
2236
|
+
console.warn('[Client][INCOMING] Listen loop restarting after stream failure', {
|
|
2237
|
+
attempt,
|
|
2238
|
+
delayMs,
|
|
2239
|
+
error,
|
|
2240
|
+
});
|
|
2241
|
+
await this.sleep(delayMs);
|
|
2242
|
+
}
|
|
2243
|
+
finally {
|
|
2244
|
+
if (reader) {
|
|
2245
|
+
// Avoid canceling the wasm-backed incoming-stream reader here.
|
|
2246
|
+
// The underlying wasm-streams source uses a one-shot cancel
|
|
2247
|
+
// closure, and teardown paths (especially in React dev remounts)
|
|
2248
|
+
// can end up invoking that callback more than once.
|
|
2249
|
+
try {
|
|
2250
|
+
reader.releaseLock();
|
|
2251
|
+
}
|
|
2252
|
+
catch {
|
|
2253
|
+
// no-op
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
prependChunkToReadable(readable, firstChunk) {
|
|
2260
|
+
const sourceReader = readable.getReader();
|
|
2261
|
+
let emittedFirst = false;
|
|
2262
|
+
return new ReadableStream({
|
|
2263
|
+
async pull(controller) {
|
|
2264
|
+
if (!emittedFirst) {
|
|
2265
|
+
emittedFirst = true;
|
|
2266
|
+
controller.enqueue(firstChunk);
|
|
2267
|
+
return;
|
|
2268
|
+
}
|
|
2269
|
+
const { done, value } = await sourceReader.read();
|
|
2270
|
+
if (done) {
|
|
2271
|
+
controller.close();
|
|
2272
|
+
sourceReader.releaseLock();
|
|
2273
|
+
return;
|
|
2274
|
+
}
|
|
2275
|
+
if (value)
|
|
2276
|
+
controller.enqueue(value);
|
|
2277
|
+
},
|
|
2278
|
+
async cancel(reason) {
|
|
2279
|
+
await sourceReader.cancel(reason).catch(() => undefined);
|
|
2280
|
+
},
|
|
2281
|
+
});
|
|
2282
|
+
}
|
|
2283
|
+
async cancelUnhandledIncomingUniStream(stream, remoteNodeId) {
|
|
2284
|
+
if (!stream || typeof stream !== 'object') {
|
|
2285
|
+
return;
|
|
2286
|
+
}
|
|
2287
|
+
try {
|
|
2288
|
+
const readable = stream;
|
|
2289
|
+
if (typeof readable.cancel === 'function') {
|
|
2290
|
+
await readable.cancel('unhandled-incoming-uni');
|
|
2291
|
+
openRtcDebugLog('[Client][INCOMING] Cancelled unhandled uni stream', {
|
|
2292
|
+
remoteNodeId,
|
|
2293
|
+
});
|
|
2294
|
+
return;
|
|
2295
|
+
}
|
|
2296
|
+
if (typeof readable.getReader === 'function') {
|
|
2297
|
+
const reader = readable.getReader();
|
|
2298
|
+
try {
|
|
2299
|
+
await reader.cancel('unhandled-incoming-uni');
|
|
2300
|
+
}
|
|
2301
|
+
finally {
|
|
2302
|
+
try {
|
|
2303
|
+
reader.releaseLock();
|
|
2304
|
+
}
|
|
2305
|
+
catch {
|
|
2306
|
+
// no-op
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
openRtcDebugLog('[Client][INCOMING] Cancelled unhandled uni stream via reader', {
|
|
2310
|
+
remoteNodeId,
|
|
2311
|
+
});
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
catch (error) {
|
|
2315
|
+
console.warn('[Client][INCOMING] Failed to cancel unhandled uni stream', {
|
|
2316
|
+
remoteNodeId,
|
|
2317
|
+
error,
|
|
2318
|
+
});
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
prependBytesToReader(sourceReader, prefix) {
|
|
2322
|
+
let emittedPrefix = prefix.byteLength === 0;
|
|
2323
|
+
return new ReadableStream({
|
|
2324
|
+
async pull(controller) {
|
|
2325
|
+
if (!emittedPrefix) {
|
|
2326
|
+
emittedPrefix = true;
|
|
2327
|
+
controller.enqueue(prefix);
|
|
2328
|
+
return;
|
|
2329
|
+
}
|
|
2330
|
+
const { done, value } = await sourceReader.read();
|
|
2331
|
+
if (done) {
|
|
2332
|
+
controller.close();
|
|
2333
|
+
return;
|
|
2334
|
+
}
|
|
2335
|
+
if (value) {
|
|
2336
|
+
controller.enqueue(value);
|
|
2337
|
+
}
|
|
2338
|
+
},
|
|
2339
|
+
async cancel(reason) {
|
|
2340
|
+
await sourceReader.cancel(reason).catch(() => undefined);
|
|
2341
|
+
},
|
|
2342
|
+
});
|
|
2343
|
+
}
|
|
2344
|
+
concatBytes(parts) {
|
|
2345
|
+
const total = parts.reduce((sum, part) => sum + part.byteLength, 0);
|
|
2346
|
+
const merged = new Uint8Array(total);
|
|
2347
|
+
let offset = 0;
|
|
2348
|
+
for (const part of parts) {
|
|
2349
|
+
merged.set(part, offset);
|
|
2350
|
+
offset += part.byteLength;
|
|
2351
|
+
}
|
|
2352
|
+
return merged;
|
|
2353
|
+
}
|
|
2354
|
+
encodeChannelEnvelope(channelId, metadata) {
|
|
2355
|
+
const normalizedChannelId = channelId.trim();
|
|
2356
|
+
if (!normalizedChannelId) {
|
|
2357
|
+
throw new Error('Channel envelope requires a non-empty channel id.');
|
|
2358
|
+
}
|
|
2359
|
+
const encoder = new TextEncoder();
|
|
2360
|
+
const channelBytes = encoder.encode(normalizedChannelId);
|
|
2361
|
+
const metadataBytes = metadata ? encoder.encode(JSON.stringify(metadata)) : new Uint8Array(0);
|
|
2362
|
+
const header = new Uint8Array(Client.CHANNEL_ENVELOPE_HEADER_BYTES + channelBytes.byteLength + metadataBytes.byteLength);
|
|
2363
|
+
const view = new DataView(header.buffer);
|
|
2364
|
+
header[0] = Client.CHANNEL_ENVELOPE_MAGIC;
|
|
2365
|
+
header[1] = Client.CHANNEL_ENVELOPE_VERSION;
|
|
2366
|
+
view.setUint16(2, channelBytes.byteLength, false);
|
|
2367
|
+
view.setUint16(4, metadataBytes.byteLength, false);
|
|
2368
|
+
header.set(channelBytes, Client.CHANNEL_ENVELOPE_HEADER_BYTES);
|
|
2369
|
+
header.set(metadataBytes, Client.CHANNEL_ENVELOPE_HEADER_BYTES + channelBytes.byteLength);
|
|
2370
|
+
return header;
|
|
2371
|
+
}
|
|
2372
|
+
wrapChannelWritable(writable, channelId, metadata) {
|
|
2373
|
+
const envelope = this.encodeChannelEnvelope(channelId, metadata);
|
|
2374
|
+
const writer = writable.getWriter();
|
|
2375
|
+
let envelopeWritten = false;
|
|
2376
|
+
const ensureEnvelope = async () => {
|
|
2377
|
+
if (envelopeWritten) {
|
|
2378
|
+
return;
|
|
2379
|
+
}
|
|
2380
|
+
envelopeWritten = true;
|
|
2381
|
+
await writer.write(envelope);
|
|
2382
|
+
};
|
|
2383
|
+
return new WritableStream({
|
|
2384
|
+
write: async (chunk) => {
|
|
2385
|
+
await ensureEnvelope();
|
|
2386
|
+
await writer.write(chunk);
|
|
2387
|
+
},
|
|
2388
|
+
close: async () => {
|
|
2389
|
+
await ensureEnvelope();
|
|
2390
|
+
try {
|
|
2391
|
+
await writer.close();
|
|
2392
|
+
}
|
|
2393
|
+
finally {
|
|
2394
|
+
writer.releaseLock();
|
|
2395
|
+
}
|
|
2396
|
+
},
|
|
2397
|
+
abort: async (reason) => {
|
|
2398
|
+
try {
|
|
2399
|
+
await writer.abort(reason);
|
|
2400
|
+
}
|
|
2401
|
+
finally {
|
|
2402
|
+
writer.releaseLock();
|
|
2403
|
+
}
|
|
2404
|
+
},
|
|
2405
|
+
});
|
|
2406
|
+
}
|
|
2407
|
+
async extractIncomingChannelEnvelope(stream, remoteNodeId) {
|
|
2408
|
+
if (!this.isBiStreamLike(stream)) {
|
|
2409
|
+
return { stream, channel: null };
|
|
2410
|
+
}
|
|
2411
|
+
let reader;
|
|
2412
|
+
try {
|
|
2413
|
+
reader = stream.recv.getReader();
|
|
2414
|
+
}
|
|
2415
|
+
catch (error) {
|
|
2416
|
+
console.warn('[Client][CHANNEL] Incoming stream recv reader unavailable; skipping channel envelope inspection', {
|
|
2417
|
+
remoteNodeId,
|
|
2418
|
+
error,
|
|
2419
|
+
});
|
|
2420
|
+
return { stream, channel: null };
|
|
2421
|
+
}
|
|
2422
|
+
const chunks = [];
|
|
2423
|
+
let bufferedLength = 0;
|
|
2424
|
+
try {
|
|
2425
|
+
while (bufferedLength < Client.CHANNEL_ENVELOPE_HEADER_BYTES) {
|
|
2426
|
+
const { done, value } = await reader.read();
|
|
2427
|
+
if (done) {
|
|
2428
|
+
break;
|
|
2429
|
+
}
|
|
2430
|
+
if (!value || value.byteLength === 0) {
|
|
2431
|
+
continue;
|
|
2432
|
+
}
|
|
2433
|
+
const chunk = new Uint8Array(value);
|
|
2434
|
+
chunks.push(chunk);
|
|
2435
|
+
bufferedLength += chunk.byteLength;
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
catch (error) {
|
|
2439
|
+
console.warn('[Client][CHANNEL] Failed reading incoming channel envelope prefix', {
|
|
2440
|
+
remoteNodeId,
|
|
2441
|
+
error,
|
|
2442
|
+
});
|
|
2443
|
+
reader.releaseLock();
|
|
2444
|
+
return { stream, channel: null };
|
|
2445
|
+
}
|
|
2446
|
+
const buffered = this.concatBytes(chunks);
|
|
2447
|
+
if (buffered.byteLength < Client.CHANNEL_ENVELOPE_HEADER_BYTES ||
|
|
2448
|
+
buffered[0] !== Client.CHANNEL_ENVELOPE_MAGIC ||
|
|
2449
|
+
buffered[1] !== Client.CHANNEL_ENVELOPE_VERSION) {
|
|
2450
|
+
const rebuilt = this.rebuildBiIncomingStream(stream, this.prependBytesToReader(reader, buffered));
|
|
2451
|
+
return { stream: rebuilt, channel: null };
|
|
2452
|
+
}
|
|
2453
|
+
const view = new DataView(buffered.buffer, buffered.byteOffset, buffered.byteLength);
|
|
2454
|
+
const channelLength = view.getUint16(2, false);
|
|
2455
|
+
const metadataLength = view.getUint16(4, false);
|
|
2456
|
+
const totalEnvelopeBytes = Client.CHANNEL_ENVELOPE_HEADER_BYTES + channelLength + metadataLength;
|
|
2457
|
+
let extendedBuffered = buffered;
|
|
2458
|
+
if (extendedBuffered.byteLength < totalEnvelopeBytes) {
|
|
2459
|
+
try {
|
|
2460
|
+
while (extendedBuffered.byteLength < totalEnvelopeBytes) {
|
|
2461
|
+
const { done, value } = await reader.read();
|
|
2462
|
+
if (done) {
|
|
2463
|
+
break;
|
|
2464
|
+
}
|
|
2465
|
+
if (!value || value.byteLength === 0) {
|
|
2466
|
+
continue;
|
|
2467
|
+
}
|
|
2468
|
+
extendedBuffered = this.concatBytes([extendedBuffered, new Uint8Array(value)]);
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
catch (error) {
|
|
2472
|
+
console.warn('[Client][CHANNEL] Failed extending incoming channel envelope read', {
|
|
2473
|
+
remoteNodeId,
|
|
2474
|
+
error,
|
|
2475
|
+
});
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
if (extendedBuffered.byteLength < totalEnvelopeBytes || channelLength === 0) {
|
|
2479
|
+
const rebuilt = this.rebuildBiIncomingStream(stream, this.prependBytesToReader(reader, extendedBuffered));
|
|
2480
|
+
return { stream: rebuilt, channel: null };
|
|
2481
|
+
}
|
|
2482
|
+
const channelStart = Client.CHANNEL_ENVELOPE_HEADER_BYTES;
|
|
2483
|
+
const channelEnd = channelStart + channelLength;
|
|
2484
|
+
const metadataEnd = channelEnd + metadataLength;
|
|
2485
|
+
const decoder = new TextDecoder();
|
|
2486
|
+
const channelId = decoder.decode(extendedBuffered.slice(channelStart, channelEnd)).trim();
|
|
2487
|
+
let metadata = null;
|
|
2488
|
+
if (metadataLength > 0) {
|
|
2489
|
+
try {
|
|
2490
|
+
const parsed = JSON.parse(decoder.decode(extendedBuffered.slice(channelEnd, metadataEnd)));
|
|
2491
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
2492
|
+
metadata = parsed;
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
catch (error) {
|
|
2496
|
+
console.warn('[Client][CHANNEL] Failed to parse incoming channel metadata', {
|
|
2497
|
+
remoteNodeId,
|
|
2498
|
+
channelId,
|
|
2499
|
+
error,
|
|
2500
|
+
});
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
if (!channelId) {
|
|
2504
|
+
const rebuilt = this.rebuildBiIncomingStream(stream, this.prependBytesToReader(reader, extendedBuffered));
|
|
2505
|
+
return { stream: rebuilt, channel: null };
|
|
2506
|
+
}
|
|
2507
|
+
const rebuilt = this.rebuildBiIncomingStream(stream, this.prependBytesToReader(reader, extendedBuffered.slice(totalEnvelopeBytes)));
|
|
2508
|
+
return {
|
|
2509
|
+
stream: rebuilt,
|
|
2510
|
+
channel: {
|
|
2511
|
+
channelId,
|
|
2512
|
+
metadata,
|
|
2513
|
+
},
|
|
2514
|
+
};
|
|
2515
|
+
}
|
|
2516
|
+
isBiStreamLike(stream) {
|
|
2517
|
+
try {
|
|
2518
|
+
const candidate = stream;
|
|
2519
|
+
return !!candidate
|
|
2520
|
+
&& !!candidate.send
|
|
2521
|
+
&& typeof candidate.send.getWriter === 'function'
|
|
2522
|
+
&& !!candidate.recv
|
|
2523
|
+
&& typeof candidate.recv.getReader === 'function';
|
|
2524
|
+
}
|
|
2525
|
+
catch {
|
|
2526
|
+
return false;
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
materializeIncomingBiStream(stream, fallbackEndpointId) {
|
|
2530
|
+
const candidate = stream;
|
|
2531
|
+
if (!candidate) {
|
|
2532
|
+
return null;
|
|
2533
|
+
}
|
|
2534
|
+
try {
|
|
2535
|
+
const send = candidate.send;
|
|
2536
|
+
const recv = candidate.recv;
|
|
2537
|
+
if (!send || typeof send.getWriter !== 'function') {
|
|
2538
|
+
return null;
|
|
2539
|
+
}
|
|
2540
|
+
if (!recv || typeof recv.getReader !== 'function') {
|
|
2541
|
+
return null;
|
|
2542
|
+
}
|
|
2543
|
+
const endpointId = typeof candidate.endpoint_id === 'string' && candidate.endpoint_id.trim().length > 0
|
|
2544
|
+
? candidate.endpoint_id
|
|
2545
|
+
: fallbackEndpointId;
|
|
2546
|
+
return {
|
|
2547
|
+
send,
|
|
2548
|
+
recv,
|
|
2549
|
+
endpoint_id: endpointId,
|
|
2550
|
+
};
|
|
2551
|
+
}
|
|
2552
|
+
catch {
|
|
2553
|
+
return null;
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
rebuildBiIncomingStream(stream, recv) {
|
|
2557
|
+
return {
|
|
2558
|
+
send: stream.send,
|
|
2559
|
+
recv,
|
|
2560
|
+
endpoint_id: typeof stream.endpoint_id === 'string' ? stream.endpoint_id : '',
|
|
2561
|
+
};
|
|
2562
|
+
}
|
|
2563
|
+
async classifyBiIncomingStream(stream) {
|
|
2564
|
+
if (!this.isBiStreamLike(stream)) {
|
|
2565
|
+
return { stream, protocolHint: 'unknown' };
|
|
2566
|
+
}
|
|
2567
|
+
const reader = stream.recv.getReader();
|
|
2568
|
+
let firstRead;
|
|
2569
|
+
try {
|
|
2570
|
+
firstRead = await reader.read();
|
|
2571
|
+
}
|
|
2572
|
+
catch {
|
|
2573
|
+
reader.releaseLock();
|
|
2574
|
+
return { stream, protocolHint: 'unknown' };
|
|
2575
|
+
}
|
|
2576
|
+
reader.releaseLock();
|
|
2577
|
+
if (firstRead.done || !firstRead.value || firstRead.value.byteLength === 0) {
|
|
2578
|
+
console.debug('[Client][INCOMING] classifyBiIncomingStream: empty read', {
|
|
2579
|
+
done: firstRead.done,
|
|
2580
|
+
hasValue: !!firstRead.value,
|
|
2581
|
+
byteLength: firstRead.value?.byteLength,
|
|
2582
|
+
});
|
|
2583
|
+
return { stream, protocolHint: 'unknown' };
|
|
2584
|
+
}
|
|
2585
|
+
const firstChunk = new Uint8Array(firstRead.value);
|
|
2586
|
+
const firstByte = firstChunk[0];
|
|
2587
|
+
console.debug('[Client][INCOMING] classifyBiIncomingStream: first byte', {
|
|
2588
|
+
firstByte: '0x' + firstByte.toString(16),
|
|
2589
|
+
EXPLICIT_PROTOCOL_BYTE: '0x' + Client.EXPLICIT_PROTOCOL_BYTE.toString(16),
|
|
2590
|
+
isExplicit: firstByte === Client.EXPLICIT_PROTOCOL_BYTE,
|
|
2591
|
+
chunkLength: firstChunk.length,
|
|
2592
|
+
});
|
|
2593
|
+
const rebuilt = this.rebuildBiIncomingStream(stream, this.prependChunkToReadable(stream.recv, firstChunk));
|
|
2594
|
+
if (firstByte === Client.EXPLICIT_PROTOCOL_BYTE) {
|
|
2595
|
+
return { stream: rebuilt, protocolHint: 'explicit' };
|
|
2596
|
+
}
|
|
2597
|
+
return { stream: rebuilt, protocolHint: 'control' };
|
|
2598
|
+
}
|
|
2599
|
+
async detectNativeMainStream(stream, remoteNodeId) {
|
|
2600
|
+
if (!this.isBiStreamLike(stream)) {
|
|
2601
|
+
return { handled: false, stream, protocolHint: 'unknown' };
|
|
2602
|
+
}
|
|
2603
|
+
const reader = stream.recv.getReader();
|
|
2604
|
+
const chunks = [];
|
|
2605
|
+
let bufferedLength = 0;
|
|
2606
|
+
const requiredPrefixBytes = 1 + 4;
|
|
2607
|
+
let reachedEof = false;
|
|
2608
|
+
try {
|
|
2609
|
+
while (bufferedLength < requiredPrefixBytes) {
|
|
2610
|
+
const { done, value } = await reader.read();
|
|
2611
|
+
if (done) {
|
|
2612
|
+
reachedEof = true;
|
|
2613
|
+
break;
|
|
2614
|
+
}
|
|
2615
|
+
if (!value || value.byteLength === 0) {
|
|
2616
|
+
continue;
|
|
2617
|
+
}
|
|
2618
|
+
chunks.push(new Uint8Array(value));
|
|
2619
|
+
bufferedLength += value.byteLength;
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
catch (error) {
|
|
2623
|
+
console.warn('[Client][NATIVE-MAIN] Failed reading incoming prefix', {
|
|
2624
|
+
remoteNodeId,
|
|
2625
|
+
error,
|
|
2626
|
+
});
|
|
2627
|
+
reader.releaseLock();
|
|
2628
|
+
return { handled: false, stream, protocolHint: 'control' };
|
|
2629
|
+
}
|
|
2630
|
+
const buffered = this.concatBytes(chunks);
|
|
2631
|
+
if (buffered.byteLength < requiredPrefixBytes || buffered[0] !== Client.NATIVE_MAIN_PROTOCOL_BYTE) {
|
|
2632
|
+
const rebuilt = this.rebuildBiIncomingStream(stream, this.prependBytesToReader(reader, buffered));
|
|
2633
|
+
return {
|
|
2634
|
+
handled: false,
|
|
2635
|
+
stream: rebuilt,
|
|
2636
|
+
protocolHint: buffered[0] === Client.EXPLICIT_PROTOCOL_BYTE ? 'explicit' : 'control',
|
|
2637
|
+
};
|
|
2638
|
+
}
|
|
2639
|
+
const labelLen = new DataView(buffered.buffer, buffered.byteOffset, buffered.byteLength).getUint32(1, false);
|
|
2640
|
+
if (labelLen === 0 || labelLen > Client.NATIVE_MAIN_MAX_LABEL_BYTES) {
|
|
2641
|
+
const rebuilt = this.rebuildBiIncomingStream(stream, this.prependBytesToReader(reader, buffered));
|
|
2642
|
+
return { handled: false, stream: rebuilt, protocolHint: 'control' };
|
|
2643
|
+
}
|
|
2644
|
+
const totalPrefixBytes = 1 + 4 + labelLen;
|
|
2645
|
+
let extendedBuffered = buffered;
|
|
2646
|
+
if (extendedBuffered.byteLength < totalPrefixBytes && !reachedEof) {
|
|
2647
|
+
const extraChunks = [extendedBuffered];
|
|
2648
|
+
let currentLength = extendedBuffered.byteLength;
|
|
2649
|
+
try {
|
|
2650
|
+
while (currentLength < totalPrefixBytes) {
|
|
2651
|
+
const { done, value } = await reader.read();
|
|
2652
|
+
if (done) {
|
|
2653
|
+
reachedEof = true;
|
|
2654
|
+
break;
|
|
2655
|
+
}
|
|
2656
|
+
if (!value || value.byteLength === 0) {
|
|
2657
|
+
continue;
|
|
2658
|
+
}
|
|
2659
|
+
const chunk = new Uint8Array(value);
|
|
2660
|
+
extraChunks.push(chunk);
|
|
2661
|
+
currentLength += chunk.byteLength;
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
catch (error) {
|
|
2665
|
+
console.warn('[Client][NATIVE-MAIN] Failed extending prefix read', {
|
|
2666
|
+
remoteNodeId,
|
|
2667
|
+
error,
|
|
2668
|
+
});
|
|
2669
|
+
}
|
|
2670
|
+
extendedBuffered = this.concatBytes(extraChunks);
|
|
2671
|
+
}
|
|
2672
|
+
if (extendedBuffered.byteLength < totalPrefixBytes) {
|
|
2673
|
+
const rebuilt = this.rebuildBiIncomingStream(stream, this.prependBytesToReader(reader, extendedBuffered));
|
|
2674
|
+
return { handled: false, stream: rebuilt, protocolHint: 'control' };
|
|
2675
|
+
}
|
|
2676
|
+
const labelBytes = extendedBuffered.slice(5, totalPrefixBytes);
|
|
2677
|
+
const label = new TextDecoder().decode(labelBytes);
|
|
2678
|
+
if (label !== Client.NATIVE_MAIN_LABEL) {
|
|
2679
|
+
const rebuilt = this.rebuildBiIncomingStream(stream, this.prependBytesToReader(reader, extendedBuffered));
|
|
2680
|
+
return { handled: false, stream: rebuilt, protocolHint: 'control' };
|
|
2681
|
+
}
|
|
2682
|
+
const remainder = extendedBuffered.slice(totalPrefixBytes);
|
|
2683
|
+
const rebuilt = this.rebuildBiIncomingStream(stream, this.prependBytesToReader(reader, remainder));
|
|
2684
|
+
openRtcDebugInfo('[Client][NATIVE-MAIN] Detected desktop native main stream', {
|
|
2685
|
+
remoteNodeId,
|
|
2686
|
+
label,
|
|
2687
|
+
});
|
|
2688
|
+
return {
|
|
2689
|
+
handled: true,
|
|
2690
|
+
stream: rebuilt,
|
|
2691
|
+
protocolHint: 'native-main',
|
|
2692
|
+
};
|
|
2693
|
+
}
|
|
2694
|
+
async resolveLocalHandshakePayload() {
|
|
2695
|
+
const localDeviceId = (await this.getLocalDeviceId().catch(() => null)) ||
|
|
2696
|
+
this.deviceId ||
|
|
2697
|
+
this.options.localDeviceId;
|
|
2698
|
+
if (!localDeviceId) {
|
|
2699
|
+
throw new Error('Local device id unavailable for native handshake response');
|
|
2700
|
+
}
|
|
2701
|
+
return {
|
|
2702
|
+
deviceId: localDeviceId,
|
|
2703
|
+
deviceName: this.options.deviceName || 'Browser Device',
|
|
2704
|
+
platformType: 'web',
|
|
2705
|
+
capabilities: {
|
|
2706
|
+
canHost: false,
|
|
2707
|
+
canSync: true,
|
|
2708
|
+
readOnly: false,
|
|
2709
|
+
},
|
|
2710
|
+
protocolVersion: Client.NATIVE_MAIN_PROTOCOL_VERSION,
|
|
2711
|
+
timestamp: Date.now(),
|
|
2712
|
+
};
|
|
2713
|
+
}
|
|
2714
|
+
getDeterministicConnectionId(remoteNodeId) {
|
|
2715
|
+
if (!this.localNodeId) {
|
|
2716
|
+
return `native-main-${remoteNodeId}`;
|
|
2717
|
+
}
|
|
2718
|
+
return this.localNodeId <= remoteNodeId
|
|
2719
|
+
? `${this.localNodeId}-${remoteNodeId}`
|
|
2720
|
+
: `${remoteNodeId}-${this.localNodeId}`;
|
|
2721
|
+
}
|
|
2722
|
+
getNativeMainConnectionId(remoteNodeId) {
|
|
2723
|
+
return this.getDeterministicConnectionId(remoteNodeId);
|
|
2724
|
+
}
|
|
2725
|
+
hasRustPeerLifecycleProjection() {
|
|
2726
|
+
if (this.signaling && typeof this.signaling.supportsRustPeerLifecycleProjection === 'function'
|
|
2727
|
+
&& this.signaling.supportsRustPeerLifecycleProjection()) {
|
|
2728
|
+
return true;
|
|
2729
|
+
}
|
|
2730
|
+
return !!(this.wasmClient &&
|
|
2731
|
+
(typeof this.wasmClient.peer_sessions === 'function'
|
|
2732
|
+
|| typeof this.wasmClient.peer_snapshots === 'function'));
|
|
2733
|
+
}
|
|
2734
|
+
shouldProjectNativeMainLifecycle() {
|
|
2735
|
+
return !this.hasRustPeerLifecycleProjection();
|
|
2736
|
+
}
|
|
2737
|
+
emitNativeMainConnectionState(remoteNodeId, state, deviceId) {
|
|
2738
|
+
if (!this.shouldProjectNativeMainLifecycle()) {
|
|
2739
|
+
return;
|
|
2740
|
+
}
|
|
2741
|
+
const connectionId = this.getNativeMainConnectionId(remoteNodeId);
|
|
2742
|
+
const previous = this.peerLifecycle && typeof this.peerLifecycle.get === 'function'
|
|
2743
|
+
? this.peerLifecycle.get(connectionId)
|
|
2744
|
+
: undefined;
|
|
2745
|
+
const next = {
|
|
2746
|
+
connectionId,
|
|
2747
|
+
deviceId,
|
|
2748
|
+
remoteNodeId,
|
|
2749
|
+
state,
|
|
2750
|
+
transportState: state === 'connected' ? 'connected' : 'closed',
|
|
2751
|
+
protocolState: state === 'connected' ? 'routable' : 'closed',
|
|
2752
|
+
routable: state === 'connected',
|
|
2753
|
+
updatedAt: Date.now(),
|
|
2754
|
+
};
|
|
2755
|
+
const unchanged = previous
|
|
2756
|
+
&& previous.status === next.state
|
|
2757
|
+
&& previous.deviceId === next.deviceId
|
|
2758
|
+
&& previous.nodeId === next.remoteNodeId
|
|
2759
|
+
&& previous.transportState === next.transportState
|
|
2760
|
+
&& previous.protocolState === next.protocolState
|
|
2761
|
+
&& previous.routable === next.routable;
|
|
2762
|
+
this.peerLifecycle.upsert({
|
|
2763
|
+
deviceId: next.deviceId,
|
|
2764
|
+
nodeId: next.remoteNodeId,
|
|
2765
|
+
connectionId: next.connectionId,
|
|
2766
|
+
status: next.state === 'connected' ? 'connected' : 'closed',
|
|
2767
|
+
health: next.state === 'connected' ? 'healthy' : 'stale',
|
|
2768
|
+
transportState: next.transportState,
|
|
2769
|
+
protocolState: next.protocolState,
|
|
2770
|
+
routable: next.routable,
|
|
2771
|
+
lastSeenAt: next.updatedAt,
|
|
2772
|
+
});
|
|
2773
|
+
if (unchanged || typeof window === 'undefined') {
|
|
2774
|
+
return;
|
|
2775
|
+
}
|
|
2776
|
+
window.dispatchEvent(new CustomEvent(Client.CONNECTION_STATE_CHANGED_EVENT, {
|
|
2777
|
+
detail: {
|
|
2778
|
+
connectionId: next.connectionId,
|
|
2779
|
+
deviceId: next.deviceId ?? null,
|
|
2780
|
+
remoteNodeId: next.remoteNodeId,
|
|
2781
|
+
state: next.state,
|
|
2782
|
+
transportState: next.transportState,
|
|
2783
|
+
protocolState: next.protocolState,
|
|
2784
|
+
routable: next.routable,
|
|
2785
|
+
updatedAt: next.updatedAt,
|
|
2786
|
+
},
|
|
2787
|
+
}));
|
|
2788
|
+
}
|
|
2789
|
+
applyBackendConnectionState(state) {
|
|
2790
|
+
if (!state?.connectionId) {
|
|
2791
|
+
return;
|
|
2792
|
+
}
|
|
2793
|
+
if (!state.deviceId && !state.deviceIdHint && !state.remoteNodeId) {
|
|
2794
|
+
// Raw connection-state events can arrive before Rust has resolved the
|
|
2795
|
+
// authoritative peer identity. Keep those events diagnostic-only
|
|
2796
|
+
// until the runtime exposes at least one stable identifier.
|
|
2797
|
+
return;
|
|
2798
|
+
}
|
|
2799
|
+
openRtcDebug('[Client] applyBackendConnectionState', {
|
|
2800
|
+
connectionId: state.connectionId,
|
|
2801
|
+
deviceId: state.deviceId ?? null,
|
|
2802
|
+
deviceIdHint: state.deviceIdHint ?? null,
|
|
2803
|
+
remoteNodeId: state.remoteNodeId ?? null,
|
|
2804
|
+
state: state.state,
|
|
2805
|
+
transportState: state.transportState ?? null,
|
|
2806
|
+
protocolState: state.protocolState ?? null,
|
|
2807
|
+
routable: state.routable ?? null,
|
|
2808
|
+
updatedAt: state.updatedAt ?? null,
|
|
2809
|
+
error: state.error ?? null,
|
|
2810
|
+
});
|
|
2811
|
+
const trustProjection = this.getConnectionTrustProjection({
|
|
2812
|
+
connectionId: state.connectionId,
|
|
2813
|
+
deviceId: state.deviceId ?? undefined,
|
|
2814
|
+
deviceIdHint: state.deviceIdHint ?? undefined,
|
|
2815
|
+
nodeId: state.remoteNodeId ?? undefined,
|
|
2816
|
+
});
|
|
2817
|
+
const effectiveRoutable = trustProjection ? trustProjection.routable : state.routable;
|
|
2818
|
+
const effectiveProtocolState = trustProjection?.protocolState ?? state.protocolState;
|
|
2819
|
+
const effectiveError = trustProjection?.error ?? state.error;
|
|
2820
|
+
const effectiveHealth = trustProjection?.health
|
|
2821
|
+
?? (state.routable || state.readinessState === 'routable'
|
|
2822
|
+
? 'healthy'
|
|
2823
|
+
: state.state === 'failed' || state.state === 'closed'
|
|
2824
|
+
? 'stale'
|
|
2825
|
+
: 'unknown');
|
|
2826
|
+
this.peerLifecycle.upsert({
|
|
2827
|
+
deviceId: (trustProjection?.deviceId ?? state.deviceId) ?? undefined,
|
|
2828
|
+
deviceIdHint: state.deviceIdHint ?? undefined,
|
|
2829
|
+
nodeId: state.remoteNodeId ?? undefined,
|
|
2830
|
+
connectionId: state.connectionId,
|
|
2831
|
+
status: state.state === 'connected'
|
|
2832
|
+
? 'connected'
|
|
2833
|
+
: state.state === 'connecting' || state.state === 'new'
|
|
2834
|
+
? 'connecting'
|
|
2835
|
+
: state.state === 'failed'
|
|
2836
|
+
? 'failed'
|
|
2837
|
+
: state.state === 'closed'
|
|
2838
|
+
? 'closed'
|
|
2839
|
+
: 'disconnected',
|
|
2840
|
+
health: effectiveHealth,
|
|
2841
|
+
transportState: state.transportState,
|
|
2842
|
+
protocolState: effectiveProtocolState,
|
|
2843
|
+
routable: effectiveRoutable,
|
|
2844
|
+
error: effectiveError,
|
|
2845
|
+
lastSeenAt: state.updatedAt ?? Date.now(),
|
|
2846
|
+
});
|
|
2847
|
+
}
|
|
2848
|
+
encodeNativeMainFrame(message) {
|
|
2849
|
+
const payload = new TextEncoder().encode(JSON.stringify(message));
|
|
2850
|
+
const labelBytes = new TextEncoder().encode(Client.NATIVE_MAIN_LABEL);
|
|
2851
|
+
const envelope = new Uint8Array(1 + 4 + labelBytes.byteLength + 4 + payload.byteLength);
|
|
2852
|
+
const view = new DataView(envelope.buffer);
|
|
2853
|
+
let offset = 0;
|
|
2854
|
+
envelope[offset] = Client.NATIVE_MAIN_PROTOCOL_BYTE;
|
|
2855
|
+
offset += 1;
|
|
2856
|
+
view.setUint32(offset, labelBytes.byteLength, false);
|
|
2857
|
+
offset += 4;
|
|
2858
|
+
envelope.set(labelBytes, offset);
|
|
2859
|
+
offset += labelBytes.byteLength;
|
|
2860
|
+
view.setUint32(offset, payload.byteLength, false);
|
|
2861
|
+
offset += 4;
|
|
2862
|
+
envelope.set(payload, offset);
|
|
2863
|
+
return envelope;
|
|
2864
|
+
}
|
|
2865
|
+
async writeNativeMainMessage(writer, message) {
|
|
2866
|
+
await writer.write(this.encodeNativeMainFrame(message));
|
|
2867
|
+
}
|
|
2868
|
+
async handleNativeHandshakeRequest(writer, remoteNodeId, request) {
|
|
2869
|
+
const localPayload = await this.resolveLocalHandshakePayload();
|
|
2870
|
+
const remoteDeviceId = request.payload && typeof request.payload.deviceId === 'string'
|
|
2871
|
+
? request.payload.deviceId
|
|
2872
|
+
: undefined;
|
|
2873
|
+
const response = {
|
|
2874
|
+
channel: 'handshake',
|
|
2875
|
+
action: 'response',
|
|
2876
|
+
requestId: request.requestId,
|
|
2877
|
+
payload: {
|
|
2878
|
+
accepted: true,
|
|
2879
|
+
...localPayload,
|
|
2880
|
+
},
|
|
2881
|
+
timestamp: Date.now(),
|
|
2882
|
+
};
|
|
2883
|
+
await this.writeNativeMainMessage(writer, response);
|
|
2884
|
+
this.emitNativeMainConnectionState(remoteNodeId, 'connected', remoteDeviceId);
|
|
2885
|
+
openRtcDebugInfo('[Client][NATIVE-MAIN] Responded to desktop handshake', {
|
|
2886
|
+
remoteNodeId,
|
|
2887
|
+
requestId: request.requestId ?? null,
|
|
2888
|
+
localDeviceId: localPayload.deviceId,
|
|
2889
|
+
});
|
|
2890
|
+
}
|
|
2891
|
+
async handleNativeSessionTokenRequest(writer, remoteNodeId, request) {
|
|
2892
|
+
const token = request.payload && typeof request.payload.token === 'string'
|
|
2893
|
+
? request.payload.token.trim()
|
|
2894
|
+
: '';
|
|
2895
|
+
const connectionId = this.getNativeMainConnectionId(remoteNodeId);
|
|
2896
|
+
const result = await this.validateIncomingSessionToken(token, connectionId);
|
|
2897
|
+
const response = result.ok
|
|
2898
|
+
? {
|
|
2899
|
+
channel: 'session-token',
|
|
2900
|
+
action: 'response',
|
|
2901
|
+
requestId: request.requestId,
|
|
2902
|
+
payload: {
|
|
2903
|
+
approved: true,
|
|
2904
|
+
scope: result.scope ?? null,
|
|
2905
|
+
connectionId,
|
|
2906
|
+
},
|
|
2907
|
+
timestamp: Date.now(),
|
|
2908
|
+
}
|
|
2909
|
+
: {
|
|
2910
|
+
channel: 'session-token',
|
|
2911
|
+
action: 'response',
|
|
2912
|
+
requestId: request.requestId,
|
|
2913
|
+
payload: {
|
|
2914
|
+
approved: false,
|
|
2915
|
+
reason: result.reason ?? 'session-token-rejected',
|
|
2916
|
+
connectionId,
|
|
2917
|
+
},
|
|
2918
|
+
timestamp: Date.now(),
|
|
2919
|
+
};
|
|
2920
|
+
await this.writeNativeMainMessage(writer, response);
|
|
2921
|
+
if (result.ok) {
|
|
2922
|
+
console.info('[Client][AUTH] host approved managed session token', {
|
|
2923
|
+
remoteNodeId,
|
|
2924
|
+
connectionId,
|
|
2925
|
+
scope: result.scope ?? null,
|
|
2926
|
+
});
|
|
2927
|
+
return;
|
|
2928
|
+
}
|
|
2929
|
+
console.warn('[Client][AUTH] host rejected managed session token', {
|
|
2930
|
+
remoteNodeId,
|
|
2931
|
+
connectionId,
|
|
2932
|
+
reason: result.reason ?? 'session-token-rejected',
|
|
2933
|
+
hasToken: token.length > 0,
|
|
2934
|
+
});
|
|
2935
|
+
}
|
|
2936
|
+
async handleNativeMainStream(stream, remoteNodeId) {
|
|
2937
|
+
if (!this.isBiStreamLike(stream)) {
|
|
2938
|
+
return;
|
|
2939
|
+
}
|
|
2940
|
+
const reader = stream.recv.getReader();
|
|
2941
|
+
const writer = stream.send.getWriter();
|
|
2942
|
+
const chunks = [];
|
|
2943
|
+
let buffered = new Uint8Array(0);
|
|
2944
|
+
try {
|
|
2945
|
+
while (true) {
|
|
2946
|
+
while (buffered.byteLength < 4) {
|
|
2947
|
+
const { done, value } = await reader.read();
|
|
2948
|
+
if (done) {
|
|
2949
|
+
if (this.shouldProjectNativeMainLifecycle()) {
|
|
2950
|
+
this.emitNativeMainConnectionState(remoteNodeId, 'closed');
|
|
2951
|
+
}
|
|
2952
|
+
else {
|
|
2953
|
+
void this.refreshPeerSnapshot().catch(() => { });
|
|
2954
|
+
}
|
|
2955
|
+
openRtcDebugInfo('[Client][NATIVE-MAIN] Remote closed native main stream', {
|
|
2956
|
+
remoteNodeId,
|
|
2957
|
+
});
|
|
2958
|
+
return;
|
|
2959
|
+
}
|
|
2960
|
+
if (value && value.byteLength > 0) {
|
|
2961
|
+
chunks.push(new Uint8Array(value));
|
|
2962
|
+
buffered = this.concatBytes(chunks);
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
const view = new DataView(buffered.buffer, buffered.byteOffset, buffered.byteLength);
|
|
2966
|
+
const messageLen = view.getUint32(0, false);
|
|
2967
|
+
if (messageLen === 0) {
|
|
2968
|
+
buffered = buffered.slice(4);
|
|
2969
|
+
chunks.length = 0;
|
|
2970
|
+
if (buffered.byteLength > 0) {
|
|
2971
|
+
chunks.push(buffered);
|
|
2972
|
+
}
|
|
2973
|
+
continue;
|
|
2974
|
+
}
|
|
2975
|
+
while (buffered.byteLength < 4 + messageLen) {
|
|
2976
|
+
const { done, value } = await reader.read();
|
|
2977
|
+
if (done) {
|
|
2978
|
+
if (this.shouldProjectNativeMainLifecycle()) {
|
|
2979
|
+
this.emitNativeMainConnectionState(remoteNodeId, 'closed');
|
|
2980
|
+
}
|
|
2981
|
+
else {
|
|
2982
|
+
void this.refreshPeerSnapshot().catch(() => { });
|
|
2983
|
+
}
|
|
2984
|
+
console.warn('[Client][NATIVE-MAIN] Native main stream ended mid-frame', {
|
|
2985
|
+
remoteNodeId,
|
|
2986
|
+
messageLen,
|
|
2987
|
+
bufferedBytes: buffered.byteLength,
|
|
2988
|
+
});
|
|
2989
|
+
return;
|
|
2990
|
+
}
|
|
2991
|
+
if (value && value.byteLength > 0) {
|
|
2992
|
+
chunks.length = 0;
|
|
2993
|
+
buffered = this.concatBytes([buffered, new Uint8Array(value)]);
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2996
|
+
const payloadBytes = buffered.slice(4, 4 + messageLen);
|
|
2997
|
+
buffered = buffered.slice(4 + messageLen);
|
|
2998
|
+
chunks.length = 0;
|
|
2999
|
+
if (buffered.byteLength > 0) {
|
|
3000
|
+
chunks.push(buffered);
|
|
3001
|
+
}
|
|
3002
|
+
let message;
|
|
3003
|
+
try {
|
|
3004
|
+
message = JSON.parse(new TextDecoder().decode(payloadBytes));
|
|
3005
|
+
}
|
|
3006
|
+
catch (error) {
|
|
3007
|
+
console.warn('[Client][NATIVE-MAIN] Failed to parse native main message', {
|
|
3008
|
+
remoteNodeId,
|
|
3009
|
+
error,
|
|
3010
|
+
});
|
|
3011
|
+
continue;
|
|
3012
|
+
}
|
|
3013
|
+
openRtcDebugInfo('[Client][NATIVE-MAIN] Received native main message', {
|
|
3014
|
+
remoteNodeId,
|
|
3015
|
+
channel: message.channel,
|
|
3016
|
+
action: message.action,
|
|
3017
|
+
requestId: message.requestId ?? null,
|
|
3018
|
+
});
|
|
3019
|
+
if (message.channel === 'handshake' && message.action === 'request') {
|
|
3020
|
+
await this.handleNativeHandshakeRequest(writer, remoteNodeId, message);
|
|
3021
|
+
continue;
|
|
3022
|
+
}
|
|
3023
|
+
if (message.channel === 'session-token' && message.action === 'request') {
|
|
3024
|
+
await this.handleNativeSessionTokenRequest(writer, remoteNodeId, message);
|
|
3025
|
+
}
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
catch (error) {
|
|
3029
|
+
if (this.shouldProjectNativeMainLifecycle()) {
|
|
3030
|
+
this.emitNativeMainConnectionState(remoteNodeId, 'closed');
|
|
3031
|
+
}
|
|
3032
|
+
else {
|
|
3033
|
+
void this.refreshPeerSnapshot().catch(() => { });
|
|
3034
|
+
}
|
|
3035
|
+
console.error('[Client][NATIVE-MAIN] Native main stream handler failed', {
|
|
3036
|
+
remoteNodeId,
|
|
3037
|
+
error,
|
|
3038
|
+
});
|
|
3039
|
+
}
|
|
3040
|
+
finally {
|
|
3041
|
+
writer.releaseLock();
|
|
3042
|
+
reader.releaseLock();
|
|
3043
|
+
}
|
|
3044
|
+
}
|
|
3045
|
+
async isCoreConnectionHealthy(remoteNodeId) {
|
|
3046
|
+
if (!remoteNodeId)
|
|
3047
|
+
return false;
|
|
3048
|
+
if (this.wasmClient && typeof this.wasmClient.is_connected === 'function') {
|
|
3049
|
+
try {
|
|
3050
|
+
return !!(await this.wasmClient.is_connected(remoteNodeId));
|
|
3051
|
+
}
|
|
3052
|
+
catch {
|
|
3053
|
+
// fall through
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
3056
|
+
try {
|
|
3057
|
+
return await this.signaling.isConnected(remoteNodeId);
|
|
3058
|
+
}
|
|
3059
|
+
catch {
|
|
3060
|
+
return false;
|
|
3061
|
+
}
|
|
3062
|
+
}
|
|
3063
|
+
async waitForStableConnection(connection) {
|
|
3064
|
+
const trustState = this.initializeConnectionTrust(connection);
|
|
3065
|
+
if (this.wasmClient && typeof this.wasmClient.wait_for_settled_peer === 'function') {
|
|
3066
|
+
try {
|
|
3067
|
+
const settled = await this.wasmClient.wait_for_settled_peer(connection.deviceId, Client.CONNECTION_STABLE_TIMEOUT_MS);
|
|
3068
|
+
if (settled?.settledReady && (!trustState.required || trustState.verified)) {
|
|
3069
|
+
return true;
|
|
3070
|
+
}
|
|
3071
|
+
if (trustState.required && trustState.failed) {
|
|
3072
|
+
return false;
|
|
3073
|
+
}
|
|
3074
|
+
if (!this.usesTicketOnlySignalingMode()) {
|
|
3075
|
+
return false;
|
|
3076
|
+
}
|
|
3077
|
+
}
|
|
3078
|
+
catch {
|
|
3079
|
+
// Fall back to the legacy transport-only stability gate below.
|
|
3080
|
+
}
|
|
3081
|
+
}
|
|
3082
|
+
const startedAt = Date.now();
|
|
3083
|
+
let stableSince = null;
|
|
3084
|
+
while (Date.now() - startedAt < Client.CONNECTION_STABLE_TIMEOUT_MS) {
|
|
3085
|
+
if (connection.isClosed)
|
|
3086
|
+
return false;
|
|
3087
|
+
if (trustState.required && trustState.failed) {
|
|
3088
|
+
return false;
|
|
3089
|
+
}
|
|
3090
|
+
const healthy = await this.isCoreConnectionHealthy(connection.deviceId);
|
|
3091
|
+
if (healthy) {
|
|
3092
|
+
if (stableSince === null)
|
|
3093
|
+
stableSince = Date.now();
|
|
3094
|
+
if (Date.now() - stableSince >= Client.CONNECTION_STABLE_WINDOW_MS
|
|
3095
|
+
&& (!trustState.required || trustState.verified)) {
|
|
3096
|
+
return true;
|
|
3097
|
+
}
|
|
3098
|
+
}
|
|
3099
|
+
else {
|
|
3100
|
+
stableSince = null;
|
|
3101
|
+
}
|
|
3102
|
+
await new Promise((resolve) => setTimeout(resolve, Client.CONNECTION_STABLE_POLL_MS));
|
|
3103
|
+
}
|
|
3104
|
+
return false;
|
|
3105
|
+
}
|
|
3106
|
+
announceConnectionWhenStable(connection) {
|
|
3107
|
+
void (async () => {
|
|
3108
|
+
const stable = await this.waitForStableConnection(connection);
|
|
3109
|
+
if (!stable) {
|
|
3110
|
+
if (!connection.isClosed) {
|
|
3111
|
+
console.warn(`[Client] Suppressing unstable connection ${connection.id}`);
|
|
3112
|
+
}
|
|
3113
|
+
const trustState = this.connectionTrustStates.get(connection.id);
|
|
3114
|
+
if (trustState?.required && !connection.isClosed) {
|
|
3115
|
+
await this.enforceConnectionTrustFailure(connection, trustState.failureReason ?? 'trusted-device-proof-timeout').catch(() => undefined);
|
|
3116
|
+
}
|
|
3117
|
+
return;
|
|
3118
|
+
}
|
|
3119
|
+
if (this.announcedConnections.has(connection.id))
|
|
3120
|
+
return;
|
|
3121
|
+
this.announcedConnections.add(connection.id);
|
|
3122
|
+
this.connectionListeners.forEach((cb) => cb(connection));
|
|
3123
|
+
})();
|
|
3124
|
+
}
|
|
3125
|
+
async normalizeIncomingStreamEvent(incoming, options) {
|
|
3126
|
+
// Compatibility check: OLD behavior returned BiStream directly?
|
|
3127
|
+
// New behavior returns { type, stream, endpointId }
|
|
3128
|
+
// Normalize
|
|
3129
|
+
let type = 'bi';
|
|
3130
|
+
let stream = incoming;
|
|
3131
|
+
let endpointId = '';
|
|
3132
|
+
let channel = null;
|
|
3133
|
+
if ('type' in incoming) {
|
|
3134
|
+
// New structure
|
|
3135
|
+
type = incoming.type;
|
|
3136
|
+
stream = incoming.stream;
|
|
3137
|
+
endpointId = incoming.endpointId;
|
|
3138
|
+
}
|
|
3139
|
+
else {
|
|
3140
|
+
endpointId = incoming.endpoint_id ?? '';
|
|
3141
|
+
}
|
|
3142
|
+
console.debug('[Client][INCOMING] handleIncomingStream called', {
|
|
3143
|
+
hasType: 'type' in incoming,
|
|
3144
|
+
type,
|
|
3145
|
+
endpointId,
|
|
3146
|
+
});
|
|
3147
|
+
let protocolHint = 'unknown';
|
|
3148
|
+
if (type === 'bi') {
|
|
3149
|
+
const materializedStream = this.materializeIncomingBiStream(stream, endpointId);
|
|
3150
|
+
if (!materializedStream || !this.isBiStreamLike(materializedStream)) {
|
|
3151
|
+
let hasSend = false;
|
|
3152
|
+
let hasRecv = false;
|
|
3153
|
+
try {
|
|
3154
|
+
hasSend = !!stream?.send;
|
|
3155
|
+
}
|
|
3156
|
+
catch { }
|
|
3157
|
+
try {
|
|
3158
|
+
hasRecv = !!stream?.recv;
|
|
3159
|
+
}
|
|
3160
|
+
catch { }
|
|
3161
|
+
console.error('[Client][INCOMING] Invalid bidirectional stream payload', {
|
|
3162
|
+
remoteNodeId: endpointId,
|
|
3163
|
+
hasSend,
|
|
3164
|
+
hasRecv,
|
|
3165
|
+
});
|
|
3166
|
+
return {
|
|
3167
|
+
type: 'bi',
|
|
3168
|
+
stream,
|
|
3169
|
+
remoteNodeId: endpointId,
|
|
3170
|
+
protocolHint: 'unknown',
|
|
3171
|
+
channel: null,
|
|
3172
|
+
invalid: true,
|
|
3173
|
+
};
|
|
3174
|
+
}
|
|
3175
|
+
stream = materializedStream;
|
|
3176
|
+
if (options?.inspectChannelEnvelope) {
|
|
3177
|
+
const channelEnvelope = await this.extractIncomingChannelEnvelope(stream, endpointId);
|
|
3178
|
+
stream = channelEnvelope.stream;
|
|
3179
|
+
channel = channelEnvelope.channel;
|
|
3180
|
+
}
|
|
3181
|
+
const classified = await this.classifyBiIncomingStream(stream);
|
|
3182
|
+
stream = classified.stream;
|
|
3183
|
+
protocolHint = classified.protocolHint;
|
|
3184
|
+
if (protocolHint !== 'explicit') {
|
|
3185
|
+
const nativeMain = await this.detectNativeMainStream(stream, endpointId);
|
|
3186
|
+
stream = nativeMain.stream;
|
|
3187
|
+
if (nativeMain.protocolHint === 'native-main') {
|
|
3188
|
+
protocolHint = 'native-main';
|
|
3189
|
+
}
|
|
3190
|
+
}
|
|
3191
|
+
}
|
|
3192
|
+
return {
|
|
3193
|
+
type: type,
|
|
3194
|
+
stream,
|
|
3195
|
+
remoteNodeId: endpointId,
|
|
3196
|
+
protocolHint,
|
|
3197
|
+
channel,
|
|
3198
|
+
};
|
|
3199
|
+
}
|
|
3200
|
+
hasIncomingChannelListener(channelId) {
|
|
3201
|
+
const normalizedChannelId = channelId.trim();
|
|
3202
|
+
if (!normalizedChannelId) {
|
|
3203
|
+
return false;
|
|
3204
|
+
}
|
|
3205
|
+
return (this.channelStreamListeners?.get(normalizedChannelId)?.size ?? 0) > 0;
|
|
3206
|
+
}
|
|
3207
|
+
hasAnyIncomingChannelListeners() {
|
|
3208
|
+
if (!this.channelStreamListeners) {
|
|
3209
|
+
return false;
|
|
3210
|
+
}
|
|
3211
|
+
for (const listeners of this.channelStreamListeners.values()) {
|
|
3212
|
+
if (listeners.size > 0) {
|
|
3213
|
+
return true;
|
|
3214
|
+
}
|
|
3215
|
+
}
|
|
3216
|
+
return false;
|
|
3217
|
+
}
|
|
3218
|
+
dispatchIncomingChannelListeners(listenerEvent) {
|
|
3219
|
+
const channelId = listenerEvent.channel?.channelId?.trim();
|
|
3220
|
+
if (!channelId) {
|
|
3221
|
+
return false;
|
|
3222
|
+
}
|
|
3223
|
+
const listeners = this.channelStreamListeners?.get(channelId);
|
|
3224
|
+
if (!listeners) {
|
|
3225
|
+
return false;
|
|
3226
|
+
}
|
|
3227
|
+
for (const callback of listeners) {
|
|
3228
|
+
if (callback(listenerEvent) === true) {
|
|
3229
|
+
return true;
|
|
3230
|
+
}
|
|
3231
|
+
}
|
|
3232
|
+
return false;
|
|
3233
|
+
}
|
|
3234
|
+
dispatchIncomingGenericListeners(listenerEvent) {
|
|
3235
|
+
let handled = false;
|
|
3236
|
+
for (const callback of this.streamListeners ?? []) {
|
|
3237
|
+
handled = callback(listenerEvent) === true || handled;
|
|
3238
|
+
}
|
|
3239
|
+
return handled;
|
|
3240
|
+
}
|
|
3241
|
+
async handleUnhandledIncomingStreamEvent(listenerEvent) {
|
|
3242
|
+
const { type, stream, remoteNodeId, protocolHint } = listenerEvent;
|
|
3243
|
+
if (listenerEvent.invalid) {
|
|
3244
|
+
return;
|
|
3245
|
+
}
|
|
3246
|
+
if (type === 'uni') {
|
|
3247
|
+
await this.cancelUnhandledIncomingUniStream(stream, remoteNodeId);
|
|
3248
|
+
return;
|
|
3249
|
+
}
|
|
3250
|
+
if (type === 'bi' && protocolHint === 'native-main') {
|
|
3251
|
+
openRtcDebugInfo('[Client][INCOMING] Handling native desktop main stream', {
|
|
3252
|
+
remoteNodeId,
|
|
3253
|
+
});
|
|
3254
|
+
void this.handleNativeMainStream(stream, remoteNodeId).catch((error) => {
|
|
3255
|
+
console.error('[Client][NATIVE-MAIN] Background handler crashed', {
|
|
3256
|
+
remoteNodeId,
|
|
3257
|
+
error,
|
|
3258
|
+
});
|
|
3259
|
+
});
|
|
3260
|
+
return;
|
|
3261
|
+
}
|
|
3262
|
+
// Legacy Connection handling for FIRST Bidirectional Stream
|
|
3263
|
+
// If we don't have a connection for this peer yet, create one using this stream.
|
|
3264
|
+
// This assumes the first stream is the "main" signaling/control channel.
|
|
3265
|
+
if (type === 'bi' && protocolHint !== 'explicit') {
|
|
3266
|
+
const remoteId = remoteNodeId;
|
|
3267
|
+
const connId = this.getDeterministicConnectionId(remoteId);
|
|
3268
|
+
if (!this.connections.has(connId)) {
|
|
3269
|
+
// New connection!
|
|
3270
|
+
const biStream = stream;
|
|
3271
|
+
if (!this.isBiStreamLike(biStream)) {
|
|
3272
|
+
console.error('[Client][INCOMING] Unable to construct connection from invalid bi stream', {
|
|
3273
|
+
remoteNodeId: remoteId,
|
|
3274
|
+
});
|
|
3275
|
+
return;
|
|
3276
|
+
}
|
|
3277
|
+
const connection = new Connection(connId, this.localNodeId, remoteId, biStream.send.getWriter(), biStream.recv.getReader(), {
|
|
3278
|
+
rtcConfig: this.getRTCConfig(),
|
|
3279
|
+
transportContext: {
|
|
3280
|
+
priorities: this.options.transportPriority || ['webrtc', 'moq', 'iroh'],
|
|
3281
|
+
disableIrohFallback: this.options.disableIrohFallback || false,
|
|
3282
|
+
isMoQReady: () => this.moqState === 'ready',
|
|
3283
|
+
sendMoQ: (data) => this.sendMoQData(remoteId, data)
|
|
3284
|
+
}
|
|
3285
|
+
});
|
|
3286
|
+
this.initializeConnectionTrust(connection, this.knownDeviceIdsByNodeId.get(remoteId) ?? null);
|
|
3287
|
+
this.registerConnection(connection);
|
|
3288
|
+
this.announceConnectionWhenStable(connection);
|
|
3289
|
+
void this.sendTransportHandshake(connection, 'hello').catch((error) => {
|
|
3290
|
+
console.warn('[Client][TRUST] Failed to send incoming handshake', {
|
|
3291
|
+
connectionId: connection.id,
|
|
3292
|
+
remoteNodeId: connection.remoteNodeId,
|
|
3293
|
+
error,
|
|
3294
|
+
});
|
|
3295
|
+
});
|
|
3296
|
+
}
|
|
3297
|
+
}
|
|
3298
|
+
}
|
|
3299
|
+
async handleIncomingStream(incoming) {
|
|
3300
|
+
const listenerEvent = await this.normalizeIncomingStreamEvent(incoming, {
|
|
3301
|
+
inspectChannelEnvelope: this.hasAnyIncomingChannelListeners(),
|
|
3302
|
+
});
|
|
3303
|
+
const handledByChannel = this.dispatchIncomingChannelListeners(listenerEvent);
|
|
3304
|
+
if (handledByChannel) {
|
|
3305
|
+
return;
|
|
3306
|
+
}
|
|
3307
|
+
const handledByGenericListeners = this.dispatchIncomingGenericListeners(listenerEvent);
|
|
3308
|
+
if (handledByGenericListeners) {
|
|
3309
|
+
return;
|
|
3310
|
+
}
|
|
3311
|
+
await this.handleUnhandledIncomingStreamEvent(listenerEvent);
|
|
3312
|
+
}
|
|
3313
|
+
registerConnection(conn) {
|
|
3314
|
+
this.connections.set(conn.id, conn);
|
|
3315
|
+
if (!this.hasRustPeerLifecycleProjection()) {
|
|
3316
|
+
this.applyConnectionTrustProjection(conn);
|
|
3317
|
+
}
|
|
3318
|
+
else {
|
|
3319
|
+
void this.refreshPeerSnapshot().catch(() => { });
|
|
3320
|
+
}
|
|
3321
|
+
conn.onMessage((msg) => {
|
|
3322
|
+
void this.handleMessage(conn, msg);
|
|
3323
|
+
});
|
|
3324
|
+
conn.onDisconnect(() => {
|
|
3325
|
+
this.connections.delete(conn.id);
|
|
3326
|
+
this.connectionTrustStates.delete(conn.id);
|
|
3327
|
+
this.connectionChannelIds.delete(conn.id);
|
|
3328
|
+
// Reflect the transport close immediately so peer-state watchers do
|
|
3329
|
+
// not remain stuck on a stale connected snapshot while the Rust
|
|
3330
|
+
// projection catches up.
|
|
3331
|
+
this.peerLifecycle.removeConnection(conn.id);
|
|
3332
|
+
this.peerLifecycle.updateHealth(conn.id, 'stale', 'closed');
|
|
3333
|
+
if (this.hasRustPeerLifecycleProjection()) {
|
|
3334
|
+
void this.refreshPeerSnapshot().catch(() => { });
|
|
3335
|
+
}
|
|
3336
|
+
const announced = this.announcedConnections.delete(conn.id);
|
|
3337
|
+
if (announced) {
|
|
3338
|
+
this.disconnectionListeners.forEach(cb => cb(conn));
|
|
3339
|
+
}
|
|
3340
|
+
});
|
|
3341
|
+
}
|
|
3342
|
+
syncConnectionPeerLifecycle(conn) {
|
|
3343
|
+
const promotionEligible = this.resolvePromotionEligibility({
|
|
3344
|
+
connectionId: conn.id,
|
|
3345
|
+
nodeId: conn.remoteNodeId,
|
|
3346
|
+
});
|
|
3347
|
+
const trustProjection = this.getConnectionTrustProjection({
|
|
3348
|
+
connectionId: conn.id,
|
|
3349
|
+
nodeId: conn.remoteNodeId,
|
|
3350
|
+
});
|
|
3351
|
+
if (trustProjection) {
|
|
3352
|
+
this.peerLifecycle.upsert({
|
|
3353
|
+
peerId: trustProjection.deviceId ?? undefined,
|
|
3354
|
+
deviceId: trustProjection.deviceId ?? undefined,
|
|
3355
|
+
deviceIdHint: trustProjection.deviceId ?? undefined,
|
|
3356
|
+
nodeId: conn.deviceId,
|
|
3357
|
+
connectionId: conn.id,
|
|
3358
|
+
status: conn.isClosed ? 'closed' : 'connected',
|
|
3359
|
+
health: trustProjection.health,
|
|
3360
|
+
transportState: conn.isClosed ? 'closed' : 'connected',
|
|
3361
|
+
protocolState: trustProjection.protocolState,
|
|
3362
|
+
routable: trustProjection.routable,
|
|
3363
|
+
promotionEligible,
|
|
3364
|
+
error: trustProjection.error,
|
|
3365
|
+
lastSeenAt: Date.now(),
|
|
3366
|
+
});
|
|
3367
|
+
return;
|
|
3368
|
+
}
|
|
3369
|
+
const existing = this.peerLifecycle.get(conn.id) ||
|
|
3370
|
+
this.peerLifecycle
|
|
3371
|
+
.list()
|
|
3372
|
+
.find((peer) => peer.connectionIds.includes(conn.id) || peer.nodeId === conn.deviceId);
|
|
3373
|
+
const peerId = existing?.peerId || existing?.deviceId || existing?.deviceIdHint;
|
|
3374
|
+
const deviceId = existing?.deviceId;
|
|
3375
|
+
const deviceIdHint = existing?.deviceIdHint;
|
|
3376
|
+
// Do not synthesize a public peer identity from transport metadata.
|
|
3377
|
+
if (!peerId && !deviceId && !deviceIdHint) {
|
|
3378
|
+
return;
|
|
3379
|
+
}
|
|
3380
|
+
this.peerLifecycle.upsert({
|
|
3381
|
+
peerId,
|
|
3382
|
+
deviceId,
|
|
3383
|
+
deviceIdHint,
|
|
3384
|
+
nodeId: conn.deviceId,
|
|
3385
|
+
connectionId: conn.id,
|
|
3386
|
+
status: conn.isClosed ? 'closed' : 'connected',
|
|
3387
|
+
health: conn.isClosed ? 'stale' : 'healthy',
|
|
3388
|
+
promotionEligible,
|
|
3389
|
+
lastSeenAt: Date.now(),
|
|
3390
|
+
});
|
|
3391
|
+
}
|
|
3392
|
+
getPeerCandidates(id) {
|
|
3393
|
+
const peer = this.peerLifecycle.get(id);
|
|
3394
|
+
const candidates = new Set();
|
|
3395
|
+
const values = [
|
|
3396
|
+
id,
|
|
3397
|
+
peer?.peerId,
|
|
3398
|
+
peer?.deviceId,
|
|
3399
|
+
peer?.nodeId,
|
|
3400
|
+
peer?.connectionId,
|
|
3401
|
+
...(peer?.connectionIds ?? []),
|
|
3402
|
+
];
|
|
3403
|
+
values.forEach((value) => {
|
|
3404
|
+
if (!value)
|
|
3405
|
+
return;
|
|
3406
|
+
const normalized = value.trim().toLowerCase();
|
|
3407
|
+
if (normalized) {
|
|
3408
|
+
candidates.add(normalized);
|
|
3409
|
+
}
|
|
3410
|
+
});
|
|
3411
|
+
return candidates;
|
|
3412
|
+
}
|
|
3413
|
+
// --- Listeners ---
|
|
3414
|
+
async updatePresence(isOnline = true, ttlMs = 300000, metadata, ticketOverride) {
|
|
3415
|
+
if (!this.wasmClient)
|
|
3416
|
+
return;
|
|
3417
|
+
const userId = this.currentUser?.id;
|
|
3418
|
+
const isPersonalWithSpaceKey = (this.options.discoveryMode === undefined || this.options.discoveryMode === 'personal') &&
|
|
3419
|
+
typeof this.options.spaceKey === 'string' &&
|
|
3420
|
+
this.options.spaceKey.trim().length > 0;
|
|
3421
|
+
if (!userId && !isPersonalWithSpaceKey)
|
|
3422
|
+
return;
|
|
3423
|
+
const nodeId = this.localNodeId;
|
|
3424
|
+
if (!nodeId)
|
|
3425
|
+
return;
|
|
3426
|
+
const ticket = await this.resolvePresenceTicket(ticketOverride);
|
|
3427
|
+
if (!ticket || !ticket.trim()) {
|
|
3428
|
+
throw new Error('updatePresence requires a valid endpoint ticket');
|
|
3429
|
+
}
|
|
3430
|
+
const name = this.options.deviceName || "Browser Device";
|
|
3431
|
+
// Merge deviceId into metadata if not present
|
|
3432
|
+
let finalMetadata = metadata;
|
|
3433
|
+
const currentDeviceId = (await this.getLocalDeviceId()) || this.deviceId;
|
|
3434
|
+
if (!currentDeviceId) {
|
|
3435
|
+
throw new Error('updatePresence requires a stable local device ID');
|
|
3436
|
+
}
|
|
3437
|
+
try {
|
|
3438
|
+
const metaObj = metadata ? JSON.parse(metadata) : {};
|
|
3439
|
+
if (!metaObj.deviceId) {
|
|
3440
|
+
metaObj.deviceId = currentDeviceId;
|
|
3441
|
+
finalMetadata = JSON.stringify(metaObj);
|
|
3442
|
+
}
|
|
3443
|
+
}
|
|
3444
|
+
catch {
|
|
3445
|
+
finalMetadata = JSON.stringify({
|
|
3446
|
+
deviceId: currentDeviceId,
|
|
3447
|
+
rawMetadata: metadata,
|
|
3448
|
+
});
|
|
3449
|
+
}
|
|
3450
|
+
await this.signaling.updatePresence(nodeId, ticket, isOnline, ttlMs, finalMetadata);
|
|
3451
|
+
}
|
|
3452
|
+
onConnection(callback) {
|
|
3453
|
+
this.connectionListeners.push(callback);
|
|
3454
|
+
return () => {
|
|
3455
|
+
this.connectionListeners = this.connectionListeners.filter((cb) => cb !== callback);
|
|
3456
|
+
};
|
|
3457
|
+
}
|
|
3458
|
+
onDisconnection(callback) {
|
|
3459
|
+
this.disconnectionListeners.push(callback);
|
|
3460
|
+
return () => {
|
|
3461
|
+
this.disconnectionListeners = this.disconnectionListeners.filter((cb) => cb !== callback);
|
|
3462
|
+
};
|
|
3463
|
+
}
|
|
3464
|
+
onMessage(callback) {
|
|
3465
|
+
this.messageListeners.push(callback);
|
|
3466
|
+
return () => {
|
|
3467
|
+
this.messageListeners = this.messageListeners.filter((cb) => cb !== callback);
|
|
3468
|
+
};
|
|
3469
|
+
}
|
|
3470
|
+
// New: listen for raw headers
|
|
3471
|
+
onIncomingStream(callback) {
|
|
3472
|
+
this.streamListeners.push(callback);
|
|
3473
|
+
return () => {
|
|
3474
|
+
this.streamListeners = this.streamListeners.filter((cb) => cb !== callback);
|
|
3475
|
+
};
|
|
3476
|
+
}
|
|
3477
|
+
registerChannel(descriptor) {
|
|
3478
|
+
const channelId = descriptor.id.trim();
|
|
3479
|
+
if (!channelId) {
|
|
3480
|
+
throw new Error('Channel registration requires a non-empty channel id.');
|
|
3481
|
+
}
|
|
3482
|
+
this.registeredChannels.set(channelId, {
|
|
3483
|
+
...descriptor,
|
|
3484
|
+
id: channelId,
|
|
3485
|
+
});
|
|
3486
|
+
}
|
|
3487
|
+
unregisterChannel(channelId) {
|
|
3488
|
+
const normalizedChannelId = channelId.trim();
|
|
3489
|
+
if (!normalizedChannelId) {
|
|
3490
|
+
return;
|
|
3491
|
+
}
|
|
3492
|
+
this.registeredChannels.delete(normalizedChannelId);
|
|
3493
|
+
this.channelStreamListeners.delete(normalizedChannelId);
|
|
3494
|
+
}
|
|
3495
|
+
listChannels() {
|
|
3496
|
+
return Array.from(this.registeredChannels.values());
|
|
3497
|
+
}
|
|
3498
|
+
onIncomingChannelStream(channelId, callback) {
|
|
3499
|
+
const normalizedChannelId = channelId.trim();
|
|
3500
|
+
if (!normalizedChannelId) {
|
|
3501
|
+
throw new Error('Channel stream listener requires a non-empty channel id.');
|
|
3502
|
+
}
|
|
3503
|
+
const listeners = this.channelStreamListeners.get(normalizedChannelId) ?? new Set();
|
|
3504
|
+
listeners.add(callback);
|
|
3505
|
+
this.channelStreamListeners.set(normalizedChannelId, listeners);
|
|
3506
|
+
return () => {
|
|
3507
|
+
const existing = this.channelStreamListeners.get(normalizedChannelId);
|
|
3508
|
+
if (!existing) {
|
|
3509
|
+
return;
|
|
3510
|
+
}
|
|
3511
|
+
existing.delete(callback);
|
|
3512
|
+
if (existing.size === 0) {
|
|
3513
|
+
this.channelStreamListeners.delete(normalizedChannelId);
|
|
3514
|
+
}
|
|
3515
|
+
};
|
|
3516
|
+
}
|
|
3517
|
+
onRoomJoinRequest(callback) {
|
|
3518
|
+
this.roomRequestListeners.push(callback);
|
|
3519
|
+
return () => {
|
|
3520
|
+
this.roomRequestListeners = this.roomRequestListeners.filter((cb) => cb !== callback);
|
|
3521
|
+
};
|
|
3522
|
+
}
|
|
3523
|
+
// --- Signaling (for WebRTC Upgrade) ---
|
|
3524
|
+
// Deprecated: Connection handles its own signaling via #pluto-signal messages.
|
|
3525
|
+
// Keeping hooks if user wants to send custom signals (e.g. app specific)
|
|
3526
|
+
// --- MoQ Data Strategy ---
|
|
3527
|
+
async createMoQDataTracks() {
|
|
3528
|
+
if (!this._moqSession || this._moqState !== 'ready')
|
|
3529
|
+
return;
|
|
3530
|
+
try {
|
|
3531
|
+
// 1. Broadcast Track
|
|
3532
|
+
await this._moqSession.publisher.createTrack(this.localNodeId, "data/broadcast");
|
|
3533
|
+
openRtcDebugLog('[Client] MoQ Data Track created: data / broadcast');
|
|
3534
|
+
}
|
|
3535
|
+
catch (e) {
|
|
3536
|
+
console.warn("Failed to create MoQ broadcast track", e);
|
|
3537
|
+
}
|
|
3538
|
+
}
|
|
3539
|
+
/**
|
|
3540
|
+
* Send data via MoQ.
|
|
3541
|
+
* @param peerId Target Node ID (for 1:1) or null (for Broadcast)
|
|
3542
|
+
* @param data Payload
|
|
3543
|
+
*/
|
|
3544
|
+
async sendMoQData(peerId, data) {
|
|
3545
|
+
if (this._moqState !== 'ready' || !this.moq)
|
|
3546
|
+
throw new Error("MoQ not ready");
|
|
3547
|
+
const trackName = peerId ? `data / ${peerId} ` : `data / broadcast`;
|
|
3548
|
+
// Ensure track exists (create on demand for 1:1)
|
|
3549
|
+
// Check if we are already publishing this track?
|
|
3550
|
+
// MoQPublisher.createTrack checks internally and returns existing if so.
|
|
3551
|
+
// We'll trust createTrack's cache logic we added earlier.
|
|
3552
|
+
const trackPub = await this.moq.publisher.createTrack(this.localNodeId, trackName);
|
|
3553
|
+
if (!trackPub)
|
|
3554
|
+
return; // Should not happen if logic works or tracks exist
|
|
3555
|
+
// Push data (single object group)
|
|
3556
|
+
// We need a timestamp?
|
|
3557
|
+
const timestamp = Date.now();
|
|
3558
|
+
await trackPub.push(data, { isKey: true, timestamp });
|
|
3559
|
+
// Do NOT close track for 1:1, keep it open for session?
|
|
3560
|
+
// Actually, if we keep opening tracks, we might hit limits?
|
|
3561
|
+
// MoQPublisher caches tracks. It's fine.
|
|
3562
|
+
}
|
|
3563
|
+
async handleMessage(conn, msg) {
|
|
3564
|
+
// Intercept Handshake to detect MoQ support
|
|
3565
|
+
if (msg && typeof msg === 'object' && msg.type === 'handshake') {
|
|
3566
|
+
const handshake = msg;
|
|
3567
|
+
// Session grant gate: Rust/wasm validation decides whether the hello is allowed.
|
|
3568
|
+
if (handshake.action === 'hello' || !handshake.action) {
|
|
3569
|
+
const result = await this.validateIncomingSessionToken(handshake.sessionToken ?? '', conn.id);
|
|
3570
|
+
if (!result.ok) {
|
|
3571
|
+
console.warn('[Client][SESSION-TOKEN] Rejecting connection — invalid session token', {
|
|
3572
|
+
connectionId: conn.id,
|
|
3573
|
+
remoteNodeId: conn.remoteNodeId,
|
|
3574
|
+
reason: result.reason,
|
|
3575
|
+
hasToken: !!handshake.sessionToken,
|
|
3576
|
+
});
|
|
3577
|
+
void conn.disconnect();
|
|
3578
|
+
return;
|
|
3579
|
+
}
|
|
3580
|
+
}
|
|
3581
|
+
if (msg.moq && this.moqState === 'ready') {
|
|
3582
|
+
openRtcDebugLog(`[Client] Peer ${conn.deviceId.substring(0, 6)} supports MoQ. Upgrading...`);
|
|
3583
|
+
conn.peerMoQReady = true;
|
|
3584
|
+
// Subscribe to Peer's tracks
|
|
3585
|
+
if (this.moq) {
|
|
3586
|
+
const peerId = conn.deviceId;
|
|
3587
|
+
// Subscribe to their broadcast and DM track
|
|
3588
|
+
// Format: [PeerID]/data/broadcast and [PeerID]/data/[MyNodeID]
|
|
3589
|
+
this.moq.subscriber.subscribe(peerId, "data/broadcast");
|
|
3590
|
+
this.moq.subscriber.subscribe(peerId, `data / ${this.localNodeId} `);
|
|
3591
|
+
}
|
|
3592
|
+
// If we are Responder, we should reply with Handshake containing moq: true (if not already sent)
|
|
3593
|
+
// But connect() sends it initially.
|
|
3594
|
+
// If we received an inbound connection, we haven't sent Handshake yet!
|
|
3595
|
+
// We should send a handshake reply on incoming connections?
|
|
3596
|
+
// The current implementation of accept (handleIncomingStream -> Connection) doesn't auto-send handshake.
|
|
3597
|
+
// We should probably reply.
|
|
3598
|
+
// To avoid loops: assume if we initiated, we sent handshake.
|
|
3599
|
+
// If we accepted, we respond.
|
|
3600
|
+
// Or just always reply if we haven't?
|
|
3601
|
+
}
|
|
3602
|
+
// Assign channel from handshake if present
|
|
3603
|
+
this.assignConnectionChannel(conn, handshake.channelId ?? null);
|
|
3604
|
+
if (handshake.transportTrust || this.requiresManagedTransportTrust()) {
|
|
3605
|
+
void (async () => {
|
|
3606
|
+
const action = handshake.action ?? 'hello';
|
|
3607
|
+
const payload = handshake.transportTrust ?? null;
|
|
3608
|
+
const trustState = this.initializeConnectionTrust(conn);
|
|
3609
|
+
if (payload?.deviceId
|
|
3610
|
+
&& !trustState.expectedDeviceId
|
|
3611
|
+
&& this.getKnownDevice(payload.deviceId, conn.remoteNodeId)) {
|
|
3612
|
+
trustState.expectedDeviceId = payload.deviceId;
|
|
3613
|
+
}
|
|
3614
|
+
if (action === 'hello') {
|
|
3615
|
+
const responseChallenge = typeof payload?.challenge === 'string' ? payload.challenge : null;
|
|
3616
|
+
if (!responseChallenge && trustState.required) {
|
|
3617
|
+
await this.enforceConnectionTrustFailure(conn, 'missing-transport-trust-challenge');
|
|
3618
|
+
return;
|
|
3619
|
+
}
|
|
3620
|
+
await this.sendTransportHandshake(conn, 'response', responseChallenge);
|
|
3621
|
+
}
|
|
3622
|
+
else if (action === 'response' || action === 'ack') {
|
|
3623
|
+
const verification = await this.verifyRemoteTransportTrust(conn, payload);
|
|
3624
|
+
if (!verification.verified) {
|
|
3625
|
+
if (trustState.required) {
|
|
3626
|
+
await this.enforceConnectionTrustFailure(conn, verification.reason ?? 'remote-transport-proof-invalid');
|
|
3627
|
+
}
|
|
3628
|
+
return;
|
|
3629
|
+
}
|
|
3630
|
+
this.markConnectionTrustVerified(conn, verification.deviceId);
|
|
3631
|
+
if (action === 'response' && typeof payload?.challenge === 'string') {
|
|
3632
|
+
await this.sendTransportHandshake(conn, 'ack', payload.challenge);
|
|
3633
|
+
}
|
|
3634
|
+
}
|
|
3635
|
+
})().catch((error) => {
|
|
3636
|
+
console.warn('[Client][TRUST] Handshake processing failed', {
|
|
3637
|
+
connectionId: conn.id,
|
|
3638
|
+
remoteNodeId: conn.remoteNodeId,
|
|
3639
|
+
error,
|
|
3640
|
+
});
|
|
3641
|
+
});
|
|
3642
|
+
}
|
|
3643
|
+
else if ((handshake.action === 'hello' || !handshake.action) && handshake.sessionToken) {
|
|
3644
|
+
// Token-gated connection without transport trust (e.g. share session).
|
|
3645
|
+
// Send a response handshake so the peer knows it was accepted.
|
|
3646
|
+
void this.sendTransportHandshake(conn, 'response').catch((error) => {
|
|
3647
|
+
console.warn('[Client][SESSION-TOKEN] Failed to send response handshake', {
|
|
3648
|
+
connectionId: conn.id,
|
|
3649
|
+
error,
|
|
3650
|
+
});
|
|
3651
|
+
});
|
|
3652
|
+
}
|
|
3653
|
+
}
|
|
3654
|
+
// We no longer intercept #signal here, Connection does it before emitting message event.
|
|
3655
|
+
this.messageListeners.forEach(cb => cb(conn, msg));
|
|
3656
|
+
}
|
|
3657
|
+
getRTCConfig() {
|
|
3658
|
+
const config = this.options.transports?.webrtc || undefined;
|
|
3659
|
+
// If strict mode is on, we might need to synthesize a config if it's missing?
|
|
3660
|
+
// But usually transports are defined.
|
|
3661
|
+
if (!config)
|
|
3662
|
+
return undefined;
|
|
3663
|
+
if (this.options.strictMode || config.privacyMode) {
|
|
3664
|
+
return {
|
|
3665
|
+
...config,
|
|
3666
|
+
iceTransportPolicy: 'relay'
|
|
3667
|
+
};
|
|
3668
|
+
}
|
|
3669
|
+
return config;
|
|
3670
|
+
}
|
|
3671
|
+
}
|
|
3672
|
+
Client.CHANNEL_ENVELOPE_MAGIC = 0x7f;
|
|
3673
|
+
Client.CHANNEL_ENVELOPE_VERSION = 0x01;
|
|
3674
|
+
Client.CHANNEL_ENVELOPE_HEADER_BYTES = 1 + 1 + 2 + 2;
|
|
3675
|
+
Client.EXPLICIT_PROTOCOL_BYTE = 0x02;
|
|
3676
|
+
Client.NATIVE_MAIN_PROTOCOL_BYTE = 0x00;
|
|
3677
|
+
Client.NATIVE_MAIN_LABEL = 'main';
|
|
3678
|
+
Client.NATIVE_MAIN_MAX_LABEL_BYTES = 64;
|
|
3679
|
+
Client.NATIVE_MAIN_PROTOCOL_VERSION = 1;
|
|
3680
|
+
Client.CONNECTION_STATE_CHANGED_EVENT = 'connection-state-changed';
|
|
3681
|
+
Client.INCOMING_STREAM_RETRY_MIN_DELAY_MS = 250;
|
|
3682
|
+
Client.INCOMING_STREAM_RETRY_MAX_DELAY_MS = 2000;
|
|
3683
|
+
Client.DEFAULT_PEER_CHANNEL_ID = 'system/peer-default';
|
|
3684
|
+
Client.SHARE_FILE_CHANNEL_ID = 'share/explicit-file';
|
|
3685
|
+
Client.SHARE_CONTROL_CHANNEL_ID = 'share/explicit-control';
|
|
3686
|
+
Client.incomingStreamHubs = new WeakMap();
|
|
3687
|
+
Client.CONNECTION_STABLE_WINDOW_MS = 600;
|
|
3688
|
+
Client.CONNECTION_STABLE_TIMEOUT_MS = 2000;
|
|
3689
|
+
Client.CONNECTION_STABLE_POLL_MS = 120;
|