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.
Files changed (122) hide show
  1. package/README.md +106 -0
  2. package/dist/OpenRTC.d.ts +71 -0
  3. package/dist/OpenRTC.js +130 -0
  4. package/dist/api/MediaTransport.d.ts +7 -0
  5. package/dist/api/MediaTransport.js +262 -0
  6. package/dist/api/PlutoPeerConnection.d.ts +50 -0
  7. package/dist/api/PlutoPeerConnection.js +434 -0
  8. package/dist/api/PlutoWebSocket.d.ts +24 -0
  9. package/dist/api/PlutoWebSocket.js +113 -0
  10. package/dist/api/PlutoWebTransport.d.ts +29 -0
  11. package/dist/api/PlutoWebTransport.js +105 -0
  12. package/dist/auth/host.d.ts +73 -0
  13. package/dist/auth/host.js +513 -0
  14. package/dist/auth/index.d.ts +1 -0
  15. package/dist/auth/index.js +1 -0
  16. package/dist/auth/internal.d.ts +1 -0
  17. package/dist/auth/internal.js +1 -0
  18. package/dist/compat/index.d.ts +20 -0
  19. package/dist/compat/index.js +20 -0
  20. package/dist/core/Client.d.ts +352 -0
  21. package/dist/core/Client.js +3689 -0
  22. package/dist/core/Connection.d.ts +67 -0
  23. package/dist/core/Connection.js +404 -0
  24. package/dist/core/IEngineBridge.d.ts +18 -0
  25. package/dist/core/IEngineBridge.js +1 -0
  26. package/dist/core/ISignalingTransport.d.ts +13 -0
  27. package/dist/core/ISignalingTransport.js +1 -0
  28. package/dist/core/PeerLifecycleManager.d.ts +43 -0
  29. package/dist/core/PeerLifecycleManager.js +330 -0
  30. package/dist/core/PeerProjectionStore.d.ts +43 -0
  31. package/dist/core/PeerProjectionStore.js +327 -0
  32. package/dist/core/PlutoRTC.d.ts +13 -0
  33. package/dist/core/PlutoRTC.js +26 -0
  34. package/dist/core/Room.d.ts +47 -0
  35. package/dist/core/Room.js +376 -0
  36. package/dist/core/Signaling.d.ts +39 -0
  37. package/dist/core/Signaling.js +147 -0
  38. package/dist/core/bridges/BridgeCodecs.d.ts +19 -0
  39. package/dist/core/bridges/BridgeCodecs.js +317 -0
  40. package/dist/core/bridges/BrowserFirestoreRooms.d.ts +36 -0
  41. package/dist/core/bridges/BrowserFirestoreRooms.js +169 -0
  42. package/dist/core/bridges/BrowserFirestoreSignaling.d.ts +111 -0
  43. package/dist/core/bridges/BrowserFirestoreSignaling.js +1010 -0
  44. package/dist/core/bridges/NativeIpcBridge.d.ts +156 -0
  45. package/dist/core/bridges/NativeIpcBridge.js +1472 -0
  46. package/dist/core/bridges/RtdbPresence.d.ts +122 -0
  47. package/dist/core/bridges/RtdbPresence.js +201 -0
  48. package/dist/core/bridges/TauriBridge.d.ts +8 -0
  49. package/dist/core/bridges/TauriBridge.js +7 -0
  50. package/dist/core/bridges/WasmBridge.d.ts +233 -0
  51. package/dist/core/bridges/WasmBridge.js +1811 -0
  52. package/dist/core/config.d.ts +31 -0
  53. package/dist/core/config.js +138 -0
  54. package/dist/core/transports/ITransport.d.ts +12 -0
  55. package/dist/core/transports/ITransport.js +1 -0
  56. package/dist/core/transports/LocalDaemonBridge.d.ts +41 -0
  57. package/dist/core/transports/LocalDaemonBridge.js +227 -0
  58. package/dist/core/transports/TauriSignaling.d.ts +28 -0
  59. package/dist/core/transports/TauriSignaling.js +373 -0
  60. package/dist/core/transports/TransportFactory.d.ts +9 -0
  61. package/dist/core/transports/TransportFactory.js +59 -0
  62. package/dist/core/transports/WasmSignaling.d.ts +78 -0
  63. package/dist/core/transports/WasmSignaling.js +522 -0
  64. package/dist/core/transports/WebRTCTransport.d.ts +23 -0
  65. package/dist/core/transports/WebRTCTransport.js +132 -0
  66. package/dist/core/trust.d.ts +23 -0
  67. package/dist/core/trust.js +99 -0
  68. package/dist/core/types.d.ts +559 -0
  69. package/dist/core/types.js +19 -0
  70. package/dist/index.d.ts +3 -0
  71. package/dist/index.js +3 -0
  72. package/dist/moq/MoQMediaTransport.d.ts +6 -0
  73. package/dist/moq/MoQMediaTransport.js +106 -0
  74. package/dist/moq/MoQPublisher.d.ts +22 -0
  75. package/dist/moq/MoQPublisher.js +179 -0
  76. package/dist/moq/MoQSession.d.ts +35 -0
  77. package/dist/moq/MoQSession.js +166 -0
  78. package/dist/moq/MoQSubscriber.d.ts +19 -0
  79. package/dist/moq/MoQSubscriber.js +235 -0
  80. package/dist/moq/common.d.ts +47 -0
  81. package/dist/moq/common.js +304 -0
  82. package/dist/moq/messages.d.ts +105 -0
  83. package/dist/moq/messages.js +367 -0
  84. package/dist/presets/plutoHosted.d.ts +13 -0
  85. package/dist/presets/plutoHosted.js +18 -0
  86. package/dist/runtime/DelegatingRuntimeAdapter.d.ts +68 -0
  87. package/dist/runtime/DelegatingRuntimeAdapter.js +250 -0
  88. package/dist/runtime/IpcRuntimeAdapter.d.ts +9 -0
  89. package/dist/runtime/IpcRuntimeAdapter.js +13 -0
  90. package/dist/runtime/RuntimeClient.d.ts +257 -0
  91. package/dist/runtime/RuntimeClient.js +1484 -0
  92. package/dist/runtime/TauriRuntimeAdapter.d.ts +1 -0
  93. package/dist/runtime/TauriRuntimeAdapter.js +1 -0
  94. package/dist/runtime/WasmRuntimeAdapter.d.ts +6 -0
  95. package/dist/runtime/WasmRuntimeAdapter.js +11 -0
  96. package/dist/runtime/factories.d.ts +24 -0
  97. package/dist/runtime/factories.js +66 -0
  98. package/dist/runtime/index.d.ts +6 -0
  99. package/dist/runtime/index.js +5 -0
  100. package/dist/runtime/ipc.d.ts +8 -0
  101. package/dist/runtime/ipc.js +65 -0
  102. package/dist/runtime/ipcContract.d.ts +46 -0
  103. package/dist/runtime/ipcContract.js +1 -0
  104. package/dist/runtime/tauri.d.ts +16 -0
  105. package/dist/runtime/tauri.js +22 -0
  106. package/dist/runtime/types.d.ts +110 -0
  107. package/dist/runtime/types.js +1 -0
  108. package/dist/transports/TauriTransport.d.ts +17 -0
  109. package/dist/transports/TauriTransport.js +49 -0
  110. package/dist/transports/WebTransport.d.ts +23 -0
  111. package/dist/transports/WebTransport.js +71 -0
  112. package/dist/utils/debug.d.ts +4 -0
  113. package/dist/utils/debug.js +33 -0
  114. package/dist/utils/runtime.d.ts +1 -0
  115. package/dist/utils/runtime.js +10 -0
  116. package/package.json +97 -0
  117. package/wasm/README.md +64 -0
  118. package/wasm/openrtc.d.ts +284 -0
  119. package/wasm/openrtc.js +2132 -0
  120. package/wasm/openrtc_bg.wasm +0 -0
  121. package/wasm/openrtc_bg.wasm.d.ts +110 -0
  122. package/wasm/package.json +16 -0
