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,1484 @@
|
|
|
1
|
+
import { Client } from '../core/Client';
|
|
2
|
+
import { resolveStorageNamespace } from '../core/config';
|
|
3
|
+
import { openRtcDebugInfo } from '../utils/debug';
|
|
4
|
+
/**
|
|
5
|
+
* RuntimeClient is the primary application-facing API.
|
|
6
|
+
* It owns host/runtime orchestration on top of the Rust core and should be the
|
|
7
|
+
* only high-level surface consumed by product code. App code should not rebuild
|
|
8
|
+
* lifecycle, discovery, or transfer-readiness policy above this layer.
|
|
9
|
+
*
|
|
10
|
+
* Allowed:
|
|
11
|
+
* - app-facing session startup/shutdown
|
|
12
|
+
* - typed device, peer, room, and transfer APIs
|
|
13
|
+
* - thin host/runtime coordination
|
|
14
|
+
*
|
|
15
|
+
* Not allowed:
|
|
16
|
+
* - teaching app code to depend on `advanced.unwrap*()`
|
|
17
|
+
* - treating `nodeId` as high-level peer identity
|
|
18
|
+
* - reimplementing Rust-owned lifecycle policy in product code
|
|
19
|
+
*
|
|
20
|
+
* Source of truth:
|
|
21
|
+
* - Rust core owns discovery, peer identity, presence, and transport lifecycle
|
|
22
|
+
* - RuntimeClient should converge toward thin wrappers over those core surfaces
|
|
23
|
+
*/
|
|
24
|
+
export class RuntimeClient {
|
|
25
|
+
constructor(options, backend) {
|
|
26
|
+
this.managedSessionKey = null;
|
|
27
|
+
this.managedSessionUserId = null;
|
|
28
|
+
this.managedSessionOptions = null;
|
|
29
|
+
this.apiKey = options.apiKey;
|
|
30
|
+
this.storageNamespace = resolveStorageNamespace({
|
|
31
|
+
apiKey: options.apiKey,
|
|
32
|
+
storagePrefix: options.storagePrefix,
|
|
33
|
+
});
|
|
34
|
+
this.client = new Client(options);
|
|
35
|
+
this.backend = backend ?? null;
|
|
36
|
+
if (backend) {
|
|
37
|
+
this.client.setSignalingBackend(backend);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
get identity() {
|
|
41
|
+
return this.client.currentUser;
|
|
42
|
+
}
|
|
43
|
+
sleep(ms) {
|
|
44
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
45
|
+
}
|
|
46
|
+
async fetchAndApplyAppLimits() {
|
|
47
|
+
try {
|
|
48
|
+
await this.client.refreshHostedAppSettings();
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
console.warn('[pluto-rtc] Could not fetch app limits, using free-tier defaults.', e);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
normalizeUiConnectionStatus(status) {
|
|
55
|
+
switch ((status || 'disconnected').trim().toLowerCase()) {
|
|
56
|
+
case 'connected':
|
|
57
|
+
return 'connected';
|
|
58
|
+
case 'connecting':
|
|
59
|
+
return 'connecting';
|
|
60
|
+
case 'failed':
|
|
61
|
+
return 'failed';
|
|
62
|
+
case 'closed':
|
|
63
|
+
case 'disconnected':
|
|
64
|
+
case 'online':
|
|
65
|
+
default:
|
|
66
|
+
return 'disconnected';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
managedUserDeviceStorageKey(userId) {
|
|
70
|
+
return [
|
|
71
|
+
RuntimeClient.MANAGED_USER_DEVICE_STORAGE_KEY_PREFIX,
|
|
72
|
+
this.storageNamespace,
|
|
73
|
+
(userId || 'anonymous').trim() || 'anonymous',
|
|
74
|
+
].join(':');
|
|
75
|
+
}
|
|
76
|
+
static encodeManagedCompoundTicket(irohTicket, grant) {
|
|
77
|
+
const payload = btoa(JSON.stringify({
|
|
78
|
+
t: grant.token,
|
|
79
|
+
s: grant.scope,
|
|
80
|
+
m: grant.maxConnections,
|
|
81
|
+
}))
|
|
82
|
+
.replace(/\+/g, '-')
|
|
83
|
+
.replace(/\//g, '_')
|
|
84
|
+
.replace(/=+$/g, '');
|
|
85
|
+
return `${irohTicket}.${payload}`;
|
|
86
|
+
}
|
|
87
|
+
decodeManagedUserDeviceGrantFromTicket(ticket) {
|
|
88
|
+
const trimmed = ticket?.trim() || '';
|
|
89
|
+
if (!trimmed) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
const dotIndex = trimmed.lastIndexOf('.');
|
|
93
|
+
if (dotIndex < 0) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const suffix = trimmed.slice(dotIndex + 1).replace(/-/g, '+').replace(/_/g, '/');
|
|
98
|
+
const padding = (4 - (suffix.length % 4)) % 4;
|
|
99
|
+
const decoded = JSON.parse(atob(suffix + '='.repeat(padding)));
|
|
100
|
+
if (typeof decoded?.t !== 'string' ||
|
|
101
|
+
typeof decoded?.s !== 'string' ||
|
|
102
|
+
decoded.s.trim() !== RuntimeClient.MANAGED_USER_DEVICE_SCOPE) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
scope: decoded.s,
|
|
107
|
+
token: decoded.t,
|
|
108
|
+
maxConnections: typeof decoded.m === 'number' ? decoded.m : 0,
|
|
109
|
+
savedAtMs: Date.now(),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
loadPersistedManagedUserDeviceGrant(userId) {
|
|
117
|
+
if (typeof localStorage === 'undefined') {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
const storageKey = this.managedUserDeviceStorageKey(userId);
|
|
121
|
+
try {
|
|
122
|
+
const raw = localStorage.getItem(storageKey);
|
|
123
|
+
if (!raw) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
const parsed = JSON.parse(raw);
|
|
127
|
+
if (typeof parsed?.token !== 'string' ||
|
|
128
|
+
typeof parsed?.scope !== 'string' ||
|
|
129
|
+
parsed.scope.trim() !== RuntimeClient.MANAGED_USER_DEVICE_SCOPE) {
|
|
130
|
+
localStorage.removeItem(storageKey);
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
scope: parsed.scope,
|
|
135
|
+
token: parsed.token,
|
|
136
|
+
maxConnections: typeof parsed.maxConnections === 'number' ? parsed.maxConnections : 0,
|
|
137
|
+
savedAtMs: typeof parsed.savedAtMs === 'number' ? parsed.savedAtMs : 0,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
console.warn('[RuntimeClient][managed-ticket][restore] failed parsing stored grant', {
|
|
142
|
+
storageKey,
|
|
143
|
+
error,
|
|
144
|
+
});
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
persistManagedUserDeviceGrant(userId, grant) {
|
|
149
|
+
if (typeof localStorage === 'undefined') {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const storageKey = this.managedUserDeviceStorageKey(userId);
|
|
153
|
+
try {
|
|
154
|
+
localStorage.setItem(storageKey, JSON.stringify({
|
|
155
|
+
...grant,
|
|
156
|
+
savedAtMs: Date.now(),
|
|
157
|
+
}));
|
|
158
|
+
console.info('[RuntimeClient][managed-ticket][persist]', {
|
|
159
|
+
storageKey,
|
|
160
|
+
scope: grant.scope,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
console.warn('[RuntimeClient][managed-ticket][persist] failed', {
|
|
165
|
+
storageKey,
|
|
166
|
+
error,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
persistManagedUserDeviceGrantFromTicket(userId, ticket) {
|
|
171
|
+
const grant = this.decodeManagedUserDeviceGrantFromTicket(ticket);
|
|
172
|
+
if (!grant) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
this.persistManagedUserDeviceGrant(userId, grant);
|
|
176
|
+
}
|
|
177
|
+
clearPersistedManagedUserDeviceGrant(userId) {
|
|
178
|
+
if (typeof localStorage === 'undefined') {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const storageKey = this.managedUserDeviceStorageKey(userId);
|
|
182
|
+
try {
|
|
183
|
+
localStorage.removeItem(storageKey);
|
|
184
|
+
console.info('[RuntimeClient][managed-ticket][clear]', {
|
|
185
|
+
storageKey,
|
|
186
|
+
scope: RuntimeClient.MANAGED_USER_DEVICE_SCOPE,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
console.warn('[RuntimeClient][managed-ticket][clear] failed', {
|
|
191
|
+
storageKey,
|
|
192
|
+
error,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
async resolvePeerStreamTarget(id) {
|
|
197
|
+
const peer = this.getPeerState(id) ?? null;
|
|
198
|
+
const session = await this.getPeerSession(id).catch(() => null);
|
|
199
|
+
const activeConnectionId = session?.activeConnectionId?.trim() || null;
|
|
200
|
+
const connection = (activeConnectionId
|
|
201
|
+
? this.client.getConnections().find((candidate) => !candidate.isClosed && candidate.id === activeConnectionId)
|
|
202
|
+
: undefined) ?? null;
|
|
203
|
+
const remoteNodeId = session?.nodeId?.trim() ||
|
|
204
|
+
connection?.remoteNodeId?.trim() ||
|
|
205
|
+
peer?.nodeId?.trim() ||
|
|
206
|
+
null;
|
|
207
|
+
return { peer, session, connection, remoteNodeId };
|
|
208
|
+
}
|
|
209
|
+
compatPeerConnectionFromTarget(target, id) {
|
|
210
|
+
const connectionId = target.session?.activeConnectionId?.trim() || `peer-stream:${id}`;
|
|
211
|
+
const remoteNodeId = target.remoteNodeId || id;
|
|
212
|
+
return {
|
|
213
|
+
id: connectionId,
|
|
214
|
+
deviceId: remoteNodeId,
|
|
215
|
+
remoteNodeId,
|
|
216
|
+
isClosed: false,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
deviceStatusSnapshotKey(devices) {
|
|
220
|
+
return JSON.stringify([...devices]
|
|
221
|
+
.map((device) => ({
|
|
222
|
+
deviceId: device.deviceId,
|
|
223
|
+
deviceName: device.deviceName ?? null,
|
|
224
|
+
online: !!device.online,
|
|
225
|
+
connectionStatus: device.connectionStatus ?? null,
|
|
226
|
+
settledReady: !!device.settledReady,
|
|
227
|
+
readinessState: device.readinessState ?? null,
|
|
228
|
+
readinessReason: device.readinessReason ?? null,
|
|
229
|
+
peerHealth: device.peerHealth ?? null,
|
|
230
|
+
peerId: device.peerId ?? null,
|
|
231
|
+
promotionEligible: device.promotionEligible ?? true,
|
|
232
|
+
connectionId: device.connectionId ?? null,
|
|
233
|
+
deviceIdHint: device.deviceIdHint ?? null,
|
|
234
|
+
nodeId: device.nodeId ?? null,
|
|
235
|
+
ticket: device.ticket ?? null,
|
|
236
|
+
activeTransportStableId: device.activeTransportStableId ?? null,
|
|
237
|
+
transportGeneration: device.transportGeneration ?? null,
|
|
238
|
+
scopes: [...(device.scopes ?? [])].sort(),
|
|
239
|
+
}))
|
|
240
|
+
.sort((left, right) => left.deviceId.localeCompare(right.deviceId)));
|
|
241
|
+
}
|
|
242
|
+
buildDeviceStatusLookup(items, readIdentity) {
|
|
243
|
+
const lookup = new Map();
|
|
244
|
+
const remember = (value, item) => {
|
|
245
|
+
const normalized = typeof value === 'string' ? value.trim() : '';
|
|
246
|
+
if (!normalized || lookup.has(normalized)) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
lookup.set(normalized, item);
|
|
250
|
+
};
|
|
251
|
+
for (const item of items) {
|
|
252
|
+
for (const key of readIdentity(item)) {
|
|
253
|
+
remember(key, item);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return lookup;
|
|
257
|
+
}
|
|
258
|
+
overlayPeerSessionsOnDeviceSnapshots(devices, sessions) {
|
|
259
|
+
if (!sessions.length) {
|
|
260
|
+
return devices;
|
|
261
|
+
}
|
|
262
|
+
const sessionIndex = this.buildDeviceStatusLookup(sessions, (session) => [
|
|
263
|
+
session.peerId,
|
|
264
|
+
session.deviceId,
|
|
265
|
+
session.deviceIdHint,
|
|
266
|
+
session.nodeId,
|
|
267
|
+
session.activeConnectionId,
|
|
268
|
+
]);
|
|
269
|
+
return devices.map((device) => {
|
|
270
|
+
const session = sessionIndex.get(device.deviceId) ??
|
|
271
|
+
sessionIndex.get(device.deviceIdHint ?? '') ??
|
|
272
|
+
sessionIndex.get(device.nodeId ?? '') ??
|
|
273
|
+
sessionIndex.get(device.connectionId ?? '') ??
|
|
274
|
+
sessionIndex.get(device.peerId ?? '');
|
|
275
|
+
if (!session) {
|
|
276
|
+
return device;
|
|
277
|
+
}
|
|
278
|
+
const promotionEligible = this.client.isPeerPromotionEligible({
|
|
279
|
+
connectionId: session.activeConnectionId ?? undefined,
|
|
280
|
+
deviceId: session.deviceId ?? undefined,
|
|
281
|
+
deviceIdHint: session.deviceIdHint ?? undefined,
|
|
282
|
+
nodeId: session.nodeId ?? undefined,
|
|
283
|
+
});
|
|
284
|
+
if (!promotionEligible) {
|
|
285
|
+
return device;
|
|
286
|
+
}
|
|
287
|
+
const status = session.status ?? device.connectionStatus;
|
|
288
|
+
return {
|
|
289
|
+
...device,
|
|
290
|
+
connectionStatus: status,
|
|
291
|
+
settledReady: session.settledReady ?? device.settledReady ?? false,
|
|
292
|
+
readinessState: session.readinessState ??
|
|
293
|
+
(session.settledReady ? 'routable' : device.readinessState),
|
|
294
|
+
readinessReason: session.readinessReason ??
|
|
295
|
+
(session.settledReady ? 'peer-session-settled' : device.readinessReason),
|
|
296
|
+
peerHealth: session.health ?? device.peerHealth,
|
|
297
|
+
peerId: session.peerId ?? device.peerId,
|
|
298
|
+
promotionEligible,
|
|
299
|
+
scopes: session.scopes?.length ? [...session.scopes] : device.scopes,
|
|
300
|
+
connectionId: session.activeConnectionId ?? device.connectionId,
|
|
301
|
+
deviceIdHint: session.deviceIdHint ?? device.deviceIdHint,
|
|
302
|
+
activeTransportStableId: session.activeTransportStableId ?? device.activeTransportStableId,
|
|
303
|
+
transportGeneration: session.transportGeneration ?? device.transportGeneration,
|
|
304
|
+
};
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
overlayPeerStateOnDeviceSnapshots(devices, peers) {
|
|
308
|
+
if (!peers.length) {
|
|
309
|
+
return devices;
|
|
310
|
+
}
|
|
311
|
+
const peerIndex = this.buildDeviceStatusLookup(peers, (peer) => [
|
|
312
|
+
peer.peerId,
|
|
313
|
+
peer.deviceId,
|
|
314
|
+
peer.deviceIdHint,
|
|
315
|
+
peer.nodeId,
|
|
316
|
+
peer.connectionId,
|
|
317
|
+
]);
|
|
318
|
+
return devices.map((device) => {
|
|
319
|
+
const peer = peerIndex.get(device.deviceId) ??
|
|
320
|
+
peerIndex.get(device.deviceIdHint ?? '') ??
|
|
321
|
+
peerIndex.get(device.nodeId ?? '') ??
|
|
322
|
+
peerIndex.get(device.connectionId ?? '') ??
|
|
323
|
+
peerIndex.get(device.peerId ?? '');
|
|
324
|
+
if (!peer) {
|
|
325
|
+
return device;
|
|
326
|
+
}
|
|
327
|
+
if (peer.promotionEligible === false) {
|
|
328
|
+
return device;
|
|
329
|
+
}
|
|
330
|
+
const status = peer.status ?? device.connectionStatus;
|
|
331
|
+
return {
|
|
332
|
+
...device,
|
|
333
|
+
connectionStatus: status,
|
|
334
|
+
settledReady: peer.routable ?? device.settledReady ?? false,
|
|
335
|
+
readinessState: (peer.routable === false && peer.protocolState
|
|
336
|
+
? peer.protocolState
|
|
337
|
+
: undefined)
|
|
338
|
+
?? device.readinessState
|
|
339
|
+
?? (peer.routable === true
|
|
340
|
+
? 'routable'
|
|
341
|
+
: peer.status === 'connecting'
|
|
342
|
+
? 'connecting'
|
|
343
|
+
: undefined),
|
|
344
|
+
readinessReason: (peer.routable === false && peer.error
|
|
345
|
+
? peer.error
|
|
346
|
+
: undefined)
|
|
347
|
+
?? device.readinessReason
|
|
348
|
+
?? (peer.routable === true
|
|
349
|
+
? 'peer-projection-routable'
|
|
350
|
+
: peer.status === 'connecting'
|
|
351
|
+
? 'peer-projection-connecting'
|
|
352
|
+
: undefined),
|
|
353
|
+
peerHealth: peer.health ?? device.peerHealth,
|
|
354
|
+
peerId: peer.peerId ?? device.peerId,
|
|
355
|
+
promotionEligible: peer.promotionEligible ?? true,
|
|
356
|
+
connectionId: peer.connectionId ?? device.connectionId,
|
|
357
|
+
deviceIdHint: peer.deviceIdHint ?? device.deviceIdHint,
|
|
358
|
+
scopes: peer.scopes?.length ? [...peer.scopes] : device.scopes,
|
|
359
|
+
};
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
runtimeDeviceToStatusSnapshot(device) {
|
|
363
|
+
return {
|
|
364
|
+
...device,
|
|
365
|
+
connectionStatus: device.online ? 'online' : 'disconnected',
|
|
366
|
+
settledReady: false,
|
|
367
|
+
peerHealth: 'unknown',
|
|
368
|
+
promotionEligible: true,
|
|
369
|
+
scopes: [],
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
uniqueLookupIds(...values) {
|
|
373
|
+
const seen = new Set();
|
|
374
|
+
const ordered = [];
|
|
375
|
+
for (const value of values) {
|
|
376
|
+
const normalized = typeof value === 'string' ? value.trim() : '';
|
|
377
|
+
if (!normalized || seen.has(normalized)) {
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
seen.add(normalized);
|
|
381
|
+
ordered.push(normalized);
|
|
382
|
+
}
|
|
383
|
+
return ordered;
|
|
384
|
+
}
|
|
385
|
+
matchSessionByLookupId(sessions, lookupIds) {
|
|
386
|
+
if (!sessions.length || !lookupIds.length) {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
const sessionIndex = this.buildDeviceStatusLookup(sessions, (session) => [
|
|
390
|
+
session.peerId,
|
|
391
|
+
session.deviceId,
|
|
392
|
+
session.deviceIdHint,
|
|
393
|
+
session.nodeId,
|
|
394
|
+
session.activeConnectionId,
|
|
395
|
+
]);
|
|
396
|
+
for (const lookupId of lookupIds) {
|
|
397
|
+
const matched = sessionIndex.get(lookupId);
|
|
398
|
+
if (matched) {
|
|
399
|
+
return matched;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
async resolvePeerLookupIds(id, backend) {
|
|
405
|
+
const resolvedIdentity = typeof backend.resolvePeerIdentity === 'function'
|
|
406
|
+
? await backend.resolvePeerIdentity(id).catch(() => null)
|
|
407
|
+
: null;
|
|
408
|
+
const baseIds = this.uniqueLookupIds(id, resolvedIdentity?.peerId, resolvedIdentity?.deviceId, resolvedIdentity?.deviceIdHint, resolvedIdentity?.nodeId);
|
|
409
|
+
if (typeof backend.listPeerSessions !== 'function') {
|
|
410
|
+
return baseIds;
|
|
411
|
+
}
|
|
412
|
+
const matchedSession = this.matchSessionByLookupId(await backend.listPeerSessions().catch(() => []), baseIds);
|
|
413
|
+
return this.uniqueLookupIds(...baseIds, matchedSession?.peerId, matchedSession?.deviceId, matchedSession?.deviceIdHint, matchedSession?.nodeId, matchedSession?.activeConnectionId);
|
|
414
|
+
}
|
|
415
|
+
mergeWatchedDevicesIntoStatusSnapshots(watchedDevices, authoritativeDevices) {
|
|
416
|
+
if (!watchedDevices) {
|
|
417
|
+
return authoritativeDevices;
|
|
418
|
+
}
|
|
419
|
+
const authoritativeById = new Map(authoritativeDevices.map((device) => [device.deviceId, device]));
|
|
420
|
+
return watchedDevices.map((device) => {
|
|
421
|
+
const authoritative = authoritativeById.get(device.deviceId);
|
|
422
|
+
if (!authoritative) {
|
|
423
|
+
return this.runtimeDeviceToStatusSnapshot(device);
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
...authoritative,
|
|
427
|
+
...device,
|
|
428
|
+
connectionStatus: authoritative.connectionStatus,
|
|
429
|
+
settledReady: authoritative.settledReady,
|
|
430
|
+
readinessState: authoritative.readinessState,
|
|
431
|
+
readinessReason: authoritative.readinessReason,
|
|
432
|
+
peerHealth: authoritative.peerHealth,
|
|
433
|
+
peerId: authoritative.peerId,
|
|
434
|
+
scopes: authoritative.scopes,
|
|
435
|
+
connectionId: authoritative.connectionId,
|
|
436
|
+
deviceIdHint: authoritative.deviceIdHint,
|
|
437
|
+
activeTransportStableId: authoritative.activeTransportStableId,
|
|
438
|
+
transportGeneration: authoritative.transportGeneration,
|
|
439
|
+
};
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
nextNativeStartRetryDelayMs(attempt) {
|
|
443
|
+
const backoff = RuntimeClient.NATIVE_START_RETRY_MIN_DELAY_MS * Math.pow(2, Math.max(0, attempt - 1));
|
|
444
|
+
return Math.min(RuntimeClient.NATIVE_START_RETRY_MAX_DELAY_MS, backoff);
|
|
445
|
+
}
|
|
446
|
+
hasManagedSession() {
|
|
447
|
+
return !!(this.managedSessionKey || this.managedSessionUserId);
|
|
448
|
+
}
|
|
449
|
+
startManagedReconciliation(task) {
|
|
450
|
+
if (!this.hasManagedSession()) {
|
|
451
|
+
return () => undefined;
|
|
452
|
+
}
|
|
453
|
+
const timer = setInterval(() => {
|
|
454
|
+
if (!this.hasManagedSession()) {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
task();
|
|
458
|
+
}, RuntimeClient.MANAGED_RECONCILIATION_INTERVAL_MS);
|
|
459
|
+
return () => clearInterval(timer);
|
|
460
|
+
}
|
|
461
|
+
connectionStateSnapshotKey(state) {
|
|
462
|
+
return JSON.stringify({
|
|
463
|
+
connectionId: state.connectionId,
|
|
464
|
+
deviceId: state.deviceId ?? null,
|
|
465
|
+
deviceIdHint: state.deviceIdHint ?? null,
|
|
466
|
+
remoteNodeId: state.remoteNodeId ?? null,
|
|
467
|
+
state: state.state,
|
|
468
|
+
transportState: state.transportState ?? null,
|
|
469
|
+
protocolState: state.protocolState ?? null,
|
|
470
|
+
routable: state.routable ?? null,
|
|
471
|
+
readinessState: state.readinessState ?? null,
|
|
472
|
+
readinessReason: state.readinessReason ?? null,
|
|
473
|
+
transportGeneration: state.transportGeneration ?? null,
|
|
474
|
+
replacementInProgress: state.replacementInProgress ?? null,
|
|
475
|
+
error: state.error ?? null,
|
|
476
|
+
updatedAt: state.updatedAt ?? null,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
async runNativeStartLoop(options) {
|
|
480
|
+
const startedAt = Date.now();
|
|
481
|
+
let attempt = 0;
|
|
482
|
+
while (Date.now() - startedAt <= RuntimeClient.NATIVE_START_RETRY_WINDOW_MS) {
|
|
483
|
+
attempt += 1;
|
|
484
|
+
try {
|
|
485
|
+
await options.start();
|
|
486
|
+
if (attempt > 1) {
|
|
487
|
+
openRtcDebugInfo(`[RuntimeClient] ${options.label} recovered after ${attempt} attempts`);
|
|
488
|
+
}
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
catch (error) {
|
|
492
|
+
const delayMs = this.nextNativeStartRetryDelayMs(attempt);
|
|
493
|
+
console.warn(`[RuntimeClient] ${options.label} failed attempt=${attempt} retry_in_ms=${delayMs}:`, error);
|
|
494
|
+
await this.sleep(delayMs);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
throw new Error(options.exhaustedMessage);
|
|
498
|
+
}
|
|
499
|
+
onIdentityChange(callback) {
|
|
500
|
+
return this.client.onAuthChange(callback);
|
|
501
|
+
}
|
|
502
|
+
async signInAnonymously() {
|
|
503
|
+
await this.client.signInAnonymously();
|
|
504
|
+
}
|
|
505
|
+
async signInWithPluto() {
|
|
506
|
+
await this.client.signInWithPluto();
|
|
507
|
+
}
|
|
508
|
+
async setAuthContext(context) {
|
|
509
|
+
const backend = this.backend;
|
|
510
|
+
if (!backend || typeof backend.setAuthContext !== 'function') {
|
|
511
|
+
throw new Error('RuntimeClient auth context requires a runtime adapter that supports setAuthContext().');
|
|
512
|
+
}
|
|
513
|
+
await backend.setAuthContext(context);
|
|
514
|
+
}
|
|
515
|
+
async signOut() {
|
|
516
|
+
await this.stopManagedSession().catch(() => undefined);
|
|
517
|
+
await this.client.signOut();
|
|
518
|
+
}
|
|
519
|
+
async initialize() {
|
|
520
|
+
await this.client.init();
|
|
521
|
+
}
|
|
522
|
+
async getRuntimeStatus() {
|
|
523
|
+
return this.client.getRuntimeStatus();
|
|
524
|
+
}
|
|
525
|
+
async start(options) {
|
|
526
|
+
if (options?.clearSessionTokens !== false) {
|
|
527
|
+
await this.client.clearSessionTokens().catch(() => { });
|
|
528
|
+
}
|
|
529
|
+
await this.client.startListening();
|
|
530
|
+
// Subscribe to native iroh messages so the TS Client can run token
|
|
531
|
+
// validation for incoming native-host connections.
|
|
532
|
+
const nativeBackend = this.backend;
|
|
533
|
+
if (nativeBackend?.onIncomingNativeMessage) {
|
|
534
|
+
nativeBackend.onIncomingNativeMessage((connectionId, remoteNodeId, msg) => {
|
|
535
|
+
void (async () => {
|
|
536
|
+
const reject = await this.client.acceptNativeMessage(connectionId, remoteNodeId, msg);
|
|
537
|
+
if (reject && nativeBackend.disconnectDevice) {
|
|
538
|
+
nativeBackend.disconnectDevice(connectionId).catch(() => { });
|
|
539
|
+
}
|
|
540
|
+
})();
|
|
541
|
+
}).catch(() => { });
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
stop() {
|
|
545
|
+
this.client.stopListening();
|
|
546
|
+
}
|
|
547
|
+
async getNodeId() {
|
|
548
|
+
return this.client.getNodeId();
|
|
549
|
+
}
|
|
550
|
+
async getNodeIdFromTicket(ticket) {
|
|
551
|
+
return this.client.getNodeIdFromTicket(ticket);
|
|
552
|
+
}
|
|
553
|
+
async getLocalDeviceId() {
|
|
554
|
+
return this.client.getLocalDeviceId();
|
|
555
|
+
}
|
|
556
|
+
async getLocalDeviceInfo() {
|
|
557
|
+
const backend = this.backend;
|
|
558
|
+
if (!backend || typeof backend.getLocalDeviceInfo !== 'function') {
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
return backend.getLocalDeviceInfo();
|
|
562
|
+
}
|
|
563
|
+
async getLocalDeviceProfile(options) {
|
|
564
|
+
const localInfo = await this.getLocalDeviceInfo().catch(() => null);
|
|
565
|
+
const localDeviceId = options?.localDeviceId ??
|
|
566
|
+
localInfo?.deviceId ??
|
|
567
|
+
(await this.getLocalDeviceId().catch(() => null)) ??
|
|
568
|
+
null;
|
|
569
|
+
const runtimeStatus = await this.getRuntimeStatus().catch(() => ({
|
|
570
|
+
runtime: 'unknown',
|
|
571
|
+
wasmLoaded: false,
|
|
572
|
+
localNodeId: undefined,
|
|
573
|
+
userId: undefined,
|
|
574
|
+
}));
|
|
575
|
+
const platform = options?.platform ??
|
|
576
|
+
(runtimeStatus.runtime === 'tauri' ? 'native' : 'web');
|
|
577
|
+
return {
|
|
578
|
+
deviceId: localDeviceId,
|
|
579
|
+
deviceName: options?.deviceName || localInfo?.deviceName || 'Unknown Device',
|
|
580
|
+
platformType: localInfo?.platformType || (platform === 'native' ? 'desktop' : 'web'),
|
|
581
|
+
userId: options?.userId ?? this.identity?.id ?? runtimeStatus.userId ?? null,
|
|
582
|
+
capabilities: localInfo?.capabilities ?? {
|
|
583
|
+
can_host: platform === 'native',
|
|
584
|
+
can_sync: platform === 'native',
|
|
585
|
+
read_only: platform !== 'native',
|
|
586
|
+
},
|
|
587
|
+
lastSeenAt: localInfo?.lastSeenAt || new Date().toISOString(),
|
|
588
|
+
localNodeId: runtimeStatus.localNodeId || null,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
async updateLocalDeviceName(deviceName) {
|
|
592
|
+
const backend = this.backend;
|
|
593
|
+
if (!backend || typeof backend.updateLocalDeviceName !== 'function') {
|
|
594
|
+
return null;
|
|
595
|
+
}
|
|
596
|
+
return backend.updateLocalDeviceName(deviceName);
|
|
597
|
+
}
|
|
598
|
+
async getManagedNodeId(options) {
|
|
599
|
+
const backend = this.backend;
|
|
600
|
+
if (!backend || typeof backend.getManagedNodeId !== 'function') {
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
return backend.getManagedNodeId(options);
|
|
604
|
+
}
|
|
605
|
+
async getTicket() {
|
|
606
|
+
return this.client.getTicket();
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Generate a session-scoped endpoint ticket with an embedded random token.
|
|
610
|
+
* The token gates incoming connections: only clients that present the token
|
|
611
|
+
* in their handshake are accepted while the registry is non-empty.
|
|
612
|
+
*
|
|
613
|
+
* @param scope Logical label for bulk-revoke (e.g. "share").
|
|
614
|
+
* @param maxConnections 0 = unlimited.
|
|
615
|
+
*/
|
|
616
|
+
async getTicketWithToken(grantScope, maxConnections = 0) {
|
|
617
|
+
// On native (Tauri IPC), generate the compound ticket entirely on the
|
|
618
|
+
// native Rust backend. This ensures the ticket points to the native iroh
|
|
619
|
+
// node (which owns the file system and protocol handler) and the token is
|
|
620
|
+
// registered in the native SessionTokenRegistry.
|
|
621
|
+
const nativeBackend = this.backend;
|
|
622
|
+
if (nativeBackend?.getEndpointTicketWithToken) {
|
|
623
|
+
try {
|
|
624
|
+
const nativeCompoundTicket = await nativeBackend.getEndpointTicketWithToken(grantScope, maxConnections);
|
|
625
|
+
if (nativeCompoundTicket) {
|
|
626
|
+
return nativeCompoundTicket;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
catch {
|
|
630
|
+
// Fall back to WASM path
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
// Fallback: use native ticket with TS-generated token
|
|
634
|
+
if (nativeBackend?.getEndpointTicket) {
|
|
635
|
+
try {
|
|
636
|
+
const nativeTicket = await nativeBackend.getEndpointTicket();
|
|
637
|
+
const compound = this.client.buildCompoundTicketWithToken(nativeTicket, grantScope, maxConnections);
|
|
638
|
+
// Sync the generated token to native backend
|
|
639
|
+
if (nativeBackend.registerSessionToken) {
|
|
640
|
+
const dotIdx = compound.indexOf('.');
|
|
641
|
+
if (dotIdx >= 0) {
|
|
642
|
+
const suffix = compound.slice(dotIdx + 1);
|
|
643
|
+
try {
|
|
644
|
+
const padded = suffix.replace(/-/g, '+').replace(/_/g, '/');
|
|
645
|
+
const padding = (4 - (padded.length % 4)) % 4;
|
|
646
|
+
const decoded = JSON.parse(atob(padded + '='.repeat(padding)));
|
|
647
|
+
if (typeof decoded?.t === 'string') {
|
|
648
|
+
await nativeBackend.registerSessionToken(decoded.t, grantScope, maxConnections).catch(() => { });
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
catch { /* malformed suffix */ }
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
return compound;
|
|
655
|
+
}
|
|
656
|
+
catch {
|
|
657
|
+
// Fall back to WASM ticket
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return this.client.getEndpointTicketWithToken(grantScope, maxConnections);
|
|
661
|
+
}
|
|
662
|
+
/** Revoke all session tokens that match the given scope. */
|
|
663
|
+
async revokeTokensByScope(grantScope) {
|
|
664
|
+
return this.revokeTokensByScopeInternal(grantScope, {
|
|
665
|
+
rotateManagedScopeIfActive: true,
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
async revokeTokensByScopeInternal(grantScope, options) {
|
|
669
|
+
const localAffected = await this.client.revokeTokensByScope(grantScope);
|
|
670
|
+
// Also revoke on native backend and disconnect affected connections
|
|
671
|
+
const nativeBackend = this.backend;
|
|
672
|
+
let nativeAffected = [];
|
|
673
|
+
if (nativeBackend?.revokeSessionTokensByScope) {
|
|
674
|
+
nativeAffected = await nativeBackend.revokeSessionTokensByScope(grantScope).catch(() => []);
|
|
675
|
+
}
|
|
676
|
+
const affected = Array.from(new Set([...localAffected, ...nativeAffected]));
|
|
677
|
+
const shouldRotateManagedGrant = options?.rotateManagedScopeIfActive !== false &&
|
|
678
|
+
grantScope === RuntimeClient.MANAGED_USER_DEVICE_SCOPE &&
|
|
679
|
+
!!this.managedSessionOptions &&
|
|
680
|
+
this.managedSessionOptions.presence !== false;
|
|
681
|
+
if (!shouldRotateManagedGrant && grantScope === RuntimeClient.MANAGED_USER_DEVICE_SCOPE) {
|
|
682
|
+
this.clearPersistedManagedUserDeviceGrant(this.managedSessionUserId);
|
|
683
|
+
}
|
|
684
|
+
if (shouldRotateManagedGrant) {
|
|
685
|
+
await this.rotateManagedUserDeviceGrant();
|
|
686
|
+
}
|
|
687
|
+
return affected;
|
|
688
|
+
}
|
|
689
|
+
/** Revoke a single session token by its raw value. */
|
|
690
|
+
revokeSessionToken(token) {
|
|
691
|
+
this.client.revokeSessionToken(token);
|
|
692
|
+
}
|
|
693
|
+
async getEndpointTicket() {
|
|
694
|
+
const backend = this.backend;
|
|
695
|
+
if (backend && typeof backend.getEndpointTicket === 'function') {
|
|
696
|
+
try {
|
|
697
|
+
return await backend.getEndpointTicket();
|
|
698
|
+
}
|
|
699
|
+
catch {
|
|
700
|
+
// Wasm adapters may not implement a dedicated endpoint-ticket method.
|
|
701
|
+
// Fall back to the runtime client ticket surface instead of failing
|
|
702
|
+
// managed-session startup.
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return this.client.getTicket();
|
|
706
|
+
}
|
|
707
|
+
async startManagedSession(options) {
|
|
708
|
+
const sessionKey = JSON.stringify({
|
|
709
|
+
platform: options.platform,
|
|
710
|
+
userId: options.userId ?? null,
|
|
711
|
+
deviceName: options.deviceName ?? null,
|
|
712
|
+
localDeviceId: options.localDeviceId ?? null,
|
|
713
|
+
metadata: options.metadata ?? null,
|
|
714
|
+
autoConnect: options.autoConnect !== false,
|
|
715
|
+
presence: options.presence !== false,
|
|
716
|
+
});
|
|
717
|
+
if (this.managedSessionKey === sessionKey) {
|
|
718
|
+
const device = await this.getLocalDeviceProfile({
|
|
719
|
+
deviceName: options.deviceName,
|
|
720
|
+
localDeviceId: options.localDeviceId ?? null,
|
|
721
|
+
platform: options.platform,
|
|
722
|
+
userId: options.userId ?? null,
|
|
723
|
+
});
|
|
724
|
+
return {
|
|
725
|
+
mode: options.platform,
|
|
726
|
+
device,
|
|
727
|
+
stop: async () => this.stopManagedSession(),
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
await this.stopManagedSession();
|
|
731
|
+
this.managedSessionKey = sessionKey;
|
|
732
|
+
this.managedSessionUserId = options.userId ?? this.identity?.id ?? null;
|
|
733
|
+
this.managedSessionOptions = { ...options };
|
|
734
|
+
let managedTicket = null;
|
|
735
|
+
if (options.platform === 'web') {
|
|
736
|
+
await this.initialize();
|
|
737
|
+
if (options.presence !== false) {
|
|
738
|
+
await this.client.clearSessionTokens().catch(() => { });
|
|
739
|
+
const restoredGrant = this.loadPersistedManagedUserDeviceGrant(this.managedSessionUserId);
|
|
740
|
+
if (restoredGrant) {
|
|
741
|
+
managedTicket = RuntimeClient.encodeManagedCompoundTicket(await this.getEndpointTicket(), restoredGrant);
|
|
742
|
+
console.info('[RuntimeClient][managed-ticket][restore]', {
|
|
743
|
+
scope: restoredGrant.scope,
|
|
744
|
+
userId: this.managedSessionUserId,
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
else {
|
|
748
|
+
managedTicket = await this.getTicketWithToken(RuntimeClient.MANAGED_USER_DEVICE_SCOPE, 0);
|
|
749
|
+
this.persistManagedUserDeviceGrantFromTicket(this.managedSessionUserId, managedTicket);
|
|
750
|
+
}
|
|
751
|
+
this.client.setManagedPresenceTicket(managedTicket);
|
|
752
|
+
await this.start({ clearSessionTokens: false });
|
|
753
|
+
}
|
|
754
|
+
else {
|
|
755
|
+
this.client.clearManagedPresenceTicket();
|
|
756
|
+
await this.start();
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
else {
|
|
760
|
+
await this.getManagedNodeId({ initializeIfMissing: true });
|
|
761
|
+
if (options.presence !== false) {
|
|
762
|
+
managedTicket = await this.getTicketWithToken(RuntimeClient.MANAGED_USER_DEVICE_SCOPE, 0);
|
|
763
|
+
this.client.setManagedPresenceTicket(managedTicket);
|
|
764
|
+
}
|
|
765
|
+
else {
|
|
766
|
+
this.client.clearManagedPresenceTicket();
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
// Non-blocking: fetch and cache app limits for this session. Falls back
|
|
770
|
+
// to free-tier defaults already set on client.appLimits if this fails.
|
|
771
|
+
void this.fetchAndApplyAppLimits();
|
|
772
|
+
const device = await this.getLocalDeviceProfile({
|
|
773
|
+
deviceName: options.deviceName,
|
|
774
|
+
localDeviceId: options.localDeviceId ?? null,
|
|
775
|
+
platform: options.platform,
|
|
776
|
+
userId: options.userId ?? null,
|
|
777
|
+
});
|
|
778
|
+
if (options.presence !== false) {
|
|
779
|
+
await this.startPresenceLoop({
|
|
780
|
+
deviceName: device.deviceName,
|
|
781
|
+
ticket: managedTicket,
|
|
782
|
+
metadata: options.metadata,
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
if (options.autoConnect !== false && device.deviceId) {
|
|
786
|
+
await this.startAutoConnect(device.deviceId);
|
|
787
|
+
}
|
|
788
|
+
await this.refreshPeerSnapshot().catch(() => []);
|
|
789
|
+
return {
|
|
790
|
+
mode: options.platform,
|
|
791
|
+
device,
|
|
792
|
+
stop: async () => this.stopManagedSession(),
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
async stopManagedSession(options) {
|
|
796
|
+
if (!this.managedSessionKey && !this.managedSessionUserId) {
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
const preserveBackendSession = !!options?.preserveBackendSession;
|
|
800
|
+
const backend = this.backend;
|
|
801
|
+
const managedOptions = this.managedSessionOptions;
|
|
802
|
+
this.client.clearManagedPresenceTicket();
|
|
803
|
+
if (!preserveBackendSession) {
|
|
804
|
+
await this.revokeTokensByScopeInternal(RuntimeClient.MANAGED_USER_DEVICE_SCOPE, {
|
|
805
|
+
rotateManagedScopeIfActive: false,
|
|
806
|
+
}).catch(() => []);
|
|
807
|
+
this.stop();
|
|
808
|
+
if (backend && typeof backend.stopAuthScopedActivity === 'function') {
|
|
809
|
+
await backend.stopAuthScopedActivity({
|
|
810
|
+
userId: this.managedSessionUserId ?? this.identity?.id ?? null,
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
else {
|
|
814
|
+
await this.setOffline().catch(() => undefined);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
else {
|
|
818
|
+
console.info('[RuntimeClient] Preserving native backend-managed session across frontend teardown', {
|
|
819
|
+
userId: this.managedSessionUserId ?? this.identity?.id ?? null,
|
|
820
|
+
scope: RuntimeClient.MANAGED_USER_DEVICE_SCOPE,
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
this.managedSessionKey = null;
|
|
824
|
+
this.managedSessionUserId = null;
|
|
825
|
+
this.managedSessionOptions = null;
|
|
826
|
+
if (managedOptions?.platform === 'web') {
|
|
827
|
+
this.clearPersistedManagedUserDeviceGrant(managedOptions.userId ?? this.identity?.id ?? null);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
async rotateManagedUserDeviceGrant() {
|
|
831
|
+
const managedOptions = this.managedSessionOptions;
|
|
832
|
+
if (!managedOptions || managedOptions.presence === false) {
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
const nextTicket = await this.getTicketWithToken(RuntimeClient.MANAGED_USER_DEVICE_SCOPE, 0);
|
|
836
|
+
const effectiveUserId = this.managedSessionUserId ?? managedOptions.userId ?? this.identity?.id ?? null;
|
|
837
|
+
if (managedOptions.platform === 'web') {
|
|
838
|
+
this.persistManagedUserDeviceGrantFromTicket(effectiveUserId, nextTicket);
|
|
839
|
+
}
|
|
840
|
+
this.client.setManagedPresenceTicket(nextTicket);
|
|
841
|
+
const device = await this.getLocalDeviceProfile({
|
|
842
|
+
deviceName: managedOptions.deviceName,
|
|
843
|
+
localDeviceId: managedOptions.localDeviceId ?? null,
|
|
844
|
+
platform: managedOptions.platform,
|
|
845
|
+
userId: managedOptions.userId ?? null,
|
|
846
|
+
});
|
|
847
|
+
await this.startPresenceLoop({
|
|
848
|
+
deviceName: device.deviceName,
|
|
849
|
+
ticket: nextTicket,
|
|
850
|
+
metadata: managedOptions.metadata,
|
|
851
|
+
});
|
|
852
|
+
console.info('[RuntimeClient][managed-ticket][rotate]', {
|
|
853
|
+
scope: RuntimeClient.MANAGED_USER_DEVICE_SCOPE,
|
|
854
|
+
platform: managedOptions.platform,
|
|
855
|
+
userId: effectiveUserId,
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
async listDevices() {
|
|
859
|
+
return this.client.searchDevices();
|
|
860
|
+
}
|
|
861
|
+
async listDevicesWithStatus() {
|
|
862
|
+
const backend = this.backend;
|
|
863
|
+
if (backend && typeof backend.listDevicesWithStatus === 'function') {
|
|
864
|
+
const devices = await backend.listDevicesWithStatus();
|
|
865
|
+
const [sessionsResult, peersResult] = await Promise.allSettled([
|
|
866
|
+
typeof backend.listPeerSessions === 'function'
|
|
867
|
+
? backend.listPeerSessions()
|
|
868
|
+
: Promise.resolve([]),
|
|
869
|
+
this.refreshPeerSnapshot().catch((error) => {
|
|
870
|
+
const cachedPeers = this.listConnectedPeers();
|
|
871
|
+
console.warn(`[RuntimeClient] refreshPeerSnapshot failed during listDevicesWithStatus(); using ${cachedPeers.length} cached connected peers`, error);
|
|
872
|
+
return cachedPeers;
|
|
873
|
+
}),
|
|
874
|
+
]);
|
|
875
|
+
const sessions = sessionsResult.status === 'fulfilled' ? sessionsResult.value : [];
|
|
876
|
+
const peers = peersResult.status === 'fulfilled' ? peersResult.value : [];
|
|
877
|
+
const withSessions = this.overlayPeerSessionsOnDeviceSnapshots(devices, sessions);
|
|
878
|
+
return this.overlayPeerStateOnDeviceSnapshots(withSessions, peers);
|
|
879
|
+
}
|
|
880
|
+
throw new Error('[pluto-rtc] listDevicesWithStatus() requires a Rust-backed runtime adapter that implements listDevicesWithStatus().');
|
|
881
|
+
}
|
|
882
|
+
async updateDevice(deviceId, updates) {
|
|
883
|
+
await this.client.updateDevice(deviceId, updates);
|
|
884
|
+
}
|
|
885
|
+
async deleteDevice(deviceId) {
|
|
886
|
+
await this.client.deleteDevice(deviceId);
|
|
887
|
+
}
|
|
888
|
+
watchDevices(callback) {
|
|
889
|
+
return this.client.onDevicesChange(callback);
|
|
890
|
+
}
|
|
891
|
+
watchDevicesWithStatus(callback) {
|
|
892
|
+
const backend = this.backend;
|
|
893
|
+
if (!backend || typeof backend.listDevicesWithStatus !== 'function') {
|
|
894
|
+
throw new Error('[pluto-rtc] watchDevicesWithStatus() requires a Rust-backed runtime adapter that implements listDevicesWithStatus().');
|
|
895
|
+
}
|
|
896
|
+
let active = true;
|
|
897
|
+
let lastSnapshotKey = null;
|
|
898
|
+
let lastAuthoritativeSnapshot = [];
|
|
899
|
+
let lastWatchedDevices = null;
|
|
900
|
+
let latestPeerStates = [];
|
|
901
|
+
let refreshTimer = null;
|
|
902
|
+
let refreshInFlight = false;
|
|
903
|
+
let refreshQueued = false;
|
|
904
|
+
const emitSnapshot = (devices) => {
|
|
905
|
+
const nextDevices = this.overlayPeerStateOnDeviceSnapshots(devices, latestPeerStates);
|
|
906
|
+
const nextKey = this.deviceStatusSnapshotKey(nextDevices);
|
|
907
|
+
if (nextKey === lastSnapshotKey) {
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
lastSnapshotKey = nextKey;
|
|
911
|
+
callback(nextDevices);
|
|
912
|
+
};
|
|
913
|
+
const runRefresh = () => {
|
|
914
|
+
if (!active)
|
|
915
|
+
return;
|
|
916
|
+
if (refreshInFlight) {
|
|
917
|
+
refreshQueued = true;
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
refreshInFlight = true;
|
|
921
|
+
void this.listDevicesWithStatus()
|
|
922
|
+
.then((devices) => {
|
|
923
|
+
if (active) {
|
|
924
|
+
lastAuthoritativeSnapshot = devices;
|
|
925
|
+
emitSnapshot(this.mergeWatchedDevicesIntoStatusSnapshots(lastWatchedDevices, devices));
|
|
926
|
+
}
|
|
927
|
+
})
|
|
928
|
+
.catch(() => undefined)
|
|
929
|
+
.finally(() => finalizeRefresh());
|
|
930
|
+
};
|
|
931
|
+
const finalizeRefresh = () => {
|
|
932
|
+
refreshInFlight = false;
|
|
933
|
+
if (!active || !refreshQueued) {
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
refreshQueued = false;
|
|
937
|
+
scheduleRefresh();
|
|
938
|
+
};
|
|
939
|
+
const scheduleRefresh = () => {
|
|
940
|
+
if (!active || refreshTimer) {
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
refreshTimer = setTimeout(() => {
|
|
944
|
+
refreshTimer = null;
|
|
945
|
+
runRefresh();
|
|
946
|
+
}, RuntimeClient.DEVICE_STATUS_REFRESH_DEBOUNCE_MS);
|
|
947
|
+
};
|
|
948
|
+
const offDevices = this.watchDevices((devices) => {
|
|
949
|
+
if (Array.isArray(devices)) {
|
|
950
|
+
lastWatchedDevices = devices;
|
|
951
|
+
emitSnapshot(this.mergeWatchedDevicesIntoStatusSnapshots(devices, lastAuthoritativeSnapshot));
|
|
952
|
+
}
|
|
953
|
+
scheduleRefresh();
|
|
954
|
+
});
|
|
955
|
+
const offPeers = this.watchPeerStates((peers) => {
|
|
956
|
+
latestPeerStates = Array.isArray(peers) ? peers : [];
|
|
957
|
+
if (!lastAuthoritativeSnapshot.length) {
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
emitSnapshot(lastAuthoritativeSnapshot);
|
|
961
|
+
});
|
|
962
|
+
const stopReconciliation = this.startManagedReconciliation(() => scheduleRefresh());
|
|
963
|
+
runRefresh();
|
|
964
|
+
return () => {
|
|
965
|
+
active = false;
|
|
966
|
+
if (refreshTimer) {
|
|
967
|
+
clearTimeout(refreshTimer);
|
|
968
|
+
}
|
|
969
|
+
stopReconciliation();
|
|
970
|
+
offDevices();
|
|
971
|
+
offPeers();
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
async connectDeviceWithRetry(device, hooks, options) {
|
|
975
|
+
return this.client.connectDeviceWithRetry(device, hooks, options);
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Runtime-owned device connect flow used by product code.
|
|
979
|
+
* Rust owns the actual managed dial policy. This wrapper only:
|
|
980
|
+
* - validates the app-facing device/ticket input
|
|
981
|
+
* - forwards the request to the active runtime adapter
|
|
982
|
+
* - refreshes peer state and invokes optional app callbacks
|
|
983
|
+
*/
|
|
984
|
+
async connectDevice(device, options) {
|
|
985
|
+
let resolvedDevice = device;
|
|
986
|
+
let ticket = typeof device.ticket === 'string' ? device.ticket.trim() : '';
|
|
987
|
+
if (!ticket) {
|
|
988
|
+
const candidates = await this.listDevicesWithStatus()
|
|
989
|
+
.catch(() => this.listDevices().catch(() => []));
|
|
990
|
+
const refreshed = candidates.find((candidate) => candidate.deviceId === device.deviceId);
|
|
991
|
+
if (refreshed) {
|
|
992
|
+
resolvedDevice = { ...device, ...refreshed };
|
|
993
|
+
ticket = typeof refreshed.ticket === 'string' ? refreshed.ticket.trim() : '';
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
if (!ticket) {
|
|
997
|
+
return {
|
|
998
|
+
success: false,
|
|
999
|
+
error: `Cannot connect to ${device.deviceId}: missing endpoint ticket`,
|
|
1000
|
+
attempts: 1,
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
const currentState = this.getPeerState(device.deviceId)?.status;
|
|
1004
|
+
if (currentState === 'connected') {
|
|
1005
|
+
return {
|
|
1006
|
+
success: true,
|
|
1007
|
+
skipped: 'already-connecting',
|
|
1008
|
+
attempts: 0,
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
try {
|
|
1012
|
+
await this.connectToDevice({
|
|
1013
|
+
deviceId: resolvedDevice.deviceId,
|
|
1014
|
+
endpointTicket: ticket,
|
|
1015
|
+
});
|
|
1016
|
+
await this.refreshPeerSnapshot().catch(() => undefined);
|
|
1017
|
+
await options.onConnected?.(resolvedDevice);
|
|
1018
|
+
return {
|
|
1019
|
+
success: true,
|
|
1020
|
+
attempts: 1,
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
catch (error) {
|
|
1024
|
+
return {
|
|
1025
|
+
success: false,
|
|
1026
|
+
error: error instanceof Error ? error.message : `Cannot connect to ${device.deviceId}`,
|
|
1027
|
+
attempts: 1,
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
watchPeerStates(callback) {
|
|
1032
|
+
return this.client.watchPeerStates(callback);
|
|
1033
|
+
}
|
|
1034
|
+
getPeerState(id) {
|
|
1035
|
+
return this.client.getPeerState(id);
|
|
1036
|
+
}
|
|
1037
|
+
async resolvePeerConnectionRecords(id) {
|
|
1038
|
+
const backend = this.backend;
|
|
1039
|
+
if (!backend || typeof backend.resolvePeerConnectionRecords !== 'function') {
|
|
1040
|
+
throw new Error('[pluto-rtc] resolvePeerConnectionRecords() requires a Rust-backed runtime adapter that implements resolvePeerConnectionRecords().');
|
|
1041
|
+
}
|
|
1042
|
+
return backend.resolvePeerConnectionRecords(id);
|
|
1043
|
+
}
|
|
1044
|
+
async getPeerSession(id) {
|
|
1045
|
+
const backend = this.backend;
|
|
1046
|
+
if (!backend || typeof backend.getPeerSession !== 'function') {
|
|
1047
|
+
throw new Error('[pluto-rtc] getPeerSession() requires a Rust-backed runtime adapter that implements getPeerSession().');
|
|
1048
|
+
}
|
|
1049
|
+
const lookupIds = await this.resolvePeerLookupIds(id, backend);
|
|
1050
|
+
for (const lookupId of lookupIds) {
|
|
1051
|
+
const session = await backend.getPeerSession(lookupId);
|
|
1052
|
+
if (session) {
|
|
1053
|
+
return session;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
if (typeof backend.listPeerSessions === 'function') {
|
|
1057
|
+
return this.matchSessionByLookupId(await backend.listPeerSessions(), lookupIds);
|
|
1058
|
+
}
|
|
1059
|
+
return null;
|
|
1060
|
+
}
|
|
1061
|
+
async listPeerSessions() {
|
|
1062
|
+
const backend = this.backend;
|
|
1063
|
+
if (!backend || typeof backend.listPeerSessions !== 'function') {
|
|
1064
|
+
throw new Error('[pluto-rtc] listPeerSessions() requires a Rust-backed runtime adapter that implements listPeerSessions().');
|
|
1065
|
+
}
|
|
1066
|
+
return backend.listPeerSessions();
|
|
1067
|
+
}
|
|
1068
|
+
async waitForConnectedPeer(id, timeoutMs) {
|
|
1069
|
+
const backend = this.backend;
|
|
1070
|
+
if (!backend || typeof backend.waitForSettledPeer !== 'function') {
|
|
1071
|
+
throw new Error('[pluto-rtc] waitForConnectedPeer() requires a Rust-backed runtime adapter that implements waitForSettledPeer().');
|
|
1072
|
+
}
|
|
1073
|
+
const resolvedIdentity = typeof backend.resolvePeerIdentity === 'function'
|
|
1074
|
+
? await backend.resolvePeerIdentity(id).catch(() => null)
|
|
1075
|
+
: null;
|
|
1076
|
+
const preferredLookupId = this.uniqueLookupIds(resolvedIdentity?.peerId, resolvedIdentity?.deviceId, resolvedIdentity?.deviceIdHint, resolvedIdentity?.nodeId, id)[0] ?? id;
|
|
1077
|
+
return backend.waitForSettledPeer(preferredLookupId, timeoutMs);
|
|
1078
|
+
}
|
|
1079
|
+
async getPeerReadiness(id, timeoutMs) {
|
|
1080
|
+
const peer = this.getPeerState(id) ?? null;
|
|
1081
|
+
const session = typeof timeoutMs === 'number'
|
|
1082
|
+
? await this.waitForConnectedPeer(id, timeoutMs).catch(() => null)
|
|
1083
|
+
: await this.getPeerSession(id).catch(() => null);
|
|
1084
|
+
const status = this.normalizeUiConnectionStatus(session?.status ?? peer?.status ?? 'disconnected');
|
|
1085
|
+
const trustBlocked = peer?.routable === false && peer?.protocolState === 'unverified';
|
|
1086
|
+
return {
|
|
1087
|
+
requestedId: id,
|
|
1088
|
+
connected: status === 'connected',
|
|
1089
|
+
settledReady: trustBlocked ? false : !!session?.settledReady,
|
|
1090
|
+
status,
|
|
1091
|
+
health: session?.health ?? peer?.health ?? 'unknown',
|
|
1092
|
+
deviceId: session?.deviceId ?? peer?.deviceId ?? null,
|
|
1093
|
+
deviceIdHint: session?.deviceIdHint ?? peer?.deviceIdHint ?? null,
|
|
1094
|
+
activeConnectionId: session?.activeConnectionId ?? peer?.connectionId ?? null,
|
|
1095
|
+
readinessState: trustBlocked
|
|
1096
|
+
? peer?.protocolState
|
|
1097
|
+
: session?.readinessState ?? peer?.protocolState,
|
|
1098
|
+
readinessReason: trustBlocked
|
|
1099
|
+
? peer?.error
|
|
1100
|
+
: session?.readinessReason ?? peer?.error,
|
|
1101
|
+
scopes: [...(session?.scopes ?? peer?.scopes ?? [])],
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
async resolvePeerIdentity(id) {
|
|
1105
|
+
const backend = this.backend;
|
|
1106
|
+
if (!backend || typeof backend.resolvePeerIdentity !== 'function') {
|
|
1107
|
+
throw new Error('[pluto-rtc] resolvePeerIdentity() requires a Rust-backed runtime adapter that implements resolvePeerIdentity().');
|
|
1108
|
+
}
|
|
1109
|
+
return backend.resolvePeerIdentity(id);
|
|
1110
|
+
}
|
|
1111
|
+
async resolveIncomingStreamPeer(remoteNodeId) {
|
|
1112
|
+
return this.resolvePeerIdentity(remoteNodeId);
|
|
1113
|
+
}
|
|
1114
|
+
listConnectedPeers() {
|
|
1115
|
+
return this.client.listConnectedPeers();
|
|
1116
|
+
}
|
|
1117
|
+
addPeerScope(id, scope) {
|
|
1118
|
+
return this.client.addPeerScope(id, scope);
|
|
1119
|
+
}
|
|
1120
|
+
releasePeerScope(id, scope) {
|
|
1121
|
+
return this.client.releasePeerScope(id, scope);
|
|
1122
|
+
}
|
|
1123
|
+
getPeerScopes(id) {
|
|
1124
|
+
return this.client.getPeerScopes(id);
|
|
1125
|
+
}
|
|
1126
|
+
isSamePeer(a, b) {
|
|
1127
|
+
return this.client.isSamePeer(a, b);
|
|
1128
|
+
}
|
|
1129
|
+
async refreshPeerSnapshot() {
|
|
1130
|
+
return this.client.refreshPeerSnapshot();
|
|
1131
|
+
}
|
|
1132
|
+
async subscribeSessions(localDeviceId, callback) {
|
|
1133
|
+
return this.client.subscribeSessions(localDeviceId, callback);
|
|
1134
|
+
}
|
|
1135
|
+
getAppLimits() {
|
|
1136
|
+
return this.client.appLimits;
|
|
1137
|
+
}
|
|
1138
|
+
async createRoom() {
|
|
1139
|
+
return this.client.rooms.createRoom();
|
|
1140
|
+
}
|
|
1141
|
+
async joinRoom(roomId, options) {
|
|
1142
|
+
return this.client.rooms.joinRoom(roomId, options);
|
|
1143
|
+
}
|
|
1144
|
+
async leaveRoom(roomId) {
|
|
1145
|
+
return this.client.rooms.leaveRoom(roomId);
|
|
1146
|
+
}
|
|
1147
|
+
async getRoomMembers(roomId) {
|
|
1148
|
+
return this.client.rooms.getRoomMembers(roomId);
|
|
1149
|
+
}
|
|
1150
|
+
watchRoom(roomId, callback) {
|
|
1151
|
+
return this.client.rooms.watchRoom(roomId, callback);
|
|
1152
|
+
}
|
|
1153
|
+
/** @deprecated Use connect() (no-arg) for lifecycle, or connectTo() for peer connections */
|
|
1154
|
+
async connectByTicket(target) {
|
|
1155
|
+
return this.client.connect(target.ticket, target.timeoutMs);
|
|
1156
|
+
}
|
|
1157
|
+
async connectPeer(options) {
|
|
1158
|
+
return this.client.connectPeer(options);
|
|
1159
|
+
}
|
|
1160
|
+
async openPeerBi(id, options) {
|
|
1161
|
+
const backend = this.backend;
|
|
1162
|
+
if (backend && typeof backend.openPeerBi === 'function') {
|
|
1163
|
+
const stream = await backend.openPeerBi(id, options?.timeoutMs);
|
|
1164
|
+
const target = await this.resolvePeerStreamTarget(id);
|
|
1165
|
+
return {
|
|
1166
|
+
connection: target.connection ?? this.compatPeerConnectionFromTarget(target, id),
|
|
1167
|
+
readable: stream.readable,
|
|
1168
|
+
writable: options?.channelId
|
|
1169
|
+
? this.client.wrapChannelWritable(stream.writable, options.channelId)
|
|
1170
|
+
: stream.writable,
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
throw new Error('[pluto-rtc] openPeerBi() requires a Rust-backed runtime adapter that implements openPeerBi().');
|
|
1174
|
+
}
|
|
1175
|
+
async openPeerUni(id, options) {
|
|
1176
|
+
const backend = this.backend;
|
|
1177
|
+
if (backend && typeof backend.openPeerUni === 'function') {
|
|
1178
|
+
const stream = await backend.openPeerUni(id, options?.timeoutMs);
|
|
1179
|
+
const target = await this.resolvePeerStreamTarget(id);
|
|
1180
|
+
return {
|
|
1181
|
+
connection: target.connection ?? this.compatPeerConnectionFromTarget(target, id),
|
|
1182
|
+
writable: options?.channelId
|
|
1183
|
+
? this.client.wrapChannelWritable(stream.writable, options.channelId)
|
|
1184
|
+
: stream.writable,
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
throw new Error('[pluto-rtc] openPeerUni() requires a Rust-backed runtime adapter that implements openPeerUni().');
|
|
1188
|
+
}
|
|
1189
|
+
async connectToDevice(params) {
|
|
1190
|
+
const backend = this.backend;
|
|
1191
|
+
if (!backend || typeof backend.connectToDevice !== 'function') {
|
|
1192
|
+
throw new Error('Selected runtime adapter does not support connectToDevice().');
|
|
1193
|
+
}
|
|
1194
|
+
return backend.connectToDevice(params);
|
|
1195
|
+
}
|
|
1196
|
+
async connectRaw(target) {
|
|
1197
|
+
return this.client.connectRaw(target.ticket, target.timeoutMs);
|
|
1198
|
+
}
|
|
1199
|
+
async openBi(nodeId) {
|
|
1200
|
+
return this.client.openBi(nodeId);
|
|
1201
|
+
}
|
|
1202
|
+
async openUni(nodeId) {
|
|
1203
|
+
return this.client.openUni(nodeId);
|
|
1204
|
+
}
|
|
1205
|
+
onIncomingStream(callback) {
|
|
1206
|
+
return this.client.onIncomingStream(callback);
|
|
1207
|
+
}
|
|
1208
|
+
registerChannel(descriptor) {
|
|
1209
|
+
this.client.registerChannel(descriptor);
|
|
1210
|
+
}
|
|
1211
|
+
unregisterChannel(channelId) {
|
|
1212
|
+
this.client.unregisterChannel(channelId);
|
|
1213
|
+
}
|
|
1214
|
+
listChannels() {
|
|
1215
|
+
return this.client.listChannels();
|
|
1216
|
+
}
|
|
1217
|
+
onIncomingChannelStream(channelId, callback) {
|
|
1218
|
+
return this.client.onIncomingChannelStream(channelId, callback);
|
|
1219
|
+
}
|
|
1220
|
+
async sendExplicitFilePath(connectionId, filePath, transferId) {
|
|
1221
|
+
const backend = this.backend;
|
|
1222
|
+
if (!backend || typeof backend.sendExplicitFilePath !== 'function') {
|
|
1223
|
+
throw new Error('Selected runtime adapter does not support sendExplicitFilePath().');
|
|
1224
|
+
}
|
|
1225
|
+
return backend.sendExplicitFilePath(connectionId, filePath, transferId);
|
|
1226
|
+
}
|
|
1227
|
+
async sendExplicitFileData(params) {
|
|
1228
|
+
const backend = this.backend;
|
|
1229
|
+
if (!backend || typeof backend.sendExplicitFileData !== 'function') {
|
|
1230
|
+
throw new Error('Selected runtime adapter does not support sendExplicitFileData().');
|
|
1231
|
+
}
|
|
1232
|
+
return backend.sendExplicitFileData(params);
|
|
1233
|
+
}
|
|
1234
|
+
async getTransferHistory(limit) {
|
|
1235
|
+
const backend = this.backend;
|
|
1236
|
+
if (!backend || typeof backend.getTransferHistory !== 'function') {
|
|
1237
|
+
return [];
|
|
1238
|
+
}
|
|
1239
|
+
return backend.getTransferHistory(limit);
|
|
1240
|
+
}
|
|
1241
|
+
async deleteTransferJob(jobId) {
|
|
1242
|
+
const backend = this.backend;
|
|
1243
|
+
if (!backend || typeof backend.deleteTransferJob !== 'function') {
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
return backend.deleteTransferJob(jobId);
|
|
1247
|
+
}
|
|
1248
|
+
async startPresenceLoop(options) {
|
|
1249
|
+
const backend = this.backend;
|
|
1250
|
+
if (backend && typeof backend.startPresenceLoopOnce === 'function') {
|
|
1251
|
+
const userId = this.identity?.id;
|
|
1252
|
+
if (!userId) {
|
|
1253
|
+
throw new Error('startPresenceLoop requires an authenticated user');
|
|
1254
|
+
}
|
|
1255
|
+
const localNodeId = await this.getNodeId();
|
|
1256
|
+
const ticket = options?.ticket || await this.getEndpointTicket();
|
|
1257
|
+
const profile = await this.getLocalDeviceProfile({
|
|
1258
|
+
deviceName: options?.deviceName,
|
|
1259
|
+
platform: 'native',
|
|
1260
|
+
userId,
|
|
1261
|
+
});
|
|
1262
|
+
await this.runNativeStartLoop({
|
|
1263
|
+
label: 'startPresenceLoop',
|
|
1264
|
+
exhaustedMessage: `[RuntimeClient] startPresenceLoop exhausted retries user_id=${userId} local_node_id=${localNodeId}`,
|
|
1265
|
+
start: () => backend.startPresenceLoopOnce(userId, localNodeId, options?.deviceName || profile.deviceName, ticket, options?.metadata),
|
|
1266
|
+
});
|
|
1267
|
+
// Phase 2: wire the RTDB presence instance (created during startPresenceLoop)
|
|
1268
|
+
// into RoomManager so room member liveness uses RTDB instead of Firestore heartbeats.
|
|
1269
|
+
const rtdbPresence = backend.rtdbPresence;
|
|
1270
|
+
if (rtdbPresence) {
|
|
1271
|
+
this.client.rooms.setRtdbPresence(rtdbPresence);
|
|
1272
|
+
}
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
await this.client.startPresenceLoop(options);
|
|
1276
|
+
}
|
|
1277
|
+
async updatePresence(options) {
|
|
1278
|
+
await this.client.updatePresence(options?.isOnline, options?.ttlMs, options?.metadata, options?.ticket);
|
|
1279
|
+
}
|
|
1280
|
+
async setOffline() {
|
|
1281
|
+
await this.client.setOffline();
|
|
1282
|
+
}
|
|
1283
|
+
async startAutoConnect(localDeviceId) {
|
|
1284
|
+
const backend = this.backend;
|
|
1285
|
+
if (backend && typeof backend.startAutoConnectOnce === 'function') {
|
|
1286
|
+
const userId = this.identity?.id;
|
|
1287
|
+
if (!userId) {
|
|
1288
|
+
throw new Error('startAutoConnect requires an authenticated user');
|
|
1289
|
+
}
|
|
1290
|
+
const effectiveLocalDeviceId = localDeviceId || (await this.getLocalDeviceId());
|
|
1291
|
+
if (!effectiveLocalDeviceId) {
|
|
1292
|
+
throw new Error('startAutoConnect requires a local device id');
|
|
1293
|
+
}
|
|
1294
|
+
await this.runNativeStartLoop({
|
|
1295
|
+
label: 'startAutoConnect',
|
|
1296
|
+
exhaustedMessage: `[RuntimeClient] startAutoConnect exhausted retries user_id=${userId} local_device_id=${effectiveLocalDeviceId}`,
|
|
1297
|
+
start: () => backend.startAutoConnectOnce(userId, effectiveLocalDeviceId),
|
|
1298
|
+
});
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
await this.client.startAutoConnect(localDeviceId);
|
|
1302
|
+
}
|
|
1303
|
+
async isConnected(nodeId) {
|
|
1304
|
+
return this.client.isConnected(nodeId);
|
|
1305
|
+
}
|
|
1306
|
+
async disconnectNode(nodeId) {
|
|
1307
|
+
await this.client.disconnectNode(nodeId);
|
|
1308
|
+
}
|
|
1309
|
+
async disconnectDevice(deviceId) {
|
|
1310
|
+
const backend = this.backend;
|
|
1311
|
+
if (!backend || typeof backend.disconnectDevice !== 'function') {
|
|
1312
|
+
await this.client.forceDisconnectPeer(deviceId);
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
1315
|
+
return backend.disconnectDevice(deviceId);
|
|
1316
|
+
}
|
|
1317
|
+
async setAutoConnectExcluded(deviceId, excluded) {
|
|
1318
|
+
const backend = this.backend;
|
|
1319
|
+
if (backend && typeof backend.setAutoConnectExcluded === 'function') {
|
|
1320
|
+
await backend.setAutoConnectExcluded(deviceId, excluded);
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
async disconnectPeer(id, scope) {
|
|
1324
|
+
await this.client.disconnectPeer(id, scope);
|
|
1325
|
+
}
|
|
1326
|
+
async forceDisconnectPeer(id) {
|
|
1327
|
+
await this.client.forceDisconnectPeer(id);
|
|
1328
|
+
}
|
|
1329
|
+
async getPeerHealth(id) {
|
|
1330
|
+
return this.client.getPeerHealth(id);
|
|
1331
|
+
}
|
|
1332
|
+
async getConnectionStates() {
|
|
1333
|
+
return this.client.signaling.getConnectionStates();
|
|
1334
|
+
}
|
|
1335
|
+
onConnectionStateChange(callback) {
|
|
1336
|
+
let active = true;
|
|
1337
|
+
const seenByConnectionId = new Map();
|
|
1338
|
+
const deliver = (state) => {
|
|
1339
|
+
if (!active) {
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
const nextKey = this.connectionStateSnapshotKey(state);
|
|
1343
|
+
if (seenByConnectionId.get(state.connectionId) === nextKey) {
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
seenByConnectionId.set(state.connectionId, nextKey);
|
|
1347
|
+
callback(state);
|
|
1348
|
+
};
|
|
1349
|
+
const emitSnapshot = () => {
|
|
1350
|
+
if (!active) {
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
void this.getConnectionStates()
|
|
1354
|
+
.then((states) => {
|
|
1355
|
+
states.forEach((state) => deliver(state));
|
|
1356
|
+
})
|
|
1357
|
+
.catch(() => undefined);
|
|
1358
|
+
};
|
|
1359
|
+
const finalize = (off) => () => {
|
|
1360
|
+
active = false;
|
|
1361
|
+
stopReconciliation();
|
|
1362
|
+
off?.();
|
|
1363
|
+
};
|
|
1364
|
+
const stopReconciliation = this.startManagedReconciliation(() => emitSnapshot());
|
|
1365
|
+
const off = this.client.signaling.onConnectionStateChange((state) => deliver(state));
|
|
1366
|
+
emitSnapshot();
|
|
1367
|
+
if (typeof off === 'function') {
|
|
1368
|
+
return finalize(off);
|
|
1369
|
+
}
|
|
1370
|
+
return Promise.resolve(off).then((resolved) => finalize(typeof resolved === 'function' ? resolved : null));
|
|
1371
|
+
}
|
|
1372
|
+
getConnections() {
|
|
1373
|
+
return this.client.getConnections();
|
|
1374
|
+
}
|
|
1375
|
+
onConnection(callback) {
|
|
1376
|
+
return this.client.onConnection(callback);
|
|
1377
|
+
}
|
|
1378
|
+
onDisconnection(callback) {
|
|
1379
|
+
return this.client.onDisconnection(callback);
|
|
1380
|
+
}
|
|
1381
|
+
onConnectionEvent(callback) {
|
|
1382
|
+
const offConnected = this.client.onConnection((connection) => {
|
|
1383
|
+
callback({
|
|
1384
|
+
type: 'connected',
|
|
1385
|
+
connectionId: connection.id,
|
|
1386
|
+
localNodeId: connection.localNodeId,
|
|
1387
|
+
remoteNodeId: connection.remoteNodeId,
|
|
1388
|
+
transport: connection.transportType,
|
|
1389
|
+
});
|
|
1390
|
+
});
|
|
1391
|
+
const offDisconnected = this.client.onDisconnection((connection) => {
|
|
1392
|
+
callback({
|
|
1393
|
+
type: 'disconnected',
|
|
1394
|
+
connectionId: connection.id,
|
|
1395
|
+
localNodeId: connection.localNodeId,
|
|
1396
|
+
remoteNodeId: connection.remoteNodeId,
|
|
1397
|
+
transport: connection.transportType,
|
|
1398
|
+
});
|
|
1399
|
+
});
|
|
1400
|
+
return () => {
|
|
1401
|
+
offConnected();
|
|
1402
|
+
offDisconnected();
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1405
|
+
forceReconnectSnapshot() {
|
|
1406
|
+
this.client.forceReconnectSnapshot();
|
|
1407
|
+
}
|
|
1408
|
+
// ─── Method Aliases (new naming convention) ─────────────────────────
|
|
1409
|
+
// These provide the simplified API. Old names remain for backward compat.
|
|
1410
|
+
/** Single-call lifecycle: combines initialize() + start() */
|
|
1411
|
+
async connect() {
|
|
1412
|
+
await this.initialize();
|
|
1413
|
+
await this.start();
|
|
1414
|
+
}
|
|
1415
|
+
/** Disconnect from the runtime (alias for stop()) */
|
|
1416
|
+
disconnect() {
|
|
1417
|
+
this.stop();
|
|
1418
|
+
}
|
|
1419
|
+
/** Alias for onIdentityChange */
|
|
1420
|
+
onAuthChange(callback) {
|
|
1421
|
+
return this.onIdentityChange(callback);
|
|
1422
|
+
}
|
|
1423
|
+
/** Alias for setAuthContext */
|
|
1424
|
+
async setAuth(context) {
|
|
1425
|
+
return this.setAuthContext(context);
|
|
1426
|
+
}
|
|
1427
|
+
/** Alias for signInWithPluto */
|
|
1428
|
+
async signIn() {
|
|
1429
|
+
return this.signInWithPluto();
|
|
1430
|
+
}
|
|
1431
|
+
/** Alias for listDevices */
|
|
1432
|
+
async getDevices() {
|
|
1433
|
+
return this.listDevices();
|
|
1434
|
+
}
|
|
1435
|
+
onDevices(callback, options) {
|
|
1436
|
+
if (options?.status) {
|
|
1437
|
+
return this.watchDevicesWithStatus(callback);
|
|
1438
|
+
}
|
|
1439
|
+
return this.watchDevices(callback);
|
|
1440
|
+
}
|
|
1441
|
+
/** Alias for getTicket — returns an invite/ticket string */
|
|
1442
|
+
async getInvite() {
|
|
1443
|
+
return this.getTicket();
|
|
1444
|
+
}
|
|
1445
|
+
/** Connect to a peer by device ID or options */
|
|
1446
|
+
async connectTo(options) {
|
|
1447
|
+
return this.connectPeer(options);
|
|
1448
|
+
}
|
|
1449
|
+
/** Alias for waitForConnectedPeer */
|
|
1450
|
+
async waitFor(id, timeoutMs) {
|
|
1451
|
+
return this.waitForConnectedPeer(id, timeoutMs);
|
|
1452
|
+
}
|
|
1453
|
+
/** Open a stream to a peer (default: bidirectional) */
|
|
1454
|
+
async openStream(id, options) {
|
|
1455
|
+
if (options?.direction === 'send') {
|
|
1456
|
+
return this.openPeerUni(id, options);
|
|
1457
|
+
}
|
|
1458
|
+
return this.openPeerBi(id, options);
|
|
1459
|
+
}
|
|
1460
|
+
async openChannelStream(channelId, id, options) {
|
|
1461
|
+
return this.openStream(id, {
|
|
1462
|
+
...options,
|
|
1463
|
+
channelId,
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
/** Alias for onIncomingStream */
|
|
1467
|
+
onStream(callback) {
|
|
1468
|
+
return this.onIncomingStream(callback);
|
|
1469
|
+
}
|
|
1470
|
+
onChannelStream(channelId, callback) {
|
|
1471
|
+
return this.onIncomingChannelStream(channelId, callback);
|
|
1472
|
+
}
|
|
1473
|
+
/** Alias for watchRoom */
|
|
1474
|
+
onRoomChange(roomId, callback) {
|
|
1475
|
+
return this.watchRoom(roomId, callback);
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
RuntimeClient.NATIVE_START_RETRY_WINDOW_MS = 120000;
|
|
1479
|
+
RuntimeClient.NATIVE_START_RETRY_MIN_DELAY_MS = 400;
|
|
1480
|
+
RuntimeClient.NATIVE_START_RETRY_MAX_DELAY_MS = 5000;
|
|
1481
|
+
RuntimeClient.MANAGED_RECONCILIATION_INTERVAL_MS = 30000;
|
|
1482
|
+
RuntimeClient.DEVICE_STATUS_REFRESH_DEBOUNCE_MS = 150;
|
|
1483
|
+
RuntimeClient.MANAGED_USER_DEVICE_SCOPE = 'user-device';
|
|
1484
|
+
RuntimeClient.MANAGED_USER_DEVICE_STORAGE_KEY_PREFIX = 'openrtc:managed:user-device';
|