@@ -0,0 +1,1811 @@
1
+ import { getAuthHost } from '../../auth/host';
2
+ import { resolveAppTag, resolveAuthMode, resolveProjectId, resolveStorageNamespace } from '../config';
3
+ import { BrowserFirestoreSignaling } from './BrowserFirestoreSignaling';
4
+ import { RtdbPresence } from './RtdbPresence';
5
+ import { openRtcDebug, openRtcDebugInfo } from '../../utils/debug';
6
+ import { isPersonalSpaceMode as isPersonalDiscoveryMode, matchesDiscoveryTag, normalizeConnectionStateSnapshot as normalizeConnectionStateSnapshotRecord, normalizeDeviceRecord, normalizeDeviceStatusSnapshotRecord, normalizePeerSessionSnapshotRecord, peerSessionToBackendConnectionState, sameConnectionState as sameNormalizedConnectionState, } from './BridgeCodecs';
7
+ function decodeBase64UrlJsonForLogs(payload) {
8
+ try {
9
+ const normalized = payload.replace(/-/g, '+').replace(/_/g, '/');
10
+ const paddingLen = (4 - (normalized.length % 4)) % 4;
11
+ const padded = normalized + '='.repeat(paddingLen);
12
+ if (typeof atob === 'function') {
13
+ return JSON.parse(atob(padded));
14
+ }
15
+ const globalBuffer = globalThis.Buffer;
16
+ if (globalBuffer) {
17
+ return JSON.parse(globalBuffer.from(padded, 'base64').toString('utf8'));
18
+ }
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ return null;
24
+ }
25
+ function fingerprintForLogs(value) {
26
+ if (!value) {
27
+ return null;
28
+ }
29
+ let hash = 0x811c9dc5;
30
+ for (let i = 0; i < value.length; i += 1) {
31
+ hash ^= value.charCodeAt(i);
32
+ hash = Math.imul(hash, 0x01000193);
33
+ }
34
+ return `fp-${(hash >>> 0).toString(16).padStart(8, '0')}`;
35
+ }
36
+ function summarizeCompoundTicketForLogs(ticket) {
37
+ const trimmed = typeof ticket === 'string' ? ticket.trim() : '';
38
+ if (!trimmed) {
39
+ return { present: false };
40
+ }
41
+ const dotIndex = trimmed.lastIndexOf('.');
42
+ const irohTicket = dotIndex >= 0 ? trimmed.slice(0, dotIndex) : trimmed;
43
+ const tokenSuffix = dotIndex >= 0 ? trimmed.slice(dotIndex + 1) : null;
44
+ const decoded = tokenSuffix ? decodeBase64UrlJsonForLogs(tokenSuffix) : null;
45
+ const token = decoded && typeof decoded.t === 'string' && decoded.t.trim().length > 0
46
+ ? decoded.t.trim()
47
+ : null;
48
+ const scope = decoded && typeof decoded.s === 'string' && decoded.s.trim().length > 0
49
+ ? decoded.s.trim()
50
+ : null;
51
+ const maxConnections = decoded && typeof decoded.m === 'number' && Number.isFinite(decoded.m)
52
+ ? decoded.m
53
+ : null;
54
+ return {
55
+ present: true,
56
+ irohFingerprint: fingerprintForLogs(irohTicket),
57
+ tokenFingerprint: fingerprintForLogs(token),
58
+ tokenSuffixFingerprint: fingerprintForLogs(tokenSuffix),
59
+ scope,
60
+ maxConnections,
61
+ };
62
+ }
63
+ /**
64
+ * WasmBridge is the browser host wrapper over the Rust/WASM runtime.
65
+ *
66
+ * Allowed:
67
+ * - normalize JS/WASM binding shapes
68
+ * - forward auth, discovery, signaling, and lifecycle calls into Rust
69
+ * - expose typed browser-facing results
70
+ *
71
+ * Not allowed:
72
+ * - become a second lifecycle engine
73
+ * - invent peer identity or connect policy that is not backed by Rust
74
+ * - normalize app-specific payload protocols into runtime behavior
75
+ *
76
+ * Source of truth:
77
+ * - Rust/WASM owns lifecycle, discovery, signaling, and connection health
78
+ * - RuntimeClient owns app-facing orchestration
79
+ */
80
+ export class WasmBridge {
81
+ /** Phase 2: expose the RTDB presence instance so RuntimeClient can wire it into RoomManager. */
82
+ get rtdbPresence() {
83
+ return this._rtdbPresence;
84
+ }
85
+ constructor(options) {
86
+ this.authListeners = [];
87
+ // To be injected/set once WASM boots
88
+ this.wasmClient = null;
89
+ this.createdAtMs = Date.now();
90
+ this.missingWasmWarnings = new Set();
91
+ this.wasmReadyWarningDelayMs = 15000;
92
+ this.browserFirestoreSignaling = null;
93
+ this.lastObservedFirebaseUserId = null;
94
+ this.sessionReaders = new Set();
95
+ this.authContext = null;
96
+ this.injectedBrowserAuthSessionActive = false;
97
+ this.startedAutoConnectKey = null;
98
+ this.autoConnectStartsInFlight = new Set();
99
+ this.browserAutoConnectUnsubscribe = null;
100
+ this.browserAutoConnectLastAttemptAt = new Map();
101
+ this.browserAutoConnectInFlight = new Set();
102
+ this.browserAutoConnectWaitStartedAt = new Map();
103
+ this.browserAutoConnectEscalationCount = new Map();
104
+ this.browserAutoConnectObservedSignatures = new Map();
105
+ this.browserAutoConnectRecoveryTimers = new Map();
106
+ this.browserAutoConnectPendingDevices = new Map();
107
+ this.browserAutoConnectScanTimer = null;
108
+ this.browserPresenceLoopInterval = null;
109
+ this.browserPresenceLoopState = null;
110
+ // Phase 1: RTDB presence — replaces the 60-second Firestore heartbeat loop.
111
+ // Exposed via getter so RuntimeClient can wire the same instance into RoomManager.
112
+ this._rtdbPresence = null;
113
+ this.rtdbPresenceScope = null;
114
+ this.rtdbTicketVersion = 0;
115
+ this.rtdbPresenceDisabledForSession = false;
116
+ this.hasSyncedAuthToWasmClient = false;
117
+ this.lastWasmAuthUserId = null;
118
+ this.lastWasmAuthToken = null;
119
+ this.lastDeliveredConnectionStates = new Map();
120
+ this.appTag = resolveAppTag(options);
121
+ this.options = {
122
+ ...options,
123
+ appTag: this.appTag,
124
+ authMode: resolveAuthMode(options),
125
+ projectId: resolveProjectId(options),
126
+ };
127
+ this.persistenceReady = Promise.resolve();
128
+ if (this.isTicketOnlyMode()) {
129
+ this.app = null;
130
+ this.auth = null;
131
+ return;
132
+ }
133
+ const authHost = getAuthHost();
134
+ this.app = authHost.getApp();
135
+ this.auth = authHost.getAuth();
136
+ authHost.onAuthStateChanged((user) => {
137
+ if (this.authContext) {
138
+ return;
139
+ }
140
+ if (this.isPersonalSpaceMode()) {
141
+ this.authListeners.forEach(cb => cb(this.toRuntimeIdentity(user)));
142
+ return;
143
+ }
144
+ const nextUserId = user?.uid ?? null;
145
+ const previousUserId = this.lastObservedFirebaseUserId;
146
+ this.lastObservedFirebaseUserId = nextUserId;
147
+ if (previousUserId && previousUserId !== nextUserId) {
148
+ void this.stopAuthScopedActivity({ userId: previousUserId });
149
+ }
150
+ void this.browserFirestoreSignaling?.handleScopeChange();
151
+ this.authListeners.forEach(cb => cb(this.toRuntimeIdentity(user)));
152
+ });
153
+ }
154
+ get tag() {
155
+ return this.appTag;
156
+ }
157
+ getDeviceIdentityStorageKey() {
158
+ const suffix = resolveStorageNamespace(this.options);
159
+ return `${WasmBridge.WEB_DEVICE_ID_STORAGE_PREFIX}_${suffix}`;
160
+ }
161
+ getNodeIdPersistenceMode() {
162
+ return this.options.nodeIdPersistence ?? 'persistent';
163
+ }
164
+ isTicketOnlyMode() {
165
+ return this.options.signalingMode === 'ticket-only';
166
+ }
167
+ createRandomDeviceId() {
168
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
169
+ return crypto.randomUUID();
170
+ }
171
+ return `web-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
172
+ }
173
+ resolveWebLogicalDeviceId() {
174
+ const mode = this.getNodeIdPersistenceMode();
175
+ if (typeof localStorage === 'undefined') {
176
+ return this.createRandomDeviceId();
177
+ }
178
+ const key = this.getDeviceIdentityStorageKey();
179
+ const now = Date.now();
180
+ let stored = null;
181
+ try {
182
+ const raw = localStorage.getItem(key);
183
+ stored = raw ? JSON.parse(raw) : null;
184
+ }
185
+ catch {
186
+ stored = null;
187
+ }
188
+ const storedDeviceId = typeof stored?.deviceId === 'string' ? stored.deviceId.trim() : '';
189
+ const storedExpiry = typeof stored?.expiresAt === 'number' ? stored.expiresAt : undefined;
190
+ if (mode === 'persistent') {
191
+ const nextDeviceId = storedDeviceId || this.createRandomDeviceId();
192
+ localStorage.setItem(key, JSON.stringify({ deviceId: nextDeviceId }));
193
+ return nextDeviceId;
194
+ }
195
+ if (storedDeviceId && typeof storedExpiry === 'number' && storedExpiry > now) {
196
+ const refreshedExpiry = now + WasmBridge.EPHEMERAL_DEVICE_ID_TTL_MS;
197
+ localStorage.setItem(key, JSON.stringify({ deviceId: storedDeviceId, expiresAt: refreshedExpiry }));
198
+ return storedDeviceId;
199
+ }
200
+ const nextDeviceId = this.createRandomDeviceId();
201
+ const expiresAt = now + WasmBridge.EPHEMERAL_DEVICE_ID_TTL_MS;
202
+ localStorage.setItem(key, JSON.stringify({ deviceId: nextDeviceId, expiresAt }));
203
+ return nextDeviceId;
204
+ }
205
+ getDefaultBrowserDeviceName() {
206
+ return this.options.deviceName?.trim() || (this.options.storagePrefix
207
+ ? `Web (${this.options.storagePrefix.replace(/_$/, '')})`
208
+ : 'Web Browser');
209
+ }
210
+ getBrowserFirestoreSignaling() {
211
+ if (this.isTicketOnlyMode()) {
212
+ throw new Error('[openrtc] Browser signaling is disabled when signalingMode="ticket-only".');
213
+ }
214
+ if (!this.browserFirestoreSignaling) {
215
+ this.browserFirestoreSignaling = new BrowserFirestoreSignaling({
216
+ app: this.app,
217
+ auth: this.auth,
218
+ appTag: this.appTag,
219
+ apiKey: this.options.apiKey,
220
+ spaceKey: this.options.spaceKey,
221
+ discoveryMode: this.options.discoveryMode,
222
+ getCurrentUserId: () => this.getCurrentUserId(),
223
+ getDiscoveryScopeId: () => this.getDiscoveryScopeId(),
224
+ getLocalDeviceId: () => this.resolveWebLogicalDeviceId(),
225
+ getDefaultDeviceName: () => this.getDefaultBrowserDeviceName(),
226
+ matchesTag: (device) => this.matchesTag(device),
227
+ normalizeDevice: (raw, fallbackId) => this.normalizeDevice(raw, fallbackId),
228
+ });
229
+ }
230
+ return this.browserFirestoreSignaling;
231
+ }
232
+ withWebPresenceMetadata(metadata, localDeviceId) {
233
+ if (!metadata || !metadata.trim()) {
234
+ return JSON.stringify({ deviceId: localDeviceId });
235
+ }
236
+ try {
237
+ const parsed = JSON.parse(metadata);
238
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
239
+ return JSON.stringify({
240
+ ...parsed,
241
+ deviceId: localDeviceId,
242
+ });
243
+ }
244
+ }
245
+ catch {
246
+ // fall through to opaque metadata wrapper
247
+ }
248
+ return JSON.stringify({
249
+ deviceId: localDeviceId,
250
+ metadata,
251
+ });
252
+ }
253
+ async setAuthContext(context) {
254
+ const previousInjectedSessionActive = this.injectedBrowserAuthSessionActive;
255
+ this.authContext = context;
256
+ if (!this.isTicketOnlyMode() && context?.customToken?.trim()) {
257
+ try {
258
+ await this.ensureBrowserFirestoreAuth(context.userId, context.customToken.trim());
259
+ }
260
+ catch (error) {
261
+ // Browser Firestore auth is only needed for the web-signaling fallback path.
262
+ // On native (Tauri/IPC) all device ops go through native IPC, so a failure
263
+ // here must not abort the auth context — doing so clears authContext and breaks
264
+ // device discovery on mobile where this sign-in can fail or be unavailable.
265
+ console.warn('[WasmBridge] ensureBrowserFirestoreAuth failed (non-fatal):', error);
266
+ }
267
+ }
268
+ else if (!this.isTicketOnlyMode() && !context && previousInjectedSessionActive) {
269
+ try {
270
+ await this.auth?.signOut();
271
+ }
272
+ catch (error) {
273
+ console.warn('[WasmBridge] Failed to clear injected browser auth session:', error);
274
+ }
275
+ finally {
276
+ this.injectedBrowserAuthSessionActive = false;
277
+ }
278
+ }
279
+ this.syncWasmAuthToken();
280
+ if (!this.isTicketOnlyMode()) {
281
+ await this.browserFirestoreSignaling?.handleScopeChange();
282
+ }
283
+ this.authListeners.forEach((callback) => callback(this.currentUser));
284
+ }
285
+ async ensureBrowserFirestoreAuth(userId, customToken) {
286
+ const currentUid = this.auth?.currentUser?.uid ?? null;
287
+ if (currentUid === userId) {
288
+ this.injectedBrowserAuthSessionActive = true;
289
+ return;
290
+ }
291
+ await getAuthHost().signInWithCustomTokenValue(customToken);
292
+ this.injectedBrowserAuthSessionActive = true;
293
+ }
294
+ syncWasmAuthToken() {
295
+ if (!this.wasmClient || typeof this.wasmClient.set_auth_token !== 'function') {
296
+ return;
297
+ }
298
+ const nextUserId = this.authContext?.userId ?? null;
299
+ const nextToken = this.authContext?.token ?? null;
300
+ if (this.hasSyncedAuthToWasmClient &&
301
+ this.lastWasmAuthUserId === nextUserId &&
302
+ this.lastWasmAuthToken === nextToken) {
303
+ return;
304
+ }
305
+ this.lastWasmAuthUserId = nextUserId;
306
+ this.lastWasmAuthToken = nextToken;
307
+ this.hasSyncedAuthToWasmClient = true;
308
+ this.wasmClient.set_auth_token(nextToken);
309
+ }
310
+ toRuntimeIdentity(user) {
311
+ if (!user) {
312
+ return null;
313
+ }
314
+ return {
315
+ id: user.uid,
316
+ email: user.email,
317
+ displayName: user.displayName,
318
+ isAnonymous: user.isAnonymous,
319
+ getToken: async (forceRefresh = false) => {
320
+ try {
321
+ return await user.getIdToken(forceRefresh);
322
+ }
323
+ catch {
324
+ return null;
325
+ }
326
+ },
327
+ };
328
+ }
329
+ getCurrentUserId() {
330
+ if (this.authContext?.userId) {
331
+ return this.authContext.userId;
332
+ }
333
+ if (this.isTicketOnlyMode()) {
334
+ return null;
335
+ }
336
+ return this.auth?.currentUser?.uid ?? null;
337
+ }
338
+ getCurrentUserIdForScope() {
339
+ return this.getCurrentUserId();
340
+ }
341
+ getAuthContext() {
342
+ return this.authContext;
343
+ }
344
+ isPersonalSpaceMode() {
345
+ return isPersonalDiscoveryMode(this.options);
346
+ }
347
+ getResolvedOptions() {
348
+ return this.options;
349
+ }
350
+ usesLegacyBrowserSignalingFallback() {
351
+ if (this.isTicketOnlyMode()) {
352
+ return false;
353
+ }
354
+ if (this.isPersonalSpaceMode()) {
355
+ return false;
356
+ }
357
+ const hasApiKey = typeof this.options.apiKey === 'string' && this.options.apiKey.trim().length > 0;
358
+ return !hasApiKey;
359
+ }
360
+ getDiscoveryScopeId() {
361
+ if (this.isTicketOnlyMode()) {
362
+ return null;
363
+ }
364
+ const currentUserId = this.getCurrentUserId();
365
+ if (currentUserId) {
366
+ return currentUserId;
367
+ }
368
+ if (this.isPersonalSpaceMode()) {
369
+ return this.resolveWebLogicalDeviceId();
370
+ }
371
+ return null;
372
+ }
373
+ matchesTag(device) {
374
+ return matchesDiscoveryTag(device, this.appTag, this.isPersonalSpaceMode());
375
+ }
376
+ matchesDiscoveryTag(device) {
377
+ return this.matchesTag(device);
378
+ }
379
+ async stopSessionSubscriptions() {
380
+ const readers = Array.from(this.sessionReaders);
381
+ this.sessionReaders.clear();
382
+ await Promise.allSettled(readers.map(async (reader) => {
383
+ try {
384
+ await reader.cancel();
385
+ }
386
+ catch {
387
+ // noop
388
+ }
389
+ }));
390
+ }
391
+ async stopRuntimeAuthScopedLoops() {
392
+ const wasmClient = await this.waitForWasmClient(250);
393
+ if (!wasmClient) {
394
+ return;
395
+ }
396
+ try {
397
+ if (typeof wasmClient.stop_auth_scoped_activity === 'function') {
398
+ wasmClient.stop_auth_scoped_activity();
399
+ }
400
+ else {
401
+ if (typeof wasmClient.stop_presence_loop === 'function') {
402
+ wasmClient.stop_presence_loop();
403
+ }
404
+ if (typeof wasmClient.stop_auto_connect === 'function') {
405
+ wasmClient.stop_auto_connect();
406
+ }
407
+ }
408
+ }
409
+ catch (error) {
410
+ console.warn('[WasmSignaling] Failed to stop auth-scoped runtime loops:', error);
411
+ }
412
+ }
413
+ stopBrowserAutoConnect() {
414
+ this.browserAutoConnectUnsubscribe?.();
415
+ this.browserAutoConnectUnsubscribe = null;
416
+ if (this.browserAutoConnectScanTimer) {
417
+ clearTimeout(this.browserAutoConnectScanTimer);
418
+ this.browserAutoConnectScanTimer = null;
419
+ }
420
+ this.browserAutoConnectPendingDevices.clear();
421
+ this.startedAutoConnectKey = null;
422
+ this.autoConnectStartsInFlight.clear();
423
+ this.browserAutoConnectInFlight.clear();
424
+ this.browserAutoConnectLastAttemptAt.clear();
425
+ this.browserAutoConnectWaitStartedAt.clear();
426
+ this.browserAutoConnectEscalationCount.clear();
427
+ this.browserAutoConnectObservedSignatures.clear();
428
+ for (const timer of this.browserAutoConnectRecoveryTimers.values()) {
429
+ clearTimeout(timer);
430
+ }
431
+ this.browserAutoConnectRecoveryTimers.clear();
432
+ }
433
+ clearBrowserAutoConnectDeviceState(deviceKey) {
434
+ this.browserAutoConnectWaitStartedAt.delete(deviceKey);
435
+ this.browserAutoConnectEscalationCount.delete(deviceKey);
436
+ this.browserAutoConnectLastAttemptAt.delete(deviceKey);
437
+ const timer = this.browserAutoConnectRecoveryTimers.get(deviceKey);
438
+ if (timer) {
439
+ clearTimeout(timer);
440
+ this.browserAutoConnectRecoveryTimers.delete(deviceKey);
441
+ }
442
+ }
443
+ queueBrowserAutoConnectRecoveryForDevice(targetDevice, options) {
444
+ if (!targetDevice?.deviceId) {
445
+ return;
446
+ }
447
+ const deviceKey = targetDevice.deviceId;
448
+ this.browserAutoConnectInFlight.delete(deviceKey);
449
+ this.browserAutoConnectObservedSignatures.delete(deviceKey);
450
+ this.browserAutoConnectLastAttemptAt.delete(deviceKey);
451
+ if (options?.resetWaitStartedAt) {
452
+ this.browserAutoConnectWaitStartedAt.delete(deviceKey);
453
+ }
454
+ const localNodeId = typeof this.browserPresenceLoopState?.localNodeId === 'string'
455
+ ? this.browserPresenceLoopState.localNodeId.trim() || null
456
+ : null;
457
+ const localDeviceId = this.resolveWebLogicalDeviceId();
458
+ this.scheduleBrowserAutoConnectScan([...this.browserAutoConnectPendingDevices.values()], localNodeId, localDeviceId);
459
+ const existingTimer = this.browserAutoConnectRecoveryTimers.get(deviceKey);
460
+ if (existingTimer) {
461
+ clearTimeout(existingTimer);
462
+ }
463
+ const retryTimer = setTimeout(() => {
464
+ this.browserAutoConnectRecoveryTimers.delete(deviceKey);
465
+ if (!this.browserAutoConnectPendingDevices.has(deviceKey)) {
466
+ return;
467
+ }
468
+ this.browserAutoConnectInFlight.delete(deviceKey);
469
+ this.browserAutoConnectObservedSignatures.delete(deviceKey);
470
+ this.browserAutoConnectLastAttemptAt.delete(deviceKey);
471
+ const retryLocalNodeId = typeof this.browserPresenceLoopState?.localNodeId === 'string'
472
+ ? this.browserPresenceLoopState.localNodeId.trim() || null
473
+ : null;
474
+ this.scheduleBrowserAutoConnectScan([...this.browserAutoConnectPendingDevices.values()], retryLocalNodeId, this.resolveWebLogicalDeviceId());
475
+ }, WasmBridge.BROWSER_AUTO_CONNECT_SETTLE_GUARD_MS + 100);
476
+ this.browserAutoConnectRecoveryTimers.set(deviceKey, retryTimer);
477
+ }
478
+ triggerBrowserAutoConnectRecovery(state) {
479
+ if (!this.startedAutoConnectKey || this.browserAutoConnectPendingDevices.size === 0) {
480
+ return;
481
+ }
482
+ const targetDevice = [...this.browserAutoConnectPendingDevices.values()].find((device) => {
483
+ const deviceId = typeof device.deviceId === 'string' ? device.deviceId.trim() : '';
484
+ const nodeId = typeof device.nodeId === 'string' ? device.nodeId.trim() : '';
485
+ return ((!!state.deviceId && deviceId === state.deviceId) ||
486
+ (!!state.deviceIdHint && deviceId === state.deviceIdHint) ||
487
+ (!!state.remoteNodeId && nodeId === state.remoteNodeId));
488
+ });
489
+ if (!targetDevice?.deviceId) {
490
+ return;
491
+ }
492
+ this.queueBrowserAutoConnectRecoveryForDevice(targetDevice);
493
+ }
494
+ browserAutoConnectLifecycleFresh(lastLifecycleTransitionAtMs) {
495
+ return (typeof lastLifecycleTransitionAtMs === 'number' &&
496
+ lastLifecycleTransitionAtMs > 0 &&
497
+ Date.now() - lastLifecycleTransitionAtMs <
498
+ WasmBridge.BROWSER_AUTO_CONNECT_SETTLE_GUARD_MS);
499
+ }
500
+ getRustOwnedAutoConnectGate(device) {
501
+ const deviceId = typeof device.deviceId === 'string' ? device.deviceId.trim() : '';
502
+ const remoteNodeId = typeof device.nodeId === 'string' ? device.nodeId.trim() : '';
503
+ if (!deviceId && !remoteNodeId) {
504
+ return 'none';
505
+ }
506
+ let hasTransitionalGate = false;
507
+ for (const state of this.lastDeliveredConnectionStates.values()) {
508
+ const matchesDeviceId = deviceId.length > 0 &&
509
+ (state.deviceId === deviceId || state.deviceIdHint === deviceId);
510
+ const matchesRemoteNodeId = remoteNodeId.length > 0 &&
511
+ state.remoteNodeId === remoteNodeId;
512
+ if (!matchesDeviceId && !matchesRemoteNodeId) {
513
+ continue;
514
+ }
515
+ if (state.replacementInProgress) {
516
+ if (this.browserAutoConnectLifecycleFresh(state.lastLifecycleTransitionAtMs)) {
517
+ hasTransitionalGate = true;
518
+ }
519
+ continue;
520
+ }
521
+ if (state.state === 'connecting') {
522
+ if (state.routable === true) {
523
+ return 'healthy';
524
+ }
525
+ if (this.browserAutoConnectLifecycleFresh(state.lastLifecycleTransitionAtMs)) {
526
+ hasTransitionalGate = true;
527
+ }
528
+ continue;
529
+ }
530
+ if (state.state === 'connected') {
531
+ if (state.routable === true) {
532
+ return 'healthy';
533
+ }
534
+ if (this.browserAutoConnectLifecycleFresh(state.lastLifecycleTransitionAtMs)) {
535
+ hasTransitionalGate = true;
536
+ }
537
+ continue;
538
+ }
539
+ if (state.state === 'pending') {
540
+ hasTransitionalGate = true;
541
+ }
542
+ }
543
+ return hasTransitionalGate ? 'transitional' : 'none';
544
+ }
545
+ browserAutoConnectNonInitiatorGraceMs(escalationCount) {
546
+ const cappedCount = Math.max(0, Math.min(3, Math.trunc(escalationCount)));
547
+ const baseMs = 2000;
548
+ const expMs = Math.min(baseMs * (1 << cappedCount), 16000);
549
+ const jitterPct = (((cappedCount * 37) + 7) % 41) - 20;
550
+ return Math.max(1000, expMs + Math.trunc((expMs * jitterPct) / 100));
551
+ }
552
+ queueBrowserAutoConnectGraceExpiryRecheck(deviceKey, delayMs) {
553
+ if (!deviceKey) {
554
+ return;
555
+ }
556
+ const existingTimer = this.browserAutoConnectRecoveryTimers.get(deviceKey);
557
+ if (existingTimer) {
558
+ clearTimeout(existingTimer);
559
+ }
560
+ const retryTimer = setTimeout(() => {
561
+ this.browserAutoConnectRecoveryTimers.delete(deviceKey);
562
+ if (!this.browserAutoConnectPendingDevices.has(deviceKey)) {
563
+ return;
564
+ }
565
+ this.browserAutoConnectObservedSignatures.delete(deviceKey);
566
+ const localNodeId = typeof this.browserPresenceLoopState?.localNodeId === 'string'
567
+ ? this.browserPresenceLoopState.localNodeId.trim() || null
568
+ : null;
569
+ this.scheduleBrowserAutoConnectScan([...this.browserAutoConnectPendingDevices.values()], localNodeId, this.resolveWebLogicalDeviceId());
570
+ }, Math.max(50, Math.trunc(delayMs)));
571
+ this.browserAutoConnectRecoveryTimers.set(deviceKey, retryTimer);
572
+ }
573
+ async shouldAttemptBrowserAutoConnectDial(device, localNodeId, localDeviceId) {
574
+ if (!device.online || !device.ticket || !device.deviceId) {
575
+ return false;
576
+ }
577
+ if (device.deviceId === localDeviceId) {
578
+ return false;
579
+ }
580
+ const deviceKey = device.deviceId;
581
+ const remoteNodeId = typeof device.nodeId === 'string' ? device.nodeId.trim() : '';
582
+ const rustOwnedGate = this.getRustOwnedAutoConnectGate(device);
583
+ if (rustOwnedGate !== 'none') {
584
+ if (rustOwnedGate === 'healthy' && remoteNodeId.length > 0) {
585
+ this.clearBrowserAutoConnectDeviceState(deviceKey);
586
+ }
587
+ return false;
588
+ }
589
+ const sessionLookupId = device.deviceId || remoteNodeId;
590
+ const session = sessionLookupId
591
+ ? await this.getPeerSession(sessionLookupId).catch(() => null)
592
+ : null;
593
+ if (session?.settledReady) {
594
+ this.clearBrowserAutoConnectDeviceState(deviceKey);
595
+ return false;
596
+ }
597
+ const lastTransitionAtMs = typeof session?.lastLifecycleTransitionAtMs === 'number'
598
+ ? session.lastLifecycleTransitionAtMs
599
+ : 0;
600
+ const withinSettleGuard = this.browserAutoConnectLifecycleFresh(lastTransitionAtMs);
601
+ const sessionHasActiveTransport = session?.activeTransportStableId != null;
602
+ const transportConnected = remoteNodeId.length > 0 &&
603
+ await this.isConnected(remoteNodeId).catch(() => false);
604
+ if ((session?.replacementPending && withinSettleGuard) ||
605
+ (session?.status === 'connecting' &&
606
+ (withinSettleGuard || sessionHasActiveTransport)) ||
607
+ (session?.status === 'connected' &&
608
+ withinSettleGuard) ||
609
+ (transportConnected && withinSettleGuard)) {
610
+ if (session?.settledReady) {
611
+ this.clearBrowserAutoConnectDeviceState(deviceKey);
612
+ }
613
+ return false;
614
+ }
615
+ if (!localNodeId || !remoteNodeId) {
616
+ return true;
617
+ }
618
+ if (localNodeId > remoteNodeId) {
619
+ this.browserAutoConnectWaitStartedAt.delete(deviceKey);
620
+ return true;
621
+ }
622
+ const now = Date.now();
623
+ const escalationCount = this.browserAutoConnectEscalationCount.get(deviceKey) ?? 0;
624
+ const waitStartedAt = this.browserAutoConnectWaitStartedAt.get(deviceKey) ?? now;
625
+ if (!this.browserAutoConnectWaitStartedAt.has(deviceKey)) {
626
+ this.browserAutoConnectWaitStartedAt.set(deviceKey, waitStartedAt);
627
+ }
628
+ const waitedMs = now - waitStartedAt;
629
+ const graceMs = this.browserAutoConnectNonInitiatorGraceMs(escalationCount);
630
+ if (waitedMs < graceMs) {
631
+ this.queueBrowserAutoConnectGraceExpiryRecheck(deviceKey, graceMs - waitedMs + 25);
632
+ return false;
633
+ }
634
+ this.browserAutoConnectWaitStartedAt.delete(deviceKey);
635
+ this.browserAutoConnectEscalationCount.set(deviceKey, escalationCount + 1);
636
+ return true;
637
+ }
638
+ async hasHealthyBrowserAutoConnectSession(device) {
639
+ const remoteNodeId = typeof device.nodeId === 'string' ? device.nodeId.trim() : '';
640
+ const rustOwnedGate = this.getRustOwnedAutoConnectGate(device);
641
+ if (rustOwnedGate !== 'none') {
642
+ return true;
643
+ }
644
+ const lookupId = device.deviceId || remoteNodeId;
645
+ if (!lookupId) {
646
+ return false;
647
+ }
648
+ const session = await this.getPeerSession(lookupId).catch(() => null);
649
+ if (session?.settledReady) {
650
+ return true;
651
+ }
652
+ const withinSettleGuard = this.browserAutoConnectLifecycleFresh(session?.lastLifecycleTransitionAtMs);
653
+ if (withinSettleGuard &&
654
+ (((session?.status === 'connecting' || session?.status === 'connected') &&
655
+ session.activeTransportStableId != null) ||
656
+ (remoteNodeId.length > 0 &&
657
+ await this.isConnected(remoteNodeId).catch(() => false)))) {
658
+ return true;
659
+ }
660
+ return false;
661
+ }
662
+ browserAutoConnectSignature(device) {
663
+ const updatedAt = typeof device.updatedAt === 'number'
664
+ ? device.updatedAt
665
+ : typeof device.updatedAt?.toMillis === 'function'
666
+ ? device.updatedAt.toMillis()
667
+ : typeof device.lastSeenAt === 'number'
668
+ ? device.lastSeenAt
669
+ : typeof device.lastSeenAt?.toMillis === 'function'
670
+ ? device.lastSeenAt.toMillis()
671
+ : 0;
672
+ return JSON.stringify({
673
+ deviceId: device.deviceId,
674
+ nodeId: device.nodeId ?? null,
675
+ online: !!device.online,
676
+ ticket: device.ticket ?? null,
677
+ updatedAt,
678
+ });
679
+ }
680
+ async refreshBrowserAutoConnectDeviceForDial(device) {
681
+ const deviceId = typeof device.deviceId === 'string' ? device.deviceId.trim() : '';
682
+ if (!deviceId) {
683
+ return device;
684
+ }
685
+ const refreshed = await this.getBrowserFirestoreSignaling()
686
+ .refreshDeviceForAutoConnect(deviceId)
687
+ .catch((error) => {
688
+ console.warn('[WasmSignaling][auto-connect][dial-refresh] failed to refresh device ticket before dial', {
689
+ deviceId,
690
+ error,
691
+ cachedTicket: summarizeCompoundTicketForLogs(device.ticket),
692
+ });
693
+ return null;
694
+ });
695
+ const effective = refreshed ?? device;
696
+ const ticketChanged = (effective.ticket ?? null) !== (device.ticket ?? null);
697
+ const nodeChanged = (effective.nodeId ?? null) !== (device.nodeId ?? null);
698
+ console.info('[WasmSignaling][auto-connect][dial-ticket]', {
699
+ deviceId,
700
+ ticketChanged,
701
+ nodeChanged,
702
+ cachedTicket: summarizeCompoundTicketForLogs(device.ticket),
703
+ effectiveTicket: summarizeCompoundTicketForLogs(effective.ticket),
704
+ cachedNodeId: device.nodeId ?? null,
705
+ effectiveNodeId: effective.nodeId ?? null,
706
+ });
707
+ return effective;
708
+ }
709
+ scheduleBrowserAutoConnectScan(devices, localNodeId, localDeviceId) {
710
+ this.browserAutoConnectPendingDevices = new Map(devices
711
+ .filter((device) => !!device.deviceId)
712
+ .map((device) => [device.deviceId, device]));
713
+ if (this.browserAutoConnectScanTimer) {
714
+ return;
715
+ }
716
+ this.browserAutoConnectScanTimer = setTimeout(() => {
717
+ this.browserAutoConnectScanTimer = null;
718
+ void this.processBrowserAutoConnectSnapshot(localNodeId, localDeviceId);
719
+ }, WasmBridge.BROWSER_AUTO_CONNECT_SCAN_DEBOUNCE_MS);
720
+ }
721
+ async processBrowserAutoConnectSnapshot(localNodeId, localDeviceId) {
722
+ const devicesById = new Map(this.browserAutoConnectPendingDevices);
723
+ const activeDeviceIds = new Set(devicesById.keys());
724
+ for (const deviceId of Array.from(this.browserAutoConnectObservedSignatures.keys())) {
725
+ if (activeDeviceIds.has(deviceId)) {
726
+ continue;
727
+ }
728
+ this.browserAutoConnectObservedSignatures.delete(deviceId);
729
+ this.clearBrowserAutoConnectDeviceState(deviceId);
730
+ }
731
+ for (const device of devicesById.values()) {
732
+ if (!device.deviceId) {
733
+ continue;
734
+ }
735
+ const deviceKey = device.deviceId;
736
+ const nextSignature = this.browserAutoConnectSignature(device);
737
+ const previousSignature = this.browserAutoConnectObservedSignatures.get(deviceKey) ?? null;
738
+ const lastAttemptAt = this.browserAutoConnectLastAttemptAt.get(deviceKey) ?? 0;
739
+ const shouldReevaluateUnchanged = lastAttemptAt > 0 &&
740
+ Date.now() - lastAttemptAt >= WasmBridge.BROWSER_AUTO_CONNECT_RETRY_COOLDOWN_MS;
741
+ if (previousSignature === nextSignature && !shouldReevaluateUnchanged) {
742
+ continue;
743
+ }
744
+ this.browserAutoConnectObservedSignatures.set(deviceKey, nextSignature);
745
+ if (this.browserAutoConnectInFlight.has(deviceKey)) {
746
+ continue;
747
+ }
748
+ this.browserAutoConnectInFlight.add(deviceKey);
749
+ void (async () => {
750
+ try {
751
+ const shouldAttempt = await this.shouldAttemptBrowserAutoConnectDial(device, localNodeId, localDeviceId);
752
+ if (!shouldAttempt) {
753
+ return;
754
+ }
755
+ const now = Date.now();
756
+ const lastAttemptAtInner = this.browserAutoConnectLastAttemptAt.get(deviceKey) ?? 0;
757
+ if (now - lastAttemptAtInner <
758
+ WasmBridge.BROWSER_AUTO_CONNECT_RETRY_COOLDOWN_MS) {
759
+ return;
760
+ }
761
+ if (await this.hasHealthyBrowserAutoConnectSession(device)) {
762
+ this.clearBrowserAutoConnectDeviceState(deviceKey);
763
+ return;
764
+ }
765
+ const effectiveDevice = await this.refreshBrowserAutoConnectDeviceForDial(device);
766
+ if (!effectiveDevice?.ticket) {
767
+ console.warn('[WasmSignaling][auto-connect][dial] skipped because the refreshed device has no ticket', {
768
+ deviceId: device.deviceId,
769
+ cachedTicket: summarizeCompoundTicketForLogs(device.ticket),
770
+ });
771
+ return;
772
+ }
773
+ const refreshedSignature = this.browserAutoConnectSignature(effectiveDevice);
774
+ this.browserAutoConnectPendingDevices.set(deviceKey, effectiveDevice);
775
+ this.browserAutoConnectObservedSignatures.set(deviceKey, refreshedSignature);
776
+ if (await this.hasHealthyBrowserAutoConnectSession(effectiveDevice)) {
777
+ this.clearBrowserAutoConnectDeviceState(deviceKey);
778
+ return;
779
+ }
780
+ this.browserAutoConnectLastAttemptAt.set(deviceKey, now);
781
+ await this.connectToDevice({
782
+ deviceId: effectiveDevice.deviceId,
783
+ endpointTicket: effectiveDevice.ticket,
784
+ });
785
+ }
786
+ catch (error) {
787
+ if (await this.hasHealthyBrowserAutoConnectSession(device).catch(() => false)) {
788
+ this.clearBrowserAutoConnectDeviceState(deviceKey);
789
+ return;
790
+ }
791
+ console.warn('[WasmSignaling] browser auto-connect attempt failed:', {
792
+ deviceId: device.deviceId,
793
+ ticket: summarizeCompoundTicketForLogs(device.ticket),
794
+ error,
795
+ });
796
+ }
797
+ finally {
798
+ this.browserAutoConnectInFlight.delete(deviceKey);
799
+ }
800
+ })();
801
+ }
802
+ }
803
+ stopBrowserPresenceLoop() {
804
+ if (this.browserPresenceLoopInterval) {
805
+ clearInterval(this.browserPresenceLoopInterval);
806
+ this.browserPresenceLoopInterval = null;
807
+ }
808
+ this.browserPresenceLoopState = null;
809
+ }
810
+ disableRtdbPresenceForSession(reason, error) {
811
+ if (!this.rtdbPresenceDisabledForSession) {
812
+ console.warn(`[WasmBridge] Disabling RTDB presence for this browser session (${reason})`, error);
813
+ }
814
+ this.rtdbPresenceDisabledForSession = true;
815
+ this.rtdbPresenceScope = null;
816
+ this.browserFirestoreSignaling?.stopRtdbPresence();
817
+ }
818
+ startLegacyBrowserPresenceLoop() {
819
+ const tick = () => {
820
+ const state = this.browserPresenceLoopState;
821
+ if (!state) {
822
+ return;
823
+ }
824
+ const devId = this.resolveWebLogicalDeviceId();
825
+ const mergedMetadata = this.withWebPresenceMetadata(state.metadata, devId);
826
+ void this.updatePresence(state.localNodeId, state.ticket, true, 300000, mergedMetadata, state.deviceName).catch((error) => {
827
+ console.warn('[WasmBridge] browser presence tick failed:', error);
828
+ });
829
+ };
830
+ tick();
831
+ this.browserPresenceLoopInterval = setInterval(tick, 60000);
832
+ }
833
+ /**
834
+ * Phase 1: Register RTDB presence for the current device.
835
+ *
836
+ * Initialises an RtdbPresence instance (lazy, one per WasmBridge), wires
837
+ * it into the BrowserFirestoreSignaling device watcher so RTDB state is
838
+ * used for the online/offline overlay, and registers this device as online
839
+ * with an onDisconnect cleanup hook.
840
+ *
841
+ * Called by startPresenceLoop (and directly from updatePresence when the
842
+ * ticket changes after initial registration).
843
+ */
844
+ async registerRtdbPresence(scope, deviceId, ticketVersion) {
845
+ if (this.isTicketOnlyMode()) {
846
+ return;
847
+ }
848
+ if (!this._rtdbPresence) {
849
+ this._rtdbPresence = new RtdbPresence(this.app);
850
+ }
851
+ this.rtdbPresenceScope = scope;
852
+ const version = ticketVersion ?? this.rtdbTicketVersion;
853
+ // Wire scope into the signaling layer so device watcher uses RTDB.
854
+ const signaling = this.getBrowserFirestoreSignaling();
855
+ signaling.setRtdbPresence(this._rtdbPresence, scope);
856
+ try {
857
+ await this._rtdbPresence.registerDevice(scope, deviceId, version);
858
+ }
859
+ catch (error) {
860
+ this.disableRtdbPresenceForSession('registerDevice failed', error);
861
+ throw error;
862
+ }
863
+ }
864
+ async stopAuthScopedActivity(options) {
865
+ void options;
866
+ const wasmClient = await this.waitForWasmClient(500);
867
+ if (wasmClient && typeof wasmClient.revoke_tokens_by_scope === 'function') {
868
+ try {
869
+ await wasmClient.revoke_tokens_by_scope('user-device');
870
+ }
871
+ catch (error) {
872
+ console.warn('[WasmBridge] user-device revoke during auth-scoped shutdown failed:', error);
873
+ }
874
+ }
875
+ this.stopBrowserAutoConnect();
876
+ this.stopBrowserPresenceLoop();
877
+ // Phase 1: clear RTDB scope so next login re-registers cleanly.
878
+ this.rtdbPresenceDisabledForSession = false;
879
+ this.rtdbPresenceScope = null;
880
+ this.rtdbTicketVersion = 0;
881
+ this.browserFirestoreSignaling?.stopAuthScopedActivity();
882
+ await this.stopSessionSubscriptions();
883
+ this.lastDeliveredConnectionStates.clear();
884
+ this.startedAutoConnectKey = null;
885
+ this.autoConnectStartsInFlight.clear();
886
+ await this.stopRuntimeAuthScopedLoops();
887
+ }
888
+ setWasmClient(client) {
889
+ this.wasmClient = client;
890
+ this.hasSyncedAuthToWasmClient = false;
891
+ this.lastDeliveredConnectionStates.clear();
892
+ this.syncWasmAuthToken();
893
+ }
894
+ warnMissingWasmOnce(method) {
895
+ if (!this.wasmClient && Date.now() - this.createdAtMs < this.wasmReadyWarningDelayMs) {
896
+ return;
897
+ }
898
+ if (this.missingWasmWarnings.has(method)) {
899
+ return;
900
+ }
901
+ this.missingWasmWarnings.add(method);
902
+ console.warn(`[WasmSignaling] ${method} skipped: wasm client is not ready.`);
903
+ }
904
+ async waitForWasmClient(timeoutMs = 4000, pollMs = 50) {
905
+ if (this.wasmClient) {
906
+ return this.wasmClient;
907
+ }
908
+ const start = Date.now();
909
+ while (!this.wasmClient && Date.now() - start < timeoutMs) {
910
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
911
+ }
912
+ return this.wasmClient ?? null;
913
+ }
914
+ normalizeDevice(raw, fallbackId) {
915
+ return normalizeDeviceRecord(raw, fallbackId);
916
+ }
917
+ normalizeDeviceRecord(raw, fallbackId) {
918
+ return this.normalizeDevice(raw, fallbackId);
919
+ }
920
+ getResolvedWebLogicalDeviceId() {
921
+ return this.resolveWebLogicalDeviceId();
922
+ }
923
+ normalizeDeviceStatusSnapshot(raw) {
924
+ return normalizeDeviceStatusSnapshotRecord(raw);
925
+ }
926
+ normalizePeerSessionSnapshot(raw) {
927
+ return normalizePeerSessionSnapshotRecord(raw);
928
+ }
929
+ toBackendConnectionStateFromPeerSession(snapshot) {
930
+ return peerSessionToBackendConnectionState(snapshot);
931
+ }
932
+ normalizeConnectionStateSnapshot(raw) {
933
+ return normalizeConnectionStateSnapshotRecord(raw);
934
+ }
935
+ normalizeWindowConnectionStateDetail(detail) {
936
+ return this.normalizeConnectionStateSnapshot(detail);
937
+ }
938
+ stabilizeConnectionState(state) {
939
+ const previous = this.lastDeliveredConnectionStates.get(state.connectionId);
940
+ return {
941
+ ...state,
942
+ deviceId: state.deviceId ?? previous?.deviceId ?? null,
943
+ deviceIdHint: state.deviceIdHint ?? previous?.deviceIdHint ?? null,
944
+ remoteNodeId: state.remoteNodeId ?? previous?.remoteNodeId ?? null,
945
+ createdAt: state.createdAt ?? previous?.createdAt,
946
+ updatedAt: state.updatedAt ?? previous?.updatedAt,
947
+ };
948
+ }
949
+ sameConnectionState(left, right) {
950
+ return sameNormalizedConnectionState(left, right);
951
+ }
952
+ deliverConnectionState(state, callback, source, subscriberDedup) {
953
+ const stabilized = this.stabilizeConnectionState(state);
954
+ // Always update shared cache for stabilization of future events
955
+ this.lastDeliveredConnectionStates.set(stabilized.connectionId, stabilized);
956
+ // Per-subscriber dedup: use subscriber-local map when provided
957
+ const dedupMap = subscriberDedup ?? this.lastDeliveredConnectionStates;
958
+ const previous = dedupMap.get(stabilized.connectionId);
959
+ if (this.sameConnectionState(previous, stabilized)) {
960
+ return;
961
+ }
962
+ if (subscriberDedup) {
963
+ subscriberDedup.set(stabilized.connectionId, stabilized);
964
+ }
965
+ if (source === 'event') {
966
+ openRtcDebug('[WasmBridge] connection-state-changed event', {
967
+ connectionId: stabilized.connectionId,
968
+ deviceId: stabilized.deviceId ?? null,
969
+ deviceIdHint: stabilized.deviceIdHint ?? null,
970
+ remoteNodeId: stabilized.remoteNodeId ?? null,
971
+ state: stabilized.state,
972
+ transportState: stabilized.transportState ?? null,
973
+ protocolState: stabilized.protocolState ?? null,
974
+ routable: stabilized.routable ?? null,
975
+ error: stabilized.error ?? null,
976
+ });
977
+ }
978
+ if (source === 'event' &&
979
+ (stabilized.replacementInProgress ||
980
+ (stabilized.state === 'connecting' &&
981
+ !stabilized.routable &&
982
+ stabilized.activeTransportStableId == null))) {
983
+ this.triggerBrowserAutoConnectRecovery(stabilized);
984
+ }
985
+ callback(stabilized);
986
+ }
987
+ async enrichBackendConnectionState(state) {
988
+ const session = await this.getPeerSession(state.connectionId).catch(() => null);
989
+ const fromSession = session ? this.toBackendConnectionStateFromPeerSession(session) : null;
990
+ if (fromSession) {
991
+ const updatedAt = Math.max(state.updatedAt ?? 0, fromSession.updatedAt ?? 0) || undefined;
992
+ const preferRawRoutable = !!state.routable && !fromSession.routable;
993
+ return {
994
+ ...fromSession,
995
+ ...state,
996
+ deviceId: fromSession.deviceId ?? state.deviceId ?? null,
997
+ deviceIdHint: fromSession.deviceIdHint ?? state.deviceIdHint ?? null,
998
+ remoteNodeId: fromSession.remoteNodeId ?? state.remoteNodeId ?? null,
999
+ state: preferRawRoutable ? state.state : fromSession.state,
1000
+ transportState: preferRawRoutable
1001
+ ? (state.transportState ?? fromSession.transportState)
1002
+ : fromSession.transportState,
1003
+ protocolState: preferRawRoutable
1004
+ ? (state.protocolState ?? fromSession.protocolState)
1005
+ : fromSession.protocolState,
1006
+ routable: preferRawRoutable ? state.routable : fromSession.routable,
1007
+ transportGeneration: state.transportGeneration ?? fromSession.transportGeneration,
1008
+ replacementInProgress: state.replacementInProgress ?? fromSession.replacementInProgress,
1009
+ transitionCount: state.transitionCount ?? fromSession.transitionCount,
1010
+ connectingTransitionCount: state.connectingTransitionCount ?? fromSession.connectingTransitionCount,
1011
+ replacementCount: state.replacementCount ?? fromSession.replacementCount,
1012
+ retireCount: state.retireCount ?? fromSession.retireCount,
1013
+ lastDisconnectReason: state.lastDisconnectReason ?? fromSession.lastDisconnectReason,
1014
+ lastReconnectReason: state.lastReconnectReason ?? fromSession.lastReconnectReason,
1015
+ createdAt: state.createdAt ?? fromSession.updatedAt,
1016
+ updatedAt,
1017
+ };
1018
+ }
1019
+ const identity = await this.resolvePeerIdentity(state.connectionId).catch(() => null);
1020
+ return {
1021
+ ...state,
1022
+ deviceId: state.deviceId ?? identity?.deviceId ?? null,
1023
+ deviceIdHint: state.deviceIdHint ?? identity?.deviceIdHint ?? null,
1024
+ remoteNodeId: state.remoteNodeId ?? identity?.nodeId ?? null,
1025
+ };
1026
+ }
1027
+ get currentUser() {
1028
+ if (this.authContext?.userId) {
1029
+ const token = this.authContext.token ?? null;
1030
+ const tokenProvider = this.authContext.tokenProvider;
1031
+ return {
1032
+ id: this.authContext.userId,
1033
+ getToken: async (forceRefresh = false) => {
1034
+ if (token && (!forceRefresh || !tokenProvider)) {
1035
+ return token;
1036
+ }
1037
+ if (tokenProvider) {
1038
+ try {
1039
+ const refreshed = await tokenProvider(forceRefresh);
1040
+ if (refreshed) {
1041
+ return refreshed;
1042
+ }
1043
+ }
1044
+ catch {
1045
+ // fall through to Firebase auth state
1046
+ }
1047
+ }
1048
+ // When authContext is user-scoped without an explicit token,
1049
+ // fall back to Firebase auth state if it matches this user.
1050
+ const firebaseUser = this.isTicketOnlyMode() ? null : (this.auth?.currentUser ?? null);
1051
+ if (firebaseUser &&
1052
+ firebaseUser.uid === this.authContext?.userId) {
1053
+ try {
1054
+ return await firebaseUser.getIdToken(forceRefresh);
1055
+ }
1056
+ catch {
1057
+ return null;
1058
+ }
1059
+ }
1060
+ return null;
1061
+ },
1062
+ };
1063
+ }
1064
+ if (this.isTicketOnlyMode()) {
1065
+ return null;
1066
+ }
1067
+ return this.toRuntimeIdentity(this.auth?.currentUser ?? null);
1068
+ }
1069
+ onAuthChange(callback) {
1070
+ this.authListeners.push(callback);
1071
+ callback(this.currentUser);
1072
+ return () => {
1073
+ this.authListeners = this.authListeners.filter(cb => cb !== callback);
1074
+ };
1075
+ }
1076
+ async checkForSSOToken() {
1077
+ if (this.isTicketOnlyMode()) {
1078
+ return;
1079
+ }
1080
+ await getAuthHost().checkForSSOToken();
1081
+ }
1082
+ async waitForAuth() {
1083
+ if (this.isTicketOnlyMode()) {
1084
+ return;
1085
+ }
1086
+ if (this.isPersonalSpaceMode()) {
1087
+ return;
1088
+ }
1089
+ if (this.authContext?.userId || this.auth?.currentUser) {
1090
+ return;
1091
+ }
1092
+ await getAuthHost().waitForAuth();
1093
+ }
1094
+ async signInAnonymously() {
1095
+ if (this.isTicketOnlyMode()) {
1096
+ return;
1097
+ }
1098
+ if (this.options.authMode !== 'anonymous') {
1099
+ throw new Error(`[pluto-rtc] Anonymous sign-in is disabled when authMode=${this.options.authMode}.`);
1100
+ }
1101
+ await getAuthHost().signInAnonymously();
1102
+ }
1103
+ async signInWithPluto() {
1104
+ if (this.isTicketOnlyMode()) {
1105
+ throw new Error('[openrtc] Pluto sign-in is unavailable when signalingMode="ticket-only".');
1106
+ }
1107
+ await getAuthHost().signInWithPluto();
1108
+ }
1109
+ async signOut() {
1110
+ const userId = this.getCurrentUserId();
1111
+ await this.stopAuthScopedActivity({ userId });
1112
+ this.authContext = null;
1113
+ this.hasSyncedAuthToWasmClient = false;
1114
+ this.syncWasmAuthToken();
1115
+ if (!this.isTicketOnlyMode()) {
1116
+ await this.auth?.signOut();
1117
+ }
1118
+ this.authListeners.forEach((callback) => callback(this.currentUser));
1119
+ }
1120
+ async getTurnCredentials() {
1121
+ if (this.isTicketOnlyMode())
1122
+ return null;
1123
+ if (!this.auth?.currentUser)
1124
+ return null;
1125
+ // In the future this should come from Rust/Tauri IPC.
1126
+ return null;
1127
+ }
1128
+ async updatePresence(localNodeId, ticketStr, isOnline = true, ttlMs = 300000, metadata, deviceNameOverride) {
1129
+ if (this.isTicketOnlyMode()) {
1130
+ return;
1131
+ }
1132
+ const localDeviceId = this.resolveWebLogicalDeviceId();
1133
+ const effectiveTtlMs = this.getNodeIdPersistenceMode() === 'ephemeral'
1134
+ ? WasmBridge.EPHEMERAL_DEVICE_ID_TTL_MS
1135
+ : ttlMs;
1136
+ const mergedMetadata = this.withWebPresenceMetadata(metadata, localDeviceId);
1137
+ this.browserPresenceLoopState = {
1138
+ localNodeId,
1139
+ ticket: ticketStr,
1140
+ metadata: mergedMetadata,
1141
+ deviceName: deviceNameOverride?.trim() || this.getDefaultBrowserDeviceName(),
1142
+ };
1143
+ if (this.usesLegacyBrowserSignalingFallback()) {
1144
+ const userId = this.getDiscoveryScopeId();
1145
+ const wasmClient = await this.waitForWasmClient(500);
1146
+ if (!userId || !wasmClient || typeof wasmClient.update_presence !== 'function') {
1147
+ return;
1148
+ }
1149
+ await wasmClient.update_presence(userId, deviceNameOverride?.trim() || this.getDefaultBrowserDeviceName(), ticketStr, mergedMetadata, BigInt(Math.max(0, Math.trunc(effectiveTtlMs))));
1150
+ return;
1151
+ }
1152
+ await this.getBrowserFirestoreSignaling().updatePresence(localNodeId, ticketStr, isOnline, effectiveTtlMs, mergedMetadata, deviceNameOverride);
1153
+ // Phase 1: bump RTDB ticketVersion so other clients invalidate their cache.
1154
+ if (this._rtdbPresence && this.rtdbPresenceScope) {
1155
+ const deviceId = this.resolveWebLogicalDeviceId();
1156
+ if (deviceId) {
1157
+ this.rtdbTicketVersion += 1;
1158
+ void this._rtdbPresence.bumpTicketVersion(this.rtdbPresenceScope, deviceId, this.rtdbTicketVersion).catch((err) => console.warn('[WasmBridge] RTDB ticketVersion bump failed:', err));
1159
+ }
1160
+ }
1161
+ }
1162
+ async setOffline(localNodeId) {
1163
+ this.stopBrowserPresenceLoop();
1164
+ if (this.isTicketOnlyMode()) {
1165
+ return;
1166
+ }
1167
+ // Phase 1: mark offline in RTDB immediately (graceful disconnect).
1168
+ if (this._rtdbPresence && this.rtdbPresenceScope) {
1169
+ const deviceId = this.resolveWebLogicalDeviceId();
1170
+ if (deviceId) {
1171
+ void this._rtdbPresence.markOffline(this.rtdbPresenceScope, deviceId, this.rtdbTicketVersion).catch((err) => console.warn('[WasmBridge] RTDB markOffline failed:', err));
1172
+ }
1173
+ }
1174
+ if (this.usesLegacyBrowserSignalingFallback()) {
1175
+ const userId = this.getDiscoveryScopeId();
1176
+ const wasmClient = await this.waitForWasmClient(500);
1177
+ if (!userId || !wasmClient || typeof wasmClient.set_offline !== 'function') {
1178
+ return;
1179
+ }
1180
+ await wasmClient.set_offline(userId);
1181
+ return;
1182
+ }
1183
+ await this.getBrowserFirestoreSignaling().setOffline(localNodeId);
1184
+ }
1185
+ async updateDevice(deviceId, updates) {
1186
+ if (!deviceId?.trim()) {
1187
+ throw new Error('updateDevice requires a deviceId');
1188
+ }
1189
+ if (this.isTicketOnlyMode()) {
1190
+ return;
1191
+ }
1192
+ if (this.usesLegacyBrowserSignalingFallback()) {
1193
+ const userId = this.getDiscoveryScopeId();
1194
+ const wasmClient = await this.waitForWasmClient(500);
1195
+ if (!userId || !wasmClient || typeof wasmClient.update_device !== 'function') {
1196
+ return;
1197
+ }
1198
+ await wasmClient.update_device(userId, deviceId, updates.deviceName ?? null, updates.capabilities ?? null, updates.metadata ?? null);
1199
+ return;
1200
+ }
1201
+ await this.getBrowserFirestoreSignaling().updateDevice(deviceId, updates);
1202
+ }
1203
+ async deleteDevice(deviceId) {
1204
+ if (!deviceId?.trim()) {
1205
+ throw new Error('deleteDevice requires a deviceId');
1206
+ }
1207
+ if (this.isTicketOnlyMode()) {
1208
+ return;
1209
+ }
1210
+ if (this.usesLegacyBrowserSignalingFallback()) {
1211
+ const userId = this.getDiscoveryScopeId();
1212
+ const wasmClient = await this.waitForWasmClient(500);
1213
+ if (!userId || !wasmClient || typeof wasmClient.delete_device !== 'function') {
1214
+ return;
1215
+ }
1216
+ await wasmClient.delete_device(userId, deviceId);
1217
+ return;
1218
+ }
1219
+ await this.getBrowserFirestoreSignaling().deleteDevice(deviceId);
1220
+ }
1221
+ async cleanupStaleDevices() {
1222
+ if (this.isTicketOnlyMode()) {
1223
+ return;
1224
+ }
1225
+ if (this.usesLegacyBrowserSignalingFallback()) {
1226
+ return;
1227
+ }
1228
+ await this.getBrowserFirestoreSignaling().cleanupStaleDevices();
1229
+ }
1230
+ async sendMessage(targetId, payload, state, replyPayload) {
1231
+ if (this.isTicketOnlyMode()) {
1232
+ throw new Error('[openrtc] sendMessage() is unavailable when signalingMode="ticket-only".');
1233
+ }
1234
+ return this.getBrowserFirestoreSignaling().sendMessage(targetId, payload, state, replyPayload);
1235
+ }
1236
+ async pollMessages(targetId) {
1237
+ if (this.isTicketOnlyMode()) {
1238
+ return [];
1239
+ }
1240
+ return this.getBrowserFirestoreSignaling().pollMessages(targetId);
1241
+ }
1242
+ async searchDevices(excludeNodeId) {
1243
+ if (this.isTicketOnlyMode()) {
1244
+ return [];
1245
+ }
1246
+ if (this.usesLegacyBrowserSignalingFallback()) {
1247
+ return this.searchDevicesViaWasm(excludeNodeId);
1248
+ }
1249
+ return this.getBrowserFirestoreSignaling().searchDevices(excludeNodeId);
1250
+ }
1251
+ async listDevicesWithStatus() {
1252
+ if (this.isTicketOnlyMode()) {
1253
+ return [];
1254
+ }
1255
+ const userId = this.getDiscoveryScopeId();
1256
+ if (!userId)
1257
+ return [];
1258
+ const wasmClient = await this.waitForWasmClient(500);
1259
+ if (wasmClient && typeof wasmClient.devices_with_status === 'function') {
1260
+ const devices = await wasmClient.devices_with_status(userId);
1261
+ const rawDevices = Array.isArray(devices) ? devices : [];
1262
+ const normalized = rawDevices
1263
+ .map((device) => this.normalizeDeviceStatusSnapshot(device))
1264
+ .filter((device) => this.matchesTag(device));
1265
+ return normalized;
1266
+ }
1267
+ return [];
1268
+ }
1269
+ async searchDevicesViaWasm(excludeNodeId) {
1270
+ const userId = this.getDiscoveryScopeId();
1271
+ if (!userId) {
1272
+ return [];
1273
+ }
1274
+ const wasmClient = await this.waitForWasmClient(500);
1275
+ if (!wasmClient || typeof wasmClient.search_devices !== 'function') {
1276
+ return [];
1277
+ }
1278
+ const devices = await wasmClient.search_devices(userId);
1279
+ let normalized = (Array.isArray(devices) ? devices : [])
1280
+ .map((device) => this.normalizeDevice(device))
1281
+ .filter((device) => this.matchesTag(device));
1282
+ if (excludeNodeId) {
1283
+ normalized = normalized.filter((device) => device.ticket !== excludeNodeId &&
1284
+ device.deviceId !== excludeNodeId &&
1285
+ device.nodeId !== excludeNodeId);
1286
+ }
1287
+ return normalized;
1288
+ }
1289
+ onDevicesChangeViaWasmPolling(callback, excludeNodeId) {
1290
+ let cancelled = false;
1291
+ let lastSnapshotKey = '';
1292
+ const emit = async () => {
1293
+ if (cancelled) {
1294
+ return;
1295
+ }
1296
+ try {
1297
+ const devices = await this.searchDevicesViaWasm(excludeNodeId);
1298
+ const snapshotKey = JSON.stringify(devices
1299
+ .map((device) => ({
1300
+ deviceId: device.deviceId,
1301
+ nodeId: device.nodeId ?? null,
1302
+ ticket: device.ticket ?? null,
1303
+ online: device.online,
1304
+ updatedAt: typeof device.updatedAt === 'number'
1305
+ ? device.updatedAt
1306
+ : device.updatedAt?.toMillis?.() ?? null,
1307
+ }))
1308
+ .sort((left, right) => left.deviceId.localeCompare(right.deviceId)));
1309
+ if (snapshotKey === lastSnapshotKey) {
1310
+ return;
1311
+ }
1312
+ lastSnapshotKey = snapshotKey;
1313
+ callback(devices);
1314
+ }
1315
+ catch (error) {
1316
+ if (!cancelled) {
1317
+ console.warn('[WasmBridge] legacy device polling failed:', error);
1318
+ }
1319
+ }
1320
+ };
1321
+ void emit();
1322
+ const interval = setInterval(() => {
1323
+ void emit();
1324
+ }, 2000);
1325
+ return () => {
1326
+ cancelled = true;
1327
+ clearInterval(interval);
1328
+ };
1329
+ }
1330
+ async getPeerSession(id) {
1331
+ const wasmClient = await this.waitForWasmClient(500);
1332
+ if (wasmClient && typeof wasmClient.peer_session === 'function') {
1333
+ const snapshot = await wasmClient.peer_session(id);
1334
+ if (!snapshot || typeof snapshot !== 'object') {
1335
+ return null;
1336
+ }
1337
+ return this.normalizePeerSessionSnapshot(snapshot);
1338
+ }
1339
+ this.warnMissingWasmOnce('getPeerSession');
1340
+ return null;
1341
+ }
1342
+ async listPeerSessions() {
1343
+ const wasmClient = await this.waitForWasmClient(500);
1344
+ if (wasmClient && typeof wasmClient.peer_sessions === 'function') {
1345
+ const snapshots = await wasmClient.peer_sessions();
1346
+ return (Array.isArray(snapshots) ? snapshots : [])
1347
+ .map((snapshot) => this.normalizePeerSessionSnapshot(snapshot))
1348
+ .filter((snapshot) => !!snapshot.peerId);
1349
+ }
1350
+ this.warnMissingWasmOnce('listPeerSessions');
1351
+ return [];
1352
+ }
1353
+ async waitForSettledPeer(id, timeoutMs) {
1354
+ const wasmClient = await this.waitForWasmClient(500);
1355
+ if (wasmClient && typeof wasmClient.wait_for_settled_peer === 'function') {
1356
+ const snapshot = await wasmClient.wait_for_settled_peer(id, timeoutMs);
1357
+ if (!snapshot || typeof snapshot !== 'object') {
1358
+ return null;
1359
+ }
1360
+ return this.normalizePeerSessionSnapshot(snapshot);
1361
+ }
1362
+ this.warnMissingWasmOnce('waitForSettledPeer');
1363
+ return null;
1364
+ }
1365
+ async openPeerBi(id, timeoutMs) {
1366
+ const wasmClient = await this.waitForWasmClient(500);
1367
+ if (wasmClient && typeof wasmClient.open_peer_bi === 'function') {
1368
+ const biStream = await wasmClient.open_peer_bi(id, timeoutMs);
1369
+ if (!biStream?.recv || !biStream?.send) {
1370
+ throw new Error('WASM open_peer_bi returned an invalid bidirectional stream');
1371
+ }
1372
+ return {
1373
+ readable: biStream.recv,
1374
+ writable: biStream.send,
1375
+ };
1376
+ }
1377
+ this.warnMissingWasmOnce('openPeerBi');
1378
+ throw new Error('WASM runtime does not support open_peer_bi()');
1379
+ }
1380
+ async openPeerUni(id, timeoutMs) {
1381
+ const wasmClient = await this.waitForWasmClient(500);
1382
+ if (wasmClient && typeof wasmClient.open_peer_uni === 'function') {
1383
+ const writable = await wasmClient.open_peer_uni(id, timeoutMs);
1384
+ if (!writable) {
1385
+ throw new Error('WASM open_peer_uni returned an invalid writable stream');
1386
+ }
1387
+ return { writable };
1388
+ }
1389
+ this.warnMissingWasmOnce('openPeerUni');
1390
+ throw new Error('WASM runtime does not support open_peer_uni()');
1391
+ }
1392
+ async resolvePeerConnectionRecords(id) {
1393
+ const wasmClient = await this.waitForWasmClient(500);
1394
+ if (wasmClient && typeof wasmClient.resolve_peer_connection_records === 'function') {
1395
+ const records = await wasmClient.resolve_peer_connection_records(id);
1396
+ return (Array.isArray(records) ? records : []).map((record) => ({
1397
+ connectionId: typeof record.connectionId === 'string' ? record.connectionId : '',
1398
+ nodeId: typeof record.nodeId === 'string' ? record.nodeId : null,
1399
+ deviceId: typeof record.deviceId === 'string' ? record.deviceId : null,
1400
+ deviceIdHint: typeof record.deviceIdHint === 'string' ? record.deviceIdHint : null,
1401
+ endpointId: typeof record.endpointId === 'string' ? record.endpointId : null,
1402
+ transportGeneration: typeof record.transportGeneration === 'number' ? record.transportGeneration : 0,
1403
+ transportStableId: typeof record.transportStableId === 'number' ? record.transportStableId : null,
1404
+ transportSource: typeof record.transportSource === 'string' ? record.transportSource : null,
1405
+ lastTransportChangeAtMs: typeof record.lastTransportChangeAtMs === 'number'
1406
+ ? record.lastTransportChangeAtMs
1407
+ : 0,
1408
+ state: typeof record.state === 'string' ? record.state : 'pending',
1409
+ statusReason: typeof record.statusReason === 'string' ? record.statusReason : null,
1410
+ createdAtMs: typeof record.createdAtMs === 'number' ? record.createdAtMs : 0,
1411
+ updatedAtMs: typeof record.updatedAtMs === 'number' ? record.updatedAtMs : 0,
1412
+ }));
1413
+ }
1414
+ this.warnMissingWasmOnce('resolvePeerConnectionRecords');
1415
+ return [];
1416
+ }
1417
+ async resolvePeerIdentity(id) {
1418
+ const wasmClient = await this.waitForWasmClient(500);
1419
+ if (wasmClient) {
1420
+ const fromRecord = (record) => {
1421
+ if (!record || typeof record !== 'object') {
1422
+ return {
1423
+ peerId: null,
1424
+ deviceId: null,
1425
+ deviceIdHint: null,
1426
+ nodeId: null,
1427
+ };
1428
+ }
1429
+ const normalized = record;
1430
+ return {
1431
+ peerId: typeof normalized.peerId === 'string' ? normalized.peerId : null,
1432
+ deviceId: typeof normalized.deviceId === 'string' ? normalized.deviceId : null,
1433
+ deviceIdHint: typeof normalized.deviceIdHint === 'string' ? normalized.deviceIdHint : null,
1434
+ nodeId: typeof normalized.nodeId === 'string' ? normalized.nodeId : null,
1435
+ };
1436
+ };
1437
+ if (typeof wasmClient.peer_session === 'function') {
1438
+ try {
1439
+ const session = await wasmClient.peer_session(id);
1440
+ const identity = fromRecord(session);
1441
+ if (identity.peerId || identity.deviceId || identity.deviceIdHint || identity.nodeId) {
1442
+ return identity;
1443
+ }
1444
+ }
1445
+ catch {
1446
+ // Fall through to peer_snapshot for compatibility.
1447
+ }
1448
+ }
1449
+ if (typeof wasmClient.peer_snapshot === 'function') {
1450
+ try {
1451
+ return fromRecord(await wasmClient.peer_snapshot(id));
1452
+ }
1453
+ catch {
1454
+ // Fall through to the empty identity below.
1455
+ }
1456
+ }
1457
+ }
1458
+ this.warnMissingWasmOnce('resolvePeerIdentity');
1459
+ return {
1460
+ peerId: null,
1461
+ deviceId: null,
1462
+ deviceIdHint: null,
1463
+ nodeId: null,
1464
+ };
1465
+ }
1466
+ onDevicesChange(callback, excludeNodeId) {
1467
+ if (this.isTicketOnlyMode()) {
1468
+ callback([]);
1469
+ return () => undefined;
1470
+ }
1471
+ if (this.usesLegacyBrowserSignalingFallback()) {
1472
+ return this.onDevicesChangeViaWasmPolling(callback, excludeNodeId);
1473
+ }
1474
+ return this.getBrowserFirestoreSignaling().onDevicesChange(callback, excludeNodeId);
1475
+ }
1476
+ async subscribeDevices(userId) {
1477
+ void userId;
1478
+ if (this.isTicketOnlyMode()) {
1479
+ return new ReadableStream({
1480
+ start(controller) {
1481
+ controller.close();
1482
+ },
1483
+ });
1484
+ }
1485
+ return this.getBrowserFirestoreSignaling().subscribeDevices();
1486
+ }
1487
+ startAutoConnect(userId, localDeviceId) {
1488
+ if (this.isTicketOnlyMode()) {
1489
+ return;
1490
+ }
1491
+ void (async () => {
1492
+ const effectiveLocalDeviceId = this.resolveWebLogicalDeviceId();
1493
+ const localNodeId = typeof this.browserPresenceLoopState?.localNodeId === 'string'
1494
+ ? this.browserPresenceLoopState.localNodeId.trim() || null
1495
+ : null;
1496
+ const autoConnectKey = `${userId}::${effectiveLocalDeviceId}`;
1497
+ if (this.startedAutoConnectKey === autoConnectKey ||
1498
+ this.autoConnectStartsInFlight.has(autoConnectKey)) {
1499
+ return;
1500
+ }
1501
+ this.autoConnectStartsInFlight.add(autoConnectKey);
1502
+ try {
1503
+ void localDeviceId;
1504
+ this.stopBrowserAutoConnect();
1505
+ this.startedAutoConnectKey = autoConnectKey;
1506
+ this.browserAutoConnectUnsubscribe = this.getBrowserFirestoreSignaling().onDevicesChange((devices) => {
1507
+ this.scheduleBrowserAutoConnectScan(devices, localNodeId, effectiveLocalDeviceId);
1508
+ });
1509
+ }
1510
+ finally {
1511
+ this.autoConnectStartsInFlight.delete(autoConnectKey);
1512
+ }
1513
+ })();
1514
+ }
1515
+ notifyDisconnectRequested(remoteNodeId) {
1516
+ if (!this.startedAutoConnectKey || this.browserAutoConnectPendingDevices.size === 0) {
1517
+ return;
1518
+ }
1519
+ const normalizedRemoteNodeId = remoteNodeId.trim();
1520
+ if (!normalizedRemoteNodeId) {
1521
+ return;
1522
+ }
1523
+ const targetDevice = [...this.browserAutoConnectPendingDevices.values()].find((device) => {
1524
+ const nodeId = typeof device.nodeId === 'string' ? device.nodeId.trim() : '';
1525
+ return nodeId === normalizedRemoteNodeId;
1526
+ });
1527
+ if (!targetDevice?.deviceId) {
1528
+ return;
1529
+ }
1530
+ this.queueBrowserAutoConnectRecoveryForDevice(targetDevice, {
1531
+ resetWaitStartedAt: true,
1532
+ });
1533
+ }
1534
+ async setAutoConnectExcluded(deviceId, excluded) {
1535
+ const wasmClient = await this.waitForWasmClient();
1536
+ if (wasmClient && typeof wasmClient.set_auto_connect_excluded === 'function') {
1537
+ wasmClient.set_auto_connect_excluded(deviceId, excluded);
1538
+ }
1539
+ }
1540
+ async connectToDevice(params) {
1541
+ const wasmClient = await this.waitForWasmClient();
1542
+ if (!wasmClient || typeof wasmClient.connect_device !== 'function') {
1543
+ this.warnMissingWasmOnce('connectToDevice');
1544
+ throw new Error('WASM runtime does not support connect_device()');
1545
+ }
1546
+ return wasmClient.connect_device(params.deviceId ?? null, params.endpointTicket);
1547
+ }
1548
+ forceReconnectSnapshot() {
1549
+ void (async () => {
1550
+ const wasmClient = await this.waitForWasmClient();
1551
+ if (wasmClient && typeof wasmClient.force_reconnect_snapshot === 'function') {
1552
+ wasmClient.force_reconnect_snapshot();
1553
+ }
1554
+ else {
1555
+ this.warnMissingWasmOnce('forceReconnectSnapshot');
1556
+ }
1557
+ })();
1558
+ }
1559
+ startPresenceLoop(userId, localNodeId, deviceName, ticket, metadata) {
1560
+ if (this.isTicketOnlyMode()) {
1561
+ return;
1562
+ }
1563
+ this.stopBrowserPresenceLoop();
1564
+ this.browserPresenceLoopState = { localNodeId, ticket, metadata, deviceName };
1565
+ const localDeviceId = this.resolveWebLogicalDeviceId();
1566
+ // Phase 1: register RTDB presence instead of polling Firestore every 60s.
1567
+ // We still call updatePresence ONCE to write the Firestore ticket doc,
1568
+ // but we do NOT start the setInterval heartbeat when RTDB is available.
1569
+ const scope = this.buildRtdbScope(userId);
1570
+ if (!this.rtdbPresenceDisabledForSession && scope && localDeviceId) {
1571
+ const mergedMetadata = this.withWebPresenceMetadata(metadata, localDeviceId);
1572
+ // Write Firestore ticket once.
1573
+ void this.updatePresence(localNodeId, ticket, true,
1574
+ // Long TTL: the doc is kept alive by RTDB onDisconnect, not by polling.
1575
+ // 7 days ensures the ticket stays valid even across RTDB reconnects.
1576
+ 7 * 24 * 60 * 60 * 1000, mergedMetadata, deviceName).catch((err) => console.warn('[WasmBridge] initial presence write failed:', err));
1577
+ // Register RTDB onDisconnect + online state (replaces the interval).
1578
+ void this.registerRtdbPresence(scope, localDeviceId, this.rtdbTicketVersion)
1579
+ .catch((err) => {
1580
+ console.warn('[WasmBridge] RTDB presence register failed; falling back to Firestore heartbeat:', err);
1581
+ this.startLegacyBrowserPresenceLoop();
1582
+ });
1583
+ // No setInterval — RTDB WebSocket keepalive handles liveness.
1584
+ return;
1585
+ }
1586
+ // Fallback: legacy 60-second Firestore heartbeat (no RTDB).
1587
+ this.startLegacyBrowserPresenceLoop();
1588
+ }
1589
+ /**
1590
+ * Build the RTDB discovery scope from the current auth context and options.
1591
+ * Returns null if scope cannot be determined (e.g. not yet authenticated).
1592
+ */
1593
+ buildRtdbScope(userIdHint) {
1594
+ if (this.isTicketOnlyMode()) {
1595
+ return null;
1596
+ }
1597
+ const mode = this.options.discoveryMode ?? 'personal';
1598
+ const appTag = this.appTag;
1599
+ if (mode === 'personal') {
1600
+ // Personal mode uses the space namespace derived from apiKey+spaceKey.
1601
+ // This is async (SHA-256 hash), so we skip RTDB for personal mode
1602
+ // in startPresenceLoop and rely on the caller to set it explicitly.
1603
+ // TODO: wire personal mode RTDB in a follow-up once namespace is cached.
1604
+ return null;
1605
+ }
1606
+ const userId = userIdHint
1607
+ || this.getDiscoveryScopeId()
1608
+ || this.getCurrentUserId();
1609
+ if (!userId)
1610
+ return null;
1611
+ // user-scoped (default for managed mode)
1612
+ return { kind: 'user-scoped', appTag, userId };
1613
+ }
1614
+ async createSession(session) {
1615
+ if (this.isTicketOnlyMode()) {
1616
+ throw new Error('[openrtc] createSession() is unavailable when signalingMode="ticket-only".');
1617
+ }
1618
+ const userId = this.getCurrentUserId();
1619
+ if (!userId && !session.initiator && !session.target && !this.isPersonalSpaceMode()) {
1620
+ throw new Error('createSession requires user scope');
1621
+ }
1622
+ const scopedSession = {
1623
+ ...session,
1624
+ appTag: session.appTag ?? this.appTag,
1625
+ initiator: session.initiator ?? userId ?? undefined,
1626
+ target: session.target ?? userId ?? undefined,
1627
+ };
1628
+ await this.getBrowserFirestoreSignaling().createSession(scopedSession);
1629
+ }
1630
+ async updateSession(sessionId, updateData) {
1631
+ if (this.isTicketOnlyMode()) {
1632
+ throw new Error('[openrtc] updateSession() is unavailable when signalingMode="ticket-only".');
1633
+ }
1634
+ await this.getBrowserFirestoreSignaling().updateSession(sessionId, updateData);
1635
+ }
1636
+ async subscribeSessions(localDeviceId) {
1637
+ if (this.isTicketOnlyMode()) {
1638
+ return new ReadableStream({
1639
+ start(controller) {
1640
+ controller.close();
1641
+ },
1642
+ });
1643
+ }
1644
+ return this.getBrowserFirestoreSignaling().subscribeSessions(localDeviceId);
1645
+ }
1646
+ async getLocalDeviceId() {
1647
+ return this.resolveWebLogicalDeviceId();
1648
+ }
1649
+ async isConnected(node_id) {
1650
+ const wasmClient = await this.waitForWasmClient();
1651
+ if (wasmClient && typeof wasmClient.is_connected === 'function') {
1652
+ return await wasmClient.is_connected(node_id);
1653
+ }
1654
+ return false;
1655
+ }
1656
+ async getConnectionStates() {
1657
+ const wasmClient = await this.waitForWasmClient(500);
1658
+ if (wasmClient && typeof wasmClient.connection_states === 'function') {
1659
+ const snapshots = await wasmClient.connection_states();
1660
+ const states = (Array.isArray(snapshots) ? snapshots : [])
1661
+ .map((snapshot) => this.normalizeConnectionStateSnapshot(snapshot))
1662
+ .filter((state) => !!state);
1663
+ if (states.length > 0) {
1664
+ openRtcDebug('[WasmBridge] replaying current wasm connection states', states.map((state) => ({
1665
+ connectionId: state.connectionId,
1666
+ deviceId: state.deviceId ?? null,
1667
+ deviceIdHint: state.deviceIdHint ?? null,
1668
+ remoteNodeId: state.remoteNodeId ?? null,
1669
+ state: state.state,
1670
+ transportState: state.transportState ?? null,
1671
+ protocolState: state.protocolState ?? null,
1672
+ routable: state.routable ?? null,
1673
+ })));
1674
+ }
1675
+ return states;
1676
+ }
1677
+ const sessions = await this.listPeerSessions().catch(() => []);
1678
+ const states = sessions
1679
+ .map((snapshot) => this.toBackendConnectionStateFromPeerSession(snapshot))
1680
+ .filter((state) => !!state);
1681
+ if (states.length > 0) {
1682
+ openRtcDebug('[WasmBridge] replaying current wasm connection states', states.map((state) => ({
1683
+ connectionId: state.connectionId,
1684
+ deviceId: state.deviceId ?? null,
1685
+ deviceIdHint: state.deviceIdHint ?? null,
1686
+ remoteNodeId: state.remoteNodeId ?? null,
1687
+ state: state.state,
1688
+ transportState: state.transportState ?? null,
1689
+ protocolState: state.protocolState ?? null,
1690
+ routable: state.routable ?? null,
1691
+ })));
1692
+ }
1693
+ return states;
1694
+ }
1695
+ onConnectionStateChange(callback) {
1696
+ if (typeof window === 'undefined') {
1697
+ return () => { };
1698
+ }
1699
+ // Per-subscriber dedup map so multiple subscribers each receive all events
1700
+ const subscriberDedup = new Map();
1701
+ const handleEvent = (event) => {
1702
+ const customEvent = event;
1703
+ const detail = customEvent?.detail && typeof customEvent.detail === 'object'
1704
+ ? customEvent.detail
1705
+ : null;
1706
+ if (!detail) {
1707
+ return;
1708
+ }
1709
+ const rawState = this.normalizeWindowConnectionStateDetail(detail);
1710
+ if (!rawState) {
1711
+ return;
1712
+ }
1713
+ void this.enrichBackendConnectionState(rawState)
1714
+ .then((enriched) => {
1715
+ this.deliverConnectionState(enriched, callback, 'event', subscriberDedup);
1716
+ })
1717
+ .catch((error) => {
1718
+ console.warn('[WasmBridge] Failed to enrich connection-state-changed event:', error);
1719
+ this.deliverConnectionState(rawState, callback, 'event', subscriberDedup);
1720
+ });
1721
+ };
1722
+ window.addEventListener('connection-state-changed', handleEvent);
1723
+ void this.getConnectionStates()
1724
+ .then((states) => {
1725
+ states.forEach((state) => this.deliverConnectionState(state, callback, 'replay', subscriberDedup));
1726
+ })
1727
+ .catch((error) => {
1728
+ console.warn('[WasmBridge] Failed to replay wasm connection states after subscription:', error);
1729
+ });
1730
+ return () => {
1731
+ window.removeEventListener('connection-state-changed', handleEvent);
1732
+ };
1733
+ }
1734
+ async sendExplicitFilePath(connectionId, filePath, transferId = '') {
1735
+ throw new Error('sendExplicitFilePath is native-only. Use sendExplicitFileData in web/wasm runtime.');
1736
+ }
1737
+ async sendExplicitFileData(params) {
1738
+ const connection = params.connection;
1739
+ const file = params.file;
1740
+ const remoteNodeId = params.remoteNodeId?.trim()
1741
+ || connection?.remoteNodeId?.trim()
1742
+ || connection?.deviceId?.trim();
1743
+ openRtcDebugInfo('[WasmBridge] sendExplicitFileData start', {
1744
+ connectionId: params.connectionId ?? null,
1745
+ connectionRemoteNodeId: connection?.remoteNodeId ?? null,
1746
+ remoteNodeId: remoteNodeId ?? null,
1747
+ fileName: file.name,
1748
+ fileSize: file.size,
1749
+ transferId: params.transferId ?? null,
1750
+ });
1751
+ if (!remoteNodeId) {
1752
+ throw new Error('Web explicit transfer requires an active connection with a resolved remote node ID');
1753
+ }
1754
+ const wasmClient = await this.waitForWasmClient();
1755
+ if (!wasmClient || typeof wasmClient.open_bi !== 'function') {
1756
+ throw new Error('WASM explicit transfer requires open_bi support');
1757
+ }
1758
+ const biStream = await wasmClient.open_bi(remoteNodeId);
1759
+ openRtcDebugInfo('[WasmBridge] sendExplicitFileData open_bi resolved', {
1760
+ remoteNodeId,
1761
+ transferId: params.transferId ?? null,
1762
+ });
1763
+ const writer = biStream.send.getWriter();
1764
+ const encoder = new TextEncoder();
1765
+ const transferId = params.transferId && params.transferId.trim().length > 0
1766
+ ? params.transferId
1767
+ : `transfer-${Date.now()}`;
1768
+ try {
1769
+ const header = {
1770
+ transfer_id: transferId,
1771
+ filename: file.name,
1772
+ size: file.size,
1773
+ mime_type: file.type || 'application/octet-stream',
1774
+ };
1775
+ const headerBytes = encoder.encode(JSON.stringify(header));
1776
+ const headerLen = new Uint8Array(4);
1777
+ new DataView(headerLen.buffer).setUint32(0, headerBytes.byteLength, false);
1778
+ await writer.write(new Uint8Array([WasmBridge.EXPLICIT_FILE_PROTOCOL_TYPE]));
1779
+ await writer.write(headerLen);
1780
+ await writer.write(headerBytes);
1781
+ const payload = new Uint8Array(await file.arrayBuffer());
1782
+ for (let offset = 0; offset < payload.byteLength; offset += WasmBridge.EXPLICIT_FILE_CHUNK_SIZE_BYTES) {
1783
+ const chunk = payload.slice(offset, Math.min(offset + WasmBridge.EXPLICIT_FILE_CHUNK_SIZE_BYTES, payload.byteLength));
1784
+ await writer.write(chunk);
1785
+ }
1786
+ }
1787
+ finally {
1788
+ await writer.close().catch(() => { });
1789
+ writer.releaseLock();
1790
+ openRtcDebugInfo('[WasmBridge] sendExplicitFileData completed', {
1791
+ remoteNodeId,
1792
+ transferId,
1793
+ fileName: file.name,
1794
+ fileSize: file.size,
1795
+ });
1796
+ }
1797
+ }
1798
+ async getTransferHistory(limit = 50) {
1799
+ return [];
1800
+ }
1801
+ async deleteTransferJob(jobId) {
1802
+ // Web runtime keeps transfer history in app-managed storage.
1803
+ }
1804
+ }
1805
+ WasmBridge.EXPLICIT_FILE_PROTOCOL_TYPE = 0x02;
1806
+ WasmBridge.EXPLICIT_FILE_CHUNK_SIZE_BYTES = 64 * 1024;
1807
+ WasmBridge.BROWSER_AUTO_CONNECT_SCAN_DEBOUNCE_MS = 150;
1808
+ WasmBridge.BROWSER_AUTO_CONNECT_RETRY_COOLDOWN_MS = 2000;
1809
+ WasmBridge.BROWSER_AUTO_CONNECT_SETTLE_GUARD_MS = 10000;
1810
+ WasmBridge.WEB_DEVICE_ID_STORAGE_PREFIX = 'pluto_rtc_web_device_identity';
1811
+ WasmBridge.EPHEMERAL_DEVICE_ID_TTL_MS = 60000;