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,1472 @@
1
+ import { WasmBridge } from './WasmBridge';
2
+ import { normalizeDeviceStatusSnapshotRecord, normalizeManagedConnectionRecord, normalizePeerSessionSnapshotRecord, normalizeResolvedPeerIdentity, } from './BridgeCodecs';
3
+ import { isTauriEnvironment } from '../../utils/runtime';
4
+ import { getAuthHost } from '../../auth/host';
5
+ import { assertValidIpcBridge, createCompatTauriIpcBridge, } from '../../runtime/ipc';
6
+ import { openRtcDebug, openRtcDebugInfo } from '../../utils/debug';
7
+ function fingerprintForLogs(value) {
8
+ if (!value) {
9
+ return null;
10
+ }
11
+ let hash = 0x811c9dc5;
12
+ for (let i = 0; i < value.length; i += 1) {
13
+ hash ^= value.charCodeAt(i);
14
+ hash = Math.imul(hash, 0x01000193);
15
+ }
16
+ return `fp-${(hash >>> 0).toString(16).padStart(8, '0')}`;
17
+ }
18
+ export class NativeIpcBridge {
19
+ constructor(options) {
20
+ /**
21
+ * NativeIpcBridge is a host adapter for native IPC.
22
+ *
23
+ * Allowed:
24
+ * - lightweight IPC readiness checks
25
+ * - native auth relay
26
+ * - typed command/event forwarding
27
+ *
28
+ * Not allowed:
29
+ * - becoming a second lifecycle engine
30
+ * - owning peer identity policy
31
+ * - teaching product code to depend on native-specific fallbacks
32
+ *
33
+ * Source of truth:
34
+ * - Rust core owns connection lifecycle and device identity
35
+ * - RuntimeClient owns host-facing orchestration
36
+ */
37
+ this.subscriptionNonce = 0;
38
+ this.lastSyncedAuthToken = null;
39
+ this.cachedLocalDeviceId = null;
40
+ this.cachedLocalDeviceName = null;
41
+ this.tauriCoreAvailability = null;
42
+ this.tauriCoreAvailabilityValue = null;
43
+ this.tauriCoreAvailabilityCheckedAtMs = 0;
44
+ this.authSyncInFlight = null;
45
+ this.lastNativeAuthSyncAtMs = 0;
46
+ this.hasLoggedMissingAuthForDevices = false;
47
+ this.nativeRtdbPresenceScope = null;
48
+ this.nativeRtdbTicketVersion = 0;
49
+ this.lastNativePresenceTicket = null;
50
+ this.fallback = new WasmBridge(options);
51
+ this.app = this.fallback.app;
52
+ this.auth = this.fallback.auth;
53
+ this.appTag = this.fallback.appTag;
54
+ this.options = this.fallback.getResolvedOptions();
55
+ this.allowWasmFallback = options.allowWasmFallback ?? false;
56
+ this.ipcBridge = assertValidIpcBridge(options.ipcBridge ?? createCompatTauriIpcBridge(), 'NativeIpcBridge');
57
+ }
58
+ get currentUser() {
59
+ return this.fallback.currentUser;
60
+ }
61
+ get tag() {
62
+ return this.fallback.tag;
63
+ }
64
+ setWasmClient(client) {
65
+ this.fallback.setWasmClient(client);
66
+ }
67
+ onAuthChange(callback) {
68
+ return this.fallback.onAuthChange(callback);
69
+ }
70
+ checkForSSOToken() {
71
+ return this.fallback.checkForSSOToken();
72
+ }
73
+ waitForAuth() {
74
+ return this.fallback.waitForAuth();
75
+ }
76
+ signInAnonymously() {
77
+ return this.fallback.signInAnonymously();
78
+ }
79
+ getTurnCredentials() {
80
+ return this.fallback.getTurnCredentials();
81
+ }
82
+ cleanupStaleDevices() {
83
+ return this.fallback.cleanupStaleDevices();
84
+ }
85
+ sendMessage(targetId, payload, state, replyPayload) {
86
+ return this.fallback.sendMessage(targetId, payload, state, replyPayload);
87
+ }
88
+ pollMessages(targetId) {
89
+ return this.fallback.pollMessages(targetId);
90
+ }
91
+ createSession(session) {
92
+ return this.fallback.createSession(session);
93
+ }
94
+ updateSession(sessionId, updateData) {
95
+ return this.fallback.updateSession(sessionId, updateData);
96
+ }
97
+ openPeerBi(id, timeoutMs) {
98
+ return this.fallback.openPeerBi(id, timeoutMs);
99
+ }
100
+ openPeerUni(id, timeoutMs) {
101
+ return this.fallback.openPeerUni(id, timeoutMs);
102
+ }
103
+ isConnected(nodeId) {
104
+ return this.fallback.isConnected(nodeId);
105
+ }
106
+ getAuthContext() {
107
+ return this.fallback.getAuthContext();
108
+ }
109
+ getCurrentUserId() {
110
+ return this.fallback.getCurrentUserIdForScope();
111
+ }
112
+ resolveWebLogicalDeviceId() {
113
+ return this.fallback.getResolvedWebLogicalDeviceId();
114
+ }
115
+ normalizeDevice(raw, fallbackId) {
116
+ return this.fallback.normalizeDeviceRecord(raw, fallbackId);
117
+ }
118
+ matchesTag(device) {
119
+ return this.fallback.matchesDiscoveryTag(device);
120
+ }
121
+ buildNativeRtdbScope(userIdHint) {
122
+ const mode = this.options.discoveryMode ?? 'personal';
123
+ if (mode === 'personal') {
124
+ return null;
125
+ }
126
+ const userId = userIdHint ?? this.getCurrentUserId();
127
+ if (!userId) {
128
+ return null;
129
+ }
130
+ return {
131
+ kind: 'user-scoped',
132
+ appTag: this.appTag,
133
+ userId,
134
+ };
135
+ }
136
+ async syncNativeRtdbPresenceTicket(ticket) {
137
+ const scope = this.buildNativeRtdbScope();
138
+ const localDeviceId = await this.resolveLocalDeviceId();
139
+ if (!scope || !localDeviceId) {
140
+ return;
141
+ }
142
+ const rtdbPresence = this.fallback.rtdbPresence;
143
+ if (!rtdbPresence || !this.nativeRtdbPresenceScope) {
144
+ await this.fallback.registerRtdbPresence(scope, localDeviceId, this.nativeRtdbTicketVersion);
145
+ this.nativeRtdbPresenceScope = scope;
146
+ this.lastNativePresenceTicket = ticket;
147
+ openRtcDebugInfo('[NativeIpcBridge][RTDB] registered native presence ticket state', {
148
+ deviceId: localDeviceId,
149
+ ticketVersion: this.nativeRtdbTicketVersion,
150
+ ticketFingerprint: fingerprintForLogs(ticket),
151
+ });
152
+ return;
153
+ }
154
+ const ticketChanged = this.lastNativePresenceTicket !== ticket;
155
+ const scopeChanged = this.nativeRtdbPresenceScope.kind !== scope.kind ||
156
+ (scope.kind === 'user-scoped' &&
157
+ this.nativeRtdbPresenceScope.kind === 'user-scoped' &&
158
+ (this.nativeRtdbPresenceScope.appTag !== scope.appTag ||
159
+ this.nativeRtdbPresenceScope.userId !== scope.userId));
160
+ if (scopeChanged) {
161
+ await this.fallback.registerRtdbPresence(scope, localDeviceId, this.nativeRtdbTicketVersion);
162
+ this.nativeRtdbPresenceScope = scope;
163
+ }
164
+ if (ticketChanged) {
165
+ this.nativeRtdbTicketVersion += 1;
166
+ await this.fallback.rtdbPresence?.bumpTicketVersion(scope, localDeviceId, this.nativeRtdbTicketVersion);
167
+ openRtcDebugInfo('[NativeIpcBridge][RTDB] bumped native ticketVersion after managed ticket change', {
168
+ deviceId: localDeviceId,
169
+ ticketVersion: this.nativeRtdbTicketVersion,
170
+ ticketFingerprint: fingerprintForLogs(ticket),
171
+ });
172
+ }
173
+ this.nativeRtdbPresenceScope = scope;
174
+ this.lastNativePresenceTicket = ticket;
175
+ }
176
+ async markNativeRtdbOffline() {
177
+ if (!this.nativeRtdbPresenceScope) {
178
+ return;
179
+ }
180
+ const localDeviceId = await this.resolveLocalDeviceId();
181
+ if (!localDeviceId) {
182
+ return;
183
+ }
184
+ try {
185
+ await this.fallback.rtdbPresence?.markOffline(this.nativeRtdbPresenceScope, localDeviceId, this.nativeRtdbTicketVersion);
186
+ openRtcDebugInfo('[NativeIpcBridge][RTDB] marked native presence offline', {
187
+ deviceId: localDeviceId,
188
+ ticketVersion: this.nativeRtdbTicketVersion,
189
+ });
190
+ }
191
+ catch (error) {
192
+ console.warn('[NativeIpcBridge][RTDB] failed to mark native presence offline:', error);
193
+ }
194
+ finally {
195
+ this.nativeRtdbPresenceScope = null;
196
+ this.nativeRtdbTicketVersion = 0;
197
+ this.lastNativePresenceTicket = null;
198
+ }
199
+ }
200
+ isTauriEnv() {
201
+ return isTauriEnvironment();
202
+ }
203
+ sleep(ms) {
204
+ return new Promise((resolve) => setTimeout(resolve, ms));
205
+ }
206
+ nextConnectionEventRetryDelayMs(attempt) {
207
+ const backoff = NativeIpcBridge.CONNECTION_EVENT_RETRY_MIN_DELAY_MS * Math.pow(2, Math.max(0, attempt - 1));
208
+ return Math.min(NativeIpcBridge.CONNECTION_EVENT_RETRY_MAX_DELAY_MS, backoff);
209
+ }
210
+ async clearNativeAuthRelay(reason) {
211
+ if (!(await this.canUseTauriIpc())) {
212
+ return;
213
+ }
214
+ try {
215
+ await this.invokeIpc('desktop_set_pluto_auth_token', {
216
+ authToken: null,
217
+ refreshToken: null,
218
+ });
219
+ this.lastSyncedAuthToken = null;
220
+ this.lastNativeAuthSyncAtMs = 0;
221
+ openRtcDebugInfo(`[NativeIpcBridge] cleared native auth relay (${reason})`);
222
+ }
223
+ catch (error) {
224
+ console.warn(`[NativeIpcBridge] failed clearing native auth relay (${reason}):`, error);
225
+ }
226
+ }
227
+ async canUseTauriIpc() {
228
+ if (this.ipcBridge) {
229
+ if (typeof this.ipcBridge.isAvailable === 'function') {
230
+ return !!(await this.ipcBridge.isAvailable());
231
+ }
232
+ return true;
233
+ }
234
+ if (typeof window === 'undefined') {
235
+ return false;
236
+ }
237
+ if (this.tauriCoreAvailabilityValue === true) {
238
+ return true;
239
+ }
240
+ // Do not permanently cache "false". Tauri globals/IPC can appear slightly after boot.
241
+ if (this.tauriCoreAvailabilityValue === false &&
242
+ Date.now() - this.tauriCoreAvailabilityCheckedAtMs <
243
+ NativeIpcBridge.TAURI_IPC_NEGATIVE_CACHE_MS) {
244
+ return false;
245
+ }
246
+ if (!this.tauriCoreAvailability) {
247
+ this.tauriCoreAvailability = (async () => {
248
+ try {
249
+ // Fast path: canonical Tauri globals are present.
250
+ if (this.isTauriEnv()) {
251
+ this.tauriCoreAvailabilityValue = true;
252
+ this.tauriCoreAvailabilityCheckedAtMs = Date.now();
253
+ return true;
254
+ }
255
+ // Fallback path: some runtimes may not expose globals consistently.
256
+ // Probe IPC directly and treat command-level failures as "IPC exists".
257
+ try {
258
+ await this.invokeIpc('rtc_native_status');
259
+ openRtcDebugInfo('[NativeIpcBridge] Resolved Tauri IPC via rtc_native_status probe');
260
+ this.tauriCoreAvailabilityValue = true;
261
+ this.tauriCoreAvailabilityCheckedAtMs = Date.now();
262
+ return true;
263
+ }
264
+ catch (error) {
265
+ const message = String(error?.message ?? error ?? '');
266
+ const missingRuntime = message.includes('__TAURI_INTERNALS__') ||
267
+ message.includes('Cannot read properties of undefined') ||
268
+ message.includes('window is not defined');
269
+ if (!missingRuntime) {
270
+ openRtcDebugInfo('[NativeIpcBridge] Tauri IPC probe reached native layer (command-level error acceptable)');
271
+ this.tauriCoreAvailabilityValue = true;
272
+ this.tauriCoreAvailabilityCheckedAtMs = Date.now();
273
+ return true;
274
+ }
275
+ this.tauriCoreAvailabilityValue = false;
276
+ this.tauriCoreAvailabilityCheckedAtMs = Date.now();
277
+ return false;
278
+ }
279
+ }
280
+ catch {
281
+ this.tauriCoreAvailabilityValue = false;
282
+ this.tauriCoreAvailabilityCheckedAtMs = Date.now();
283
+ return false;
284
+ }
285
+ })().finally(() => {
286
+ this.tauriCoreAvailability = null;
287
+ });
288
+ }
289
+ return this.tauriCoreAvailability;
290
+ }
291
+ async invokeIpc(command, args) {
292
+ return this.ipcBridge.invoke(command, args);
293
+ }
294
+ async listenIpc(event, handler) {
295
+ return this.ipcBridge.listen(event, (payload) => {
296
+ handler({ payload });
297
+ });
298
+ }
299
+ async shouldUseTauriSignaling() {
300
+ const available = await this.canUseTauriIpc();
301
+ if (available) {
302
+ return true;
303
+ }
304
+ if (this.allowWasmFallback) {
305
+ return false;
306
+ }
307
+ throw new Error('[pluto-rtc] Tauri runtime adapter requires native IPC, but it is unavailable.');
308
+ }
309
+ async signInWithPluto() {
310
+ if (!(await this.canUseTauriIpc())) {
311
+ await this.fallback.signInWithPluto();
312
+ return;
313
+ }
314
+ const result = await this.invokeIpc('rtc_sign_in_with_pluto');
315
+ const customToken = typeof result?.customToken === 'string' ? result.customToken.trim() : '';
316
+ if (!customToken) {
317
+ throw new Error('[pluto-rtc] Native Pluto SSO did not return a custom token.');
318
+ }
319
+ await getAuthHost().signInWithCustomTokenValue(customToken);
320
+ await this.ensureNativeAuth('pluto-sso', { requireToken: true, maxWaitMs: 10000 });
321
+ }
322
+ async setAuthContext(context) {
323
+ await this.fallback.setAuthContext(context);
324
+ if (!context?.token) {
325
+ await this.markNativeRtdbOffline();
326
+ await this.clearNativeAuthRelay('setAuthContext');
327
+ }
328
+ }
329
+ async signOut() {
330
+ await this.fallback.signOut();
331
+ await this.markNativeRtdbOffline();
332
+ await this.clearNativeAuthRelay('signOut');
333
+ }
334
+ async stopAuthScopedActivity(options) {
335
+ const userId = options?.userId ?? this.currentUser?.id ?? null;
336
+ if (await this.shouldUseTauriSignaling()) {
337
+ try {
338
+ await Promise.allSettled([
339
+ this.revokeSessionTokensByScope('user-device'),
340
+ this.invokeIpc('stop_rtc_presence_loop'),
341
+ this.invokeIpc('stop_rtc_auto_connect'),
342
+ userId ? this.invokeIpc('set_rtc_offline', { userId }) : Promise.resolve(),
343
+ ]);
344
+ }
345
+ catch (error) {
346
+ console.warn('[NativeIpcBridge] Failed to stop native auth-scoped RTC activity:', error);
347
+ }
348
+ }
349
+ await this.markNativeRtdbOffline();
350
+ await this.fallback.stopAuthScopedActivity({ userId });
351
+ }
352
+ async ensureNativeAuthOnce(reason, requireToken) {
353
+ if (!(await this.canUseTauriIpc()))
354
+ return true;
355
+ if (this.authSyncInFlight) {
356
+ return this.authSyncInFlight;
357
+ }
358
+ const syncPromise = (async () => {
359
+ try {
360
+ if (!this.currentUser) {
361
+ console.warn(`[NativeIpcBridge] auth sync skipped (${reason}): no current user`);
362
+ return !requireToken;
363
+ }
364
+ const shouldForceRefresh = (Date.now() - this.lastNativeAuthSyncAtMs) >= (8 * 60 * 1000);
365
+ const token = this.currentUser?.getToken
366
+ ? await this.currentUser.getToken(shouldForceRefresh)
367
+ : null;
368
+ const authContext = this.getAuthContext();
369
+ const refreshToken = authContext?.refreshToken ??
370
+ (this.auth?.currentUser?.refreshToken ?? null);
371
+ if (!token) {
372
+ console.warn(`[NativeIpcBridge] auth sync skipped (${reason}): missing token for user ${this.currentUser?.id}`);
373
+ return !requireToken;
374
+ }
375
+ if (token === this.lastSyncedAuthToken) {
376
+ return true;
377
+ }
378
+ const relayResult = await Promise.allSettled([
379
+ this.invokeIpc('desktop_set_pluto_auth_token', { authToken: token, refreshToken }),
380
+ ]);
381
+ if (relayResult[0]?.status === 'rejected') {
382
+ console.warn(`[NativeIpcBridge] desktop auth relay failed (${reason}):`, relayResult[0].reason);
383
+ return false;
384
+ }
385
+ this.lastSyncedAuthToken = token;
386
+ this.lastNativeAuthSyncAtMs = Date.now();
387
+ openRtcDebugInfo(`[NativeIpcBridge] auth token relayed to native runtime (${reason}) user_id=${this.currentUser?.id}`);
388
+ return true;
389
+ }
390
+ catch (error) {
391
+ console.error('[NativeIpcBridge] auth sync failed:', error);
392
+ return false;
393
+ }
394
+ })();
395
+ this.authSyncInFlight = syncPromise;
396
+ try {
397
+ return await syncPromise;
398
+ }
399
+ finally {
400
+ if (this.authSyncInFlight === syncPromise) {
401
+ this.authSyncInFlight = null;
402
+ }
403
+ }
404
+ }
405
+ async ensureNativeAuth(reason = 'runtime', options) {
406
+ if (!(await this.canUseTauriIpc()))
407
+ return true;
408
+ const requireToken = options?.requireToken ?? false;
409
+ const maxWaitMs = options?.maxWaitMs ?? (requireToken ? NativeIpcBridge.AUTH_REQUIRED_WAIT_MS : 0);
410
+ const pollMs = options?.pollMs ?? 250;
411
+ const deadlineMs = Date.now() + Math.max(0, maxWaitMs);
412
+ while (true) {
413
+ const ready = await this.ensureNativeAuthOnce(reason, requireToken);
414
+ if (ready) {
415
+ return true;
416
+ }
417
+ if (!requireToken || Date.now() >= deadlineMs) {
418
+ return false;
419
+ }
420
+ await this.sleep(pollMs);
421
+ }
422
+ }
423
+ async resolveLocalDeviceId() {
424
+ if (this.cachedLocalDeviceId)
425
+ return this.cachedLocalDeviceId;
426
+ if (await this.canUseTauriIpc()) {
427
+ try {
428
+ const info = await this.invokeIpc('get_rtc_local_device_info');
429
+ const nativeDeviceId = typeof info?.device_id === 'string' ? info.device_id : '';
430
+ const nativeDeviceName = typeof info?.device_name === 'string' ? info.device_name.trim() : '';
431
+ if (nativeDeviceId) {
432
+ this.cachedLocalDeviceId = nativeDeviceId;
433
+ if (nativeDeviceName) {
434
+ this.cachedLocalDeviceName = nativeDeviceName;
435
+ }
436
+ return nativeDeviceId;
437
+ }
438
+ }
439
+ catch (error) {
440
+ console.warn('[NativeIpcBridge] get_rtc_local_device_info failed:', error);
441
+ }
442
+ }
443
+ const fallbackDeviceId = this.resolveWebLogicalDeviceId();
444
+ this.cachedLocalDeviceId = fallbackDeviceId;
445
+ return fallbackDeviceId;
446
+ }
447
+ async resolveLocalDeviceName() {
448
+ if (this.cachedLocalDeviceName) {
449
+ return this.cachedLocalDeviceName;
450
+ }
451
+ if (await this.canUseTauriIpc()) {
452
+ try {
453
+ const info = await this.invokeIpc('get_rtc_local_device_info');
454
+ const nativeDeviceName = typeof info?.device_name === 'string' ? info.device_name.trim() : '';
455
+ if (nativeDeviceName) {
456
+ const resolvedName = this.normalizeNativeDeviceName(nativeDeviceName);
457
+ this.cachedLocalDeviceName = resolvedName;
458
+ return resolvedName;
459
+ }
460
+ }
461
+ catch (error) {
462
+ console.warn('[NativeIpcBridge] get_rtc_local_device_info failed:', error);
463
+ }
464
+ }
465
+ return this.defaultDeviceNameFallback();
466
+ }
467
+ normalizeNativeDeviceName(rawName) {
468
+ const trimmedName = rawName.trim();
469
+ if (!trimmedName) {
470
+ return this.defaultDeviceNameFallback();
471
+ }
472
+ if (trimmedName === 'Desktop Device') {
473
+ const fallbackName = this.defaultDeviceNameFallback();
474
+ if (fallbackName !== 'Desktop Device') {
475
+ return fallbackName;
476
+ }
477
+ }
478
+ return trimmedName;
479
+ }
480
+ defaultDeviceNameFallback() {
481
+ if (typeof window === 'undefined' || typeof navigator === 'undefined') {
482
+ return 'Desktop Device';
483
+ }
484
+ const userAgent = navigator.userAgent || '';
485
+ if (/iPhone|iPad|iPod/i.test(userAgent)) {
486
+ return 'iPhone';
487
+ }
488
+ if (/Android/i.test(userAgent)) {
489
+ return 'Android';
490
+ }
491
+ return 'Desktop Device';
492
+ }
493
+ withPresenceMetadata(metadata, deviceId) {
494
+ try {
495
+ const data = metadata ? JSON.parse(metadata) : {};
496
+ return JSON.stringify({ ...data, deviceId, plutoVersion: '0.1.0-desktop' });
497
+ }
498
+ catch {
499
+ return JSON.stringify({ deviceId, plutoVersion: '0.1.0-desktop', rawMetadata: metadata });
500
+ }
501
+ }
502
+ // ── ISignalingBackend overrides ──────────────────────────────────────
503
+ async searchDevices(excludeNodeId) {
504
+ if (await this.shouldUseTauriSignaling()) {
505
+ const authReady = await this.ensureNativeAuth('searchDevices', {
506
+ requireToken: true,
507
+ maxWaitMs: NativeIpcBridge.AUTH_REQUIRED_WAIT_MS,
508
+ });
509
+ if (!authReady) {
510
+ throw new Error('Native auth token unavailable for searchDevices');
511
+ }
512
+ const result = await this.invokeIpc('search_rtc_devices', {
513
+ userId: this.currentUser?.id,
514
+ });
515
+ if (Array.isArray(result?.devices)) {
516
+ let devices = result.devices.map((d) => this.normalizeDevice(d));
517
+ if (excludeNodeId) {
518
+ devices = devices.filter((d) => d.ticket !== excludeNodeId &&
519
+ d.deviceId !== excludeNodeId &&
520
+ d.nodeId !== excludeNodeId);
521
+ }
522
+ return devices;
523
+ }
524
+ if (typeof result?.message === 'string') {
525
+ throw new Error(String(result.message));
526
+ }
527
+ throw new Error('Unexpected response for search_rtc_devices');
528
+ }
529
+ return this.fallback.searchDevices(excludeNodeId);
530
+ }
531
+ async listDevicesWithStatus() {
532
+ if (await this.shouldUseTauriSignaling()) {
533
+ const authReady = await this.ensureNativeAuth('listDevicesWithStatus', {
534
+ requireToken: true,
535
+ maxWaitMs: NativeIpcBridge.AUTH_REQUIRED_WAIT_MS,
536
+ });
537
+ if (!authReady) {
538
+ throw new Error('Native auth token unavailable for listDevicesWithStatus');
539
+ }
540
+ const result = await this.invokeIpc('search_rtc_devices_with_status', {
541
+ userId: this.currentUser?.id,
542
+ });
543
+ if (Array.isArray(result?.devices)) {
544
+ const devices = result.devices
545
+ .map((device) => normalizeDeviceStatusSnapshotRecord(device))
546
+ .filter((device) => this.matchesTag(device));
547
+ openRtcDebug('[NativeIpcBridge] listDevicesWithStatus result', {
548
+ count: devices.length,
549
+ devices: devices.map((device) => ({
550
+ deviceId: device.deviceId,
551
+ deviceName: device.deviceName,
552
+ online: !!device.online,
553
+ connectionStatus: device.connectionStatus,
554
+ settledReady: !!device.settledReady,
555
+ peerHealth: device.peerHealth,
556
+ scopes: device.scopes,
557
+ connectionId: device.connectionId ?? null,
558
+ deviceIdHint: device.deviceIdHint ?? null,
559
+ nodeId: device.nodeId ?? null,
560
+ })),
561
+ });
562
+ return devices;
563
+ }
564
+ if (typeof result?.message === 'string') {
565
+ throw new Error(String(result.message));
566
+ }
567
+ throw new Error('Unexpected response for search_rtc_devices_with_status');
568
+ }
569
+ return this.fallback.listDevicesWithStatus();
570
+ }
571
+ async getPeerSession(id) {
572
+ if (await this.shouldUseTauriSignaling()) {
573
+ const snapshot = await this.invokeIpc('get_rtc_peer_session', { id });
574
+ if (!snapshot || typeof snapshot !== 'object') {
575
+ return null;
576
+ }
577
+ return normalizePeerSessionSnapshotRecord(snapshot);
578
+ }
579
+ return this.fallback.getPeerSession(id);
580
+ }
581
+ async listPeerSessions() {
582
+ if (await this.shouldUseTauriSignaling()) {
583
+ const snapshots = await this.invokeIpc('list_rtc_peer_sessions');
584
+ return (Array.isArray(snapshots) ? snapshots : []).map((snapshot) => normalizePeerSessionSnapshotRecord(snapshot));
585
+ }
586
+ return this.fallback.listPeerSessions();
587
+ }
588
+ async waitForSettledPeer(id, timeoutMs) {
589
+ if (await this.shouldUseTauriSignaling()) {
590
+ const snapshot = await this.invokeIpc('wait_for_rtc_settled_peer', { id, timeoutMs });
591
+ if (!snapshot || typeof snapshot !== 'object') {
592
+ return null;
593
+ }
594
+ return normalizePeerSessionSnapshotRecord(snapshot);
595
+ }
596
+ return this.fallback.waitForSettledPeer(id, timeoutMs);
597
+ }
598
+ async resolvePeerConnectionRecords(id) {
599
+ if (await this.shouldUseTauriSignaling()) {
600
+ const records = await this.invokeIpc('resolve_rtc_peer_connection_records', { id });
601
+ return (Array.isArray(records) ? records : []).map((record) => normalizeManagedConnectionRecord(record));
602
+ }
603
+ return this.fallback.resolvePeerConnectionRecords(id);
604
+ }
605
+ async resolvePeerIdentity(id) {
606
+ if (await this.shouldUseTauriSignaling()) {
607
+ const snapshot = await this.invokeIpc('resolve_rtc_peer_identity', { id });
608
+ return normalizeResolvedPeerIdentity(snapshot);
609
+ }
610
+ return this.fallback.resolvePeerIdentity(id);
611
+ }
612
+ async setOffline(localNodeId) {
613
+ if (await this.shouldUseTauriSignaling()) {
614
+ try {
615
+ const authReady = await this.ensureNativeAuth('setOffline', {
616
+ requireToken: true,
617
+ maxWaitMs: NativeIpcBridge.AUTH_REQUIRED_WAIT_MS,
618
+ });
619
+ if (!authReady) {
620
+ throw new Error('Native auth token unavailable for setOffline');
621
+ }
622
+ const userId = this.getCurrentUserId();
623
+ await this.invokeIpc('set_rtc_offline', { userId });
624
+ await this.markNativeRtdbOffline();
625
+ return;
626
+ }
627
+ catch (error) {
628
+ console.warn('[NativeIpcBridge] set_rtc_offline failed:', error);
629
+ }
630
+ }
631
+ return this.fallback.setOffline(localNodeId);
632
+ }
633
+ async updateDevice(deviceId, updates) {
634
+ const useNative = await this.shouldUseTauriSignaling();
635
+ if (useNative) {
636
+ const authReady = await this.ensureNativeAuth('updateDevice', {
637
+ requireToken: true,
638
+ maxWaitMs: NativeIpcBridge.AUTH_REQUIRED_WAIT_MS,
639
+ });
640
+ if (!authReady) {
641
+ throw new Error('Native auth token unavailable for updateDevice');
642
+ }
643
+ const userId = this.getCurrentUserId();
644
+ if (!userId) {
645
+ throw new Error('updateDevice requires authenticated user scope');
646
+ }
647
+ try {
648
+ await this.invokeIpc('update_rtc_device', {
649
+ userId,
650
+ deviceId,
651
+ deviceName: updates.deviceName ?? null,
652
+ capabilities: updates.capabilities ?? null,
653
+ metadata: updates.metadata ?? null,
654
+ });
655
+ return;
656
+ }
657
+ catch (error) {
658
+ console.warn('[NativeIpcBridge] update_rtc_device failed:', error);
659
+ throw error instanceof Error ? error : new Error(String(error));
660
+ }
661
+ }
662
+ return this.fallback.updateDevice(deviceId, updates);
663
+ }
664
+ async deleteDevice(deviceId) {
665
+ const useNative = await this.shouldUseTauriSignaling();
666
+ if (useNative) {
667
+ const authReady = await this.ensureNativeAuth('deleteDevice', {
668
+ requireToken: true,
669
+ maxWaitMs: NativeIpcBridge.AUTH_REQUIRED_WAIT_MS,
670
+ });
671
+ if (!authReady) {
672
+ throw new Error('Native auth token unavailable for deleteDevice');
673
+ }
674
+ const userId = this.getCurrentUserId();
675
+ if (!userId) {
676
+ throw new Error('deleteDevice requires authenticated user scope');
677
+ }
678
+ try {
679
+ await this.invokeIpc('delete_rtc_device', { userId, deviceId });
680
+ return;
681
+ }
682
+ catch (error) {
683
+ console.warn('[NativeIpcBridge] delete_rtc_device failed:', error);
684
+ throw error instanceof Error ? error : new Error(String(error));
685
+ }
686
+ }
687
+ return this.fallback.deleteDevice(deviceId);
688
+ }
689
+ async getLocalDeviceInfo() {
690
+ if (!(await this.canUseTauriIpc())) {
691
+ const deviceId = await this.getLocalDeviceId?.();
692
+ if (!deviceId) {
693
+ return null;
694
+ }
695
+ return {
696
+ deviceId,
697
+ deviceName: this.defaultDeviceNameFallback(),
698
+ platformType: 'web',
699
+ };
700
+ }
701
+ try {
702
+ const info = await this.invokeIpc('get_rtc_local_device_info');
703
+ const deviceId = typeof info?.device_id === 'string' ? info.device_id.trim() : '';
704
+ if (!deviceId) {
705
+ return null;
706
+ }
707
+ return {
708
+ deviceId,
709
+ deviceName: this.normalizeNativeDeviceName(typeof info?.device_name === 'string' ? info.device_name : ''),
710
+ platformType: typeof info?.platform_type === 'string' ? info.platform_type : 'desktop',
711
+ capabilities: info?.capabilities ?? undefined,
712
+ lastSeenAt: typeof info?.last_seen_at === 'string' ? info.last_seen_at : undefined,
713
+ };
714
+ }
715
+ catch (error) {
716
+ console.warn('[NativeIpcBridge] getLocalDeviceInfo failed:', error);
717
+ return null;
718
+ }
719
+ }
720
+ async updateLocalDeviceName(deviceName) {
721
+ if (!(await this.canUseTauriIpc())) {
722
+ return null;
723
+ }
724
+ try {
725
+ const info = await this.invokeIpc('update_rtc_local_device_name', { deviceName });
726
+ const deviceId = typeof info?.device_id === 'string' ? info.device_id.trim() : '';
727
+ if (!deviceId) {
728
+ return null;
729
+ }
730
+ this.cachedLocalDeviceName = this.normalizeNativeDeviceName(deviceName);
731
+ return {
732
+ deviceId,
733
+ deviceName: this.cachedLocalDeviceName,
734
+ platformType: typeof info?.platform_type === 'string' ? info.platform_type : 'desktop',
735
+ capabilities: info?.capabilities ?? undefined,
736
+ lastSeenAt: typeof info?.last_seen_at === 'string' ? info.last_seen_at : undefined,
737
+ };
738
+ }
739
+ catch (error) {
740
+ console.warn('[NativeIpcBridge] updateLocalDeviceName failed:', error);
741
+ return null;
742
+ }
743
+ }
744
+ async getManagedNodeId(options) {
745
+ const initializeIfMissing = options?.initializeIfMissing ?? true;
746
+ if (!(await this.canUseTauriIpc())) {
747
+ return null;
748
+ }
749
+ try {
750
+ const existing = await this.invokeIpc('get_iroh_node_id');
751
+ if (existing || !initializeIfMissing) {
752
+ return existing;
753
+ }
754
+ return this.invokeIpc('start_iroh_node');
755
+ }
756
+ catch (error) {
757
+ console.warn('[NativeIpcBridge] getManagedNodeId failed:', error);
758
+ if (!initializeIfMissing) {
759
+ return null;
760
+ }
761
+ throw error;
762
+ }
763
+ }
764
+ async getEndpointTicket() {
765
+ if (!(await this.canUseTauriIpc())) {
766
+ throw new Error('[pluto-rtc] getEndpointTicket requires native IPC in Tauri runtime.');
767
+ }
768
+ return this.invokeIpc('get_iroh_endpoint_ticket');
769
+ }
770
+ async connectToDevice(params) {
771
+ if (!(await this.canUseTauriIpc())) {
772
+ throw new Error('[pluto-rtc] connectToDevice requires native IPC in Tauri runtime.');
773
+ }
774
+ if (this.options.authMode !== 'anonymous') {
775
+ await this.ensureNativeAuth('connectToDevice', {
776
+ requireToken: true,
777
+ maxWaitMs: NativeIpcBridge.AUTH_REQUIRED_WAIT_MS,
778
+ });
779
+ }
780
+ else {
781
+ openRtcDebugInfo('[NativeIpcBridge] connectToDevice using anonymous native ticket path');
782
+ }
783
+ return this.invokeIpc('connect_to_device', {
784
+ deviceId: params.deviceId ?? null,
785
+ endpointTicket: params.endpointTicket,
786
+ });
787
+ }
788
+ // -----------------------------------------------------------------------
789
+ // Session token IPC — syncs token operations with the native Rust backend
790
+ // -----------------------------------------------------------------------
791
+ async registerSessionToken(token, grantScope, maxConnections) {
792
+ if (!(await this.canUseTauriIpc()))
793
+ return;
794
+ await this.invokeIpc('register_session_token', { token, scope: grantScope, maxConnections });
795
+ }
796
+ async getEndpointTicketWithToken(grantScope, maxConnections) {
797
+ if (!(await this.canUseTauriIpc()))
798
+ return null;
799
+ try {
800
+ return await this.invokeIpc('get_endpoint_ticket_with_token', { scope: grantScope, maxConnections });
801
+ }
802
+ catch {
803
+ return null;
804
+ }
805
+ }
806
+ async validateSessionToken(token, connectionId) {
807
+ if (!(await this.canUseTauriIpc()))
808
+ return null;
809
+ try {
810
+ return await this.invokeIpc('validate_session_token', {
811
+ token,
812
+ connectionId: connectionId ?? null,
813
+ });
814
+ }
815
+ catch {
816
+ return null;
817
+ }
818
+ }
819
+ async revokeSessionTokensByScope(grantScope) {
820
+ if (!(await this.canUseTauriIpc()))
821
+ return [];
822
+ try {
823
+ return await this.invokeIpc('revoke_session_tokens_by_scope', { scope: grantScope });
824
+ }
825
+ catch {
826
+ return [];
827
+ }
828
+ }
829
+ async disconnectDevice(deviceId) {
830
+ if (!(await this.canUseTauriIpc())) {
831
+ throw new Error('[pluto-rtc] disconnectDevice requires native IPC in Tauri runtime.');
832
+ }
833
+ await this.invokeIpc('disconnect_device', { deviceId });
834
+ }
835
+ async setAutoConnectExcluded(deviceId, excluded) {
836
+ if (!(await this.canUseTauriIpc())) {
837
+ return;
838
+ }
839
+ await this.invokeIpc('set_auto_connect_excluded', { deviceId, excluded });
840
+ }
841
+ onDevicesChange(callback, excludeNodeId) {
842
+ let cancelled = false;
843
+ const devicesById = new Map();
844
+ let reader = null;
845
+ let emittedEmptySnapshot = false;
846
+ const emit = () => {
847
+ let devices = Array.from(devicesById.values());
848
+ if (excludeNodeId) {
849
+ devices = devices.filter(d => d.ticket !== excludeNodeId &&
850
+ d.deviceId !== excludeNodeId &&
851
+ d.nodeId !== excludeNodeId);
852
+ }
853
+ callback(devices);
854
+ };
855
+ const bootstrap = async () => {
856
+ if (!(await this.shouldUseTauriSignaling())) {
857
+ console.warn('[NativeIpcBridge] onDevicesChange falling back to wasm signaling (Tauri IPC unavailable)');
858
+ cleanup = this.fallback.onDevicesChange(callback, excludeNodeId);
859
+ return;
860
+ }
861
+ while (!cancelled) {
862
+ try {
863
+ const authWindowMs = 15000;
864
+ const authDeadlineMs = Date.now() + authWindowMs;
865
+ let userId = this.getCurrentUserId();
866
+ while (!cancelled && !userId && Date.now() < authDeadlineMs) {
867
+ if (!this.hasLoggedMissingAuthForDevices) {
868
+ this.hasLoggedMissingAuthForDevices = true;
869
+ openRtcDebugInfo('[NativeIpcBridge] onDevicesChange waiting for authenticated user before subscription');
870
+ }
871
+ userId = this.getCurrentUserId();
872
+ if (userId)
873
+ break;
874
+ await this.sleep(250);
875
+ }
876
+ if (!userId) {
877
+ if (!cancelled && !emittedEmptySnapshot) {
878
+ console.warn('[NativeIpcBridge] onDevicesChange could not resolve authenticated user; keeping subscription in retry mode');
879
+ callback([]);
880
+ emittedEmptySnapshot = true;
881
+ }
882
+ await this.sleep(1000);
883
+ continue;
884
+ }
885
+ const authReady = await this.ensureNativeAuth('onDevicesChange', {
886
+ requireToken: true,
887
+ maxWaitMs: Math.max(0, authDeadlineMs - Date.now()),
888
+ });
889
+ if (!authReady) {
890
+ if (!cancelled && !emittedEmptySnapshot) {
891
+ console.warn('[NativeIpcBridge] onDevicesChange timed out waiting for auth token; keeping subscription in retry mode');
892
+ callback([]);
893
+ emittedEmptySnapshot = true;
894
+ }
895
+ await this.sleep(1000);
896
+ continue;
897
+ }
898
+ this.hasLoggedMissingAuthForDevices = false;
899
+ emittedEmptySnapshot = false;
900
+ openRtcDebugInfo(`[NativeIpcBridge] onDevicesChange starting subscription user_id=${userId}`);
901
+ const initialDevices = await this.searchDevices(excludeNodeId);
902
+ devicesById.clear();
903
+ for (const device of initialDevices)
904
+ devicesById.set(device.deviceId, device);
905
+ emit();
906
+ const stream = await this.subscribeDevices(userId);
907
+ reader = stream.getReader();
908
+ while (!cancelled) {
909
+ const { done, value } = await reader.read();
910
+ if (done || !value)
911
+ break;
912
+ for (const event of value) {
913
+ if (event.type === 'removed') {
914
+ devicesById.delete(event.deviceId);
915
+ continue;
916
+ }
917
+ const normalized = this.normalizeDevice(event.device);
918
+ if (!normalized.deviceId)
919
+ continue;
920
+ if (!this.matchesTag(normalized)) {
921
+ devicesById.delete(normalized.deviceId);
922
+ continue;
923
+ }
924
+ devicesById.set(normalized.deviceId, normalized);
925
+ }
926
+ emit();
927
+ }
928
+ }
929
+ catch (error) {
930
+ if (!cancelled) {
931
+ console.error('[NativeIpcBridge] onDevicesChange stream failed; retrying subscription:', error);
932
+ await this.sleep(1000);
933
+ }
934
+ }
935
+ finally {
936
+ if (reader) {
937
+ try {
938
+ await reader.cancel();
939
+ }
940
+ catch {
941
+ // no-op
942
+ }
943
+ reader = null;
944
+ }
945
+ }
946
+ }
947
+ };
948
+ let cleanup = () => {
949
+ cancelled = true;
950
+ if (reader) {
951
+ void reader.cancel();
952
+ reader = null;
953
+ }
954
+ };
955
+ void bootstrap();
956
+ return () => cleanup();
957
+ }
958
+ async subscribeDevices(userId) {
959
+ if (await this.shouldUseTauriSignaling()) {
960
+ const authReady = await this.ensureNativeAuth('subscribeDevices', {
961
+ requireToken: true,
962
+ maxWaitMs: NativeIpcBridge.AUTH_REQUIRED_WAIT_MS,
963
+ });
964
+ if (!authReady) {
965
+ throw new Error('Native auth token unavailable for subscribeDevices');
966
+ }
967
+ const requestId = `sub-${Math.random().toString(36).substring(2, 11)}`;
968
+ let unlisten = null;
969
+ let stopSubscription = null;
970
+ let closed = false;
971
+ const closeSubscription = async () => {
972
+ if (closed) {
973
+ return;
974
+ }
975
+ closed = true;
976
+ if (unlisten) {
977
+ await Promise.resolve(unlisten());
978
+ unlisten = null;
979
+ }
980
+ if (stopSubscription) {
981
+ try {
982
+ await stopSubscription();
983
+ }
984
+ catch {
985
+ // no-op
986
+ }
987
+ stopSubscription = null;
988
+ }
989
+ };
990
+ return new ReadableStream({
991
+ start: async (controller) => {
992
+ openRtcDebugInfo(`[NativeIpcBridge] subscribeDevices request start user_id=${userId} request_id=${requestId}`);
993
+ unlisten = await this.listenIpc('rtc-device-events', (event) => {
994
+ if (closed) {
995
+ return;
996
+ }
997
+ const payload = event.payload;
998
+ if (payload.requestId === requestId) {
999
+ if (payload.type === 'deviceEvents') {
1000
+ const count = Array.isArray(payload.events) ? payload.events.length : 0;
1001
+ openRtcDebug(`[NativeIpcBridge] subscribeDevices event batch request_id=${requestId} count=${count}`);
1002
+ try {
1003
+ controller.enqueue(payload.events);
1004
+ }
1005
+ catch {
1006
+ void closeSubscription();
1007
+ }
1008
+ }
1009
+ else if (payload.type === 'error') {
1010
+ console.warn(`[NativeIpcBridge] subscribeDevices stream error request_id=${requestId}: ${payload.message}`);
1011
+ if (!closed) {
1012
+ try {
1013
+ controller.error(payload.message);
1014
+ }
1015
+ catch {
1016
+ // no-op
1017
+ }
1018
+ void closeSubscription();
1019
+ }
1020
+ }
1021
+ }
1022
+ });
1023
+ stopSubscription = async () => {
1024
+ await this.invokeIpc('stop_rtc_subscription', { requestId });
1025
+ };
1026
+ try {
1027
+ await this.invokeIpc('start_rtc_device_subscription', { requestId, userId });
1028
+ }
1029
+ catch (error) {
1030
+ try {
1031
+ controller.error(error);
1032
+ }
1033
+ catch {
1034
+ // no-op
1035
+ }
1036
+ await closeSubscription();
1037
+ }
1038
+ },
1039
+ cancel: async () => {
1040
+ openRtcDebugInfo(`[NativeIpcBridge] subscribeDevices request stop request_id=${requestId}`);
1041
+ await closeSubscription();
1042
+ }
1043
+ });
1044
+ }
1045
+ return this.fallback.subscribeDevices(userId);
1046
+ }
1047
+ async subscribeSessions(localDeviceId) {
1048
+ if (await this.shouldUseTauriSignaling()) {
1049
+ const authReady = await this.ensureNativeAuth('subscribeSessions', {
1050
+ requireToken: true,
1051
+ maxWaitMs: NativeIpcBridge.AUTH_REQUIRED_WAIT_MS,
1052
+ });
1053
+ if (!authReady) {
1054
+ throw new Error('Native auth token unavailable for subscribeSessions');
1055
+ }
1056
+ const requestId = `sub-${Math.random().toString(36).substring(2, 11)}`;
1057
+ const effectiveLocalDeviceId = (await this.resolveLocalDeviceId()) || localDeviceId;
1058
+ let unlistenSession = null;
1059
+ let stopSubscription = null;
1060
+ let closed = false;
1061
+ const closeSubscription = async () => {
1062
+ if (closed) {
1063
+ return;
1064
+ }
1065
+ closed = true;
1066
+ if (unlistenSession) {
1067
+ await Promise.resolve(unlistenSession());
1068
+ unlistenSession = null;
1069
+ }
1070
+ if (stopSubscription) {
1071
+ try {
1072
+ await stopSubscription();
1073
+ }
1074
+ catch {
1075
+ // no-op
1076
+ }
1077
+ stopSubscription = null;
1078
+ }
1079
+ };
1080
+ return new ReadableStream({
1081
+ start: async (controller) => {
1082
+ unlistenSession = await this.listenIpc('rtc-session-events', (event) => {
1083
+ if (closed) {
1084
+ return;
1085
+ }
1086
+ const payload = event.payload;
1087
+ if (payload.requestId === requestId) {
1088
+ if (payload.type === 'sessionEvents') {
1089
+ try {
1090
+ controller.enqueue(payload.events);
1091
+ }
1092
+ catch {
1093
+ void closeSubscription();
1094
+ }
1095
+ }
1096
+ else if (payload.type === 'error') {
1097
+ if (!closed) {
1098
+ try {
1099
+ controller.error(payload.message);
1100
+ }
1101
+ catch {
1102
+ // no-op
1103
+ }
1104
+ void closeSubscription();
1105
+ }
1106
+ }
1107
+ }
1108
+ });
1109
+ stopSubscription = async () => {
1110
+ await this.invokeIpc('stop_rtc_subscription', { requestId });
1111
+ };
1112
+ try {
1113
+ await this.invokeIpc('start_rtc_session_subscription', {
1114
+ requestId,
1115
+ localDeviceId: effectiveLocalDeviceId,
1116
+ });
1117
+ }
1118
+ catch (error) {
1119
+ try {
1120
+ controller.error(error);
1121
+ }
1122
+ catch {
1123
+ // no-op
1124
+ }
1125
+ await closeSubscription();
1126
+ }
1127
+ },
1128
+ cancel: async () => {
1129
+ await closeSubscription();
1130
+ }
1131
+ });
1132
+ }
1133
+ return this.fallback.subscribeSessions(localDeviceId);
1134
+ }
1135
+ async getLocalDeviceId() {
1136
+ return this.resolveLocalDeviceId();
1137
+ }
1138
+ async updatePresence(localNodeId, ticketStr, isOnline = true, ttlMs = 300000, metadata) {
1139
+ if (await this.shouldUseTauriSignaling()) {
1140
+ try {
1141
+ const authReady = await this.ensureNativeAuth('updatePresence', {
1142
+ requireToken: true,
1143
+ maxWaitMs: NativeIpcBridge.AUTH_REQUIRED_WAIT_MS,
1144
+ });
1145
+ if (!authReady) {
1146
+ throw new Error('Native auth token unavailable for updatePresence');
1147
+ }
1148
+ const userId = this.getCurrentUserId();
1149
+ const localDeviceId = await this.resolveLocalDeviceId();
1150
+ const localDeviceName = await this.resolveLocalDeviceName();
1151
+ await this.invokeIpc('update_rtc_presence', {
1152
+ userId,
1153
+ deviceName: localDeviceName,
1154
+ ticket: ticketStr,
1155
+ metadata: this.withPresenceMetadata(metadata, localDeviceId),
1156
+ });
1157
+ await this.syncNativeRtdbPresenceTicket(ticketStr);
1158
+ return;
1159
+ }
1160
+ catch (error) {
1161
+ console.warn('[NativeIpcBridge] update_rtc_presence failed:', error);
1162
+ }
1163
+ }
1164
+ return this.fallback.updatePresence(localNodeId, ticketStr, isOnline, ttlMs, metadata);
1165
+ }
1166
+ startAutoConnect(userId, localDeviceId) {
1167
+ void (async () => {
1168
+ try {
1169
+ if (!(await this.shouldUseTauriSignaling())) {
1170
+ console.error('[NativeIpcBridge] startAutoConnect requires native Rust signaling; IPC unavailable');
1171
+ return;
1172
+ }
1173
+ const effectiveLocalDeviceId = (await this.resolveLocalDeviceId()) || localDeviceId;
1174
+ await this.startAutoConnectOnce(userId, effectiveLocalDeviceId);
1175
+ }
1176
+ catch (error) {
1177
+ console.warn('[NativeIpcBridge] startAutoConnect failed:', error);
1178
+ }
1179
+ })();
1180
+ }
1181
+ forceReconnectSnapshot() {
1182
+ void (async () => {
1183
+ if (!(await this.shouldUseTauriSignaling())) {
1184
+ console.error('[NativeIpcBridge] forceReconnectSnapshot requires native Rust signaling; IPC unavailable');
1185
+ return;
1186
+ }
1187
+ try {
1188
+ await this.invokeIpc('force_rtc_reconnect_snapshot');
1189
+ }
1190
+ catch (error) {
1191
+ console.warn('[NativeIpcBridge] force_rtc_reconnect_snapshot failed:', error);
1192
+ }
1193
+ })();
1194
+ }
1195
+ startPresenceLoop(userId, localNodeId, deviceName, ticket, metadata) {
1196
+ void (async () => {
1197
+ try {
1198
+ if (!(await this.shouldUseTauriSignaling())) {
1199
+ console.warn('[NativeIpcBridge] startPresenceLoop falling back to wasm signaling (Tauri IPC unavailable)');
1200
+ this.fallback.startPresenceLoop(userId, localNodeId, deviceName, ticket, metadata);
1201
+ return;
1202
+ }
1203
+ const localDeviceId = await this.resolveLocalDeviceId();
1204
+ const resolvedDeviceName = this.shouldUseResolvedDeviceName(deviceName)
1205
+ ? await this.resolveLocalDeviceName()
1206
+ : deviceName.trim();
1207
+ const metadataWithDevice = this.withPresenceMetadata(metadata, localDeviceId);
1208
+ await this.startPresenceLoopOnce(userId, localNodeId, resolvedDeviceName, ticket, metadataWithDevice);
1209
+ await this.syncNativeRtdbPresenceTicket(ticket);
1210
+ }
1211
+ catch (error) {
1212
+ console.warn('[NativeIpcBridge] startPresenceLoop failed:', error);
1213
+ }
1214
+ })();
1215
+ }
1216
+ async startAutoConnectOnce(userId, localDeviceId) {
1217
+ // One-shot native forwarder only. Retry/dedup policy belongs in RuntimeClient
1218
+ // or the Rust core, not inside the bridge compatibility layer.
1219
+ if (!(await this.shouldUseTauriSignaling())) {
1220
+ throw new Error('[pluto-rtc] Native IPC unavailable for startAutoConnectOnce().');
1221
+ }
1222
+ const authReady = await this.ensureNativeAuth('startAutoConnect', {
1223
+ requireToken: true,
1224
+ maxWaitMs: NativeIpcBridge.AUTH_REQUIRED_WAIT_MS,
1225
+ });
1226
+ if (!authReady) {
1227
+ throw new Error('Native auth token unavailable for startAutoConnect');
1228
+ }
1229
+ await this.invokeIpc('start_rtc_auto_connect', {
1230
+ userId,
1231
+ localDeviceId,
1232
+ });
1233
+ }
1234
+ async startPresenceLoopOnce(userId, localNodeId, deviceName, ticket, metadata) {
1235
+ // One-shot native forwarder only. Retry/dedup policy belongs in RuntimeClient
1236
+ // or the Rust core, not inside the bridge compatibility layer.
1237
+ if (!(await this.shouldUseTauriSignaling())) {
1238
+ throw new Error('[pluto-rtc] Native IPC unavailable for startPresenceLoopOnce().');
1239
+ }
1240
+ const authReady = await this.ensureNativeAuth('startPresenceLoop', {
1241
+ requireToken: true,
1242
+ maxWaitMs: NativeIpcBridge.AUTH_REQUIRED_WAIT_MS,
1243
+ });
1244
+ if (!authReady) {
1245
+ throw new Error('Native auth token unavailable for startPresenceLoop');
1246
+ }
1247
+ await this.invokeIpc('start_rtc_presence_loop', {
1248
+ userId,
1249
+ localNodeId,
1250
+ deviceName,
1251
+ ticket,
1252
+ metadata,
1253
+ apiKey: this.options.apiKey ?? null,
1254
+ });
1255
+ }
1256
+ shouldUseResolvedDeviceName(deviceName) {
1257
+ const normalized = deviceName.trim();
1258
+ return normalized.length === 0 || normalized === 'Unknown Device' || normalized === 'Desktop Device';
1259
+ }
1260
+ async sendExplicitFilePath(connectionId, filePath, transferId = '') {
1261
+ openRtcDebugInfo('[NativeIpcBridge] sendExplicitFilePath start', {
1262
+ connectionId,
1263
+ filePath,
1264
+ transferId: transferId || null,
1265
+ authMode: this.options.authMode,
1266
+ });
1267
+ if (await this.canUseTauriIpc()) {
1268
+ if (this.options.authMode !== 'anonymous') {
1269
+ await this.ensureNativeAuth('sendExplicitFilePath');
1270
+ }
1271
+ else {
1272
+ openRtcDebugInfo('[NativeIpcBridge] sendExplicitFilePath using anonymous native path');
1273
+ }
1274
+ const result = await this.invokeIpc('send_file', {
1275
+ connectionId,
1276
+ filePath,
1277
+ transferId,
1278
+ });
1279
+ openRtcDebugInfo('[NativeIpcBridge] sendExplicitFilePath completed', {
1280
+ connectionId,
1281
+ transferId: transferId || null,
1282
+ result,
1283
+ });
1284
+ return result;
1285
+ }
1286
+ return this.fallback.sendExplicitFilePath(connectionId, filePath, transferId);
1287
+ }
1288
+ async sendExplicitFileData(params) {
1289
+ openRtcDebugInfo('[NativeIpcBridge] sendExplicitFileData start', {
1290
+ connectionId: params.connectionId ?? null,
1291
+ connectionRemoteNodeId: params.connection?.remoteNodeId ?? null,
1292
+ remoteNodeId: params.remoteNodeId ?? null,
1293
+ transferId: params.transferId ?? null,
1294
+ fileName: params.file.name,
1295
+ fileSize: params.file.size,
1296
+ authMode: this.options.authMode,
1297
+ });
1298
+ if (await this.canUseTauriIpc()) {
1299
+ if (this.options.authMode !== 'anonymous') {
1300
+ await this.ensureNativeAuth('sendExplicitFileData');
1301
+ }
1302
+ else {
1303
+ openRtcDebugInfo('[NativeIpcBridge] sendExplicitFileData using anonymous native path');
1304
+ }
1305
+ const connectionId = params.connectionId ||
1306
+ params.connection?.deviceId ||
1307
+ params.connection?.remoteNodeId ||
1308
+ params.remoteNodeId ||
1309
+ '';
1310
+ if (!connectionId) {
1311
+ throw new Error('sendExplicitFileData requires a settled peer identifier in native runtime');
1312
+ }
1313
+ const bytes = new Uint8Array(await params.file.arrayBuffer());
1314
+ await this.invokeIpc('send_file_data', {
1315
+ connectionId,
1316
+ filename: params.file.name,
1317
+ data: Array.from(bytes),
1318
+ transferId: params.transferId,
1319
+ });
1320
+ openRtcDebugInfo('[NativeIpcBridge] sendExplicitFileData completed', {
1321
+ connectionId,
1322
+ transferId: params.transferId ?? null,
1323
+ fileName: params.file.name,
1324
+ fileSize: params.file.size,
1325
+ });
1326
+ return;
1327
+ }
1328
+ return this.fallback.sendExplicitFileData(params);
1329
+ }
1330
+ async getConnectionStates() {
1331
+ if (await this.canUseTauriIpc()) {
1332
+ try {
1333
+ if (this.options.authMode !== 'anonymous') {
1334
+ await this.ensureNativeAuth('getConnectionStates');
1335
+ }
1336
+ const states = await this.invokeIpc('list_rtc_connection_states');
1337
+ return Array.isArray(states) ? states : [];
1338
+ }
1339
+ catch (error) {
1340
+ console.warn('[NativeIpcBridge] Failed to fetch native connection states:', error);
1341
+ return [];
1342
+ }
1343
+ }
1344
+ return [];
1345
+ }
1346
+ async onConnectionStateChange(callback) {
1347
+ let stopped = false;
1348
+ let unsubscribeInner = null;
1349
+ const startedAt = Date.now();
1350
+ let attempt = 0;
1351
+ const stopSubscription = () => {
1352
+ const unlisten = unsubscribeInner;
1353
+ if (unlisten) {
1354
+ void Promise.resolve(unlisten());
1355
+ }
1356
+ };
1357
+ while (!stopped && Date.now() - startedAt <= NativeIpcBridge.CONNECTION_EVENT_SUBSCRIBE_WINDOW_MS) {
1358
+ attempt += 1;
1359
+ if (!(await this.canUseTauriIpc())) {
1360
+ await this.sleep(this.nextConnectionEventRetryDelayMs(attempt));
1361
+ continue;
1362
+ }
1363
+ unsubscribeInner = await this.listenIpc('connection-state-changed', (event) => {
1364
+ if (!event?.payload) {
1365
+ return;
1366
+ }
1367
+ openRtcDebug('[NativeIpcBridge] connection-state-changed trigger', {
1368
+ connectionId: event.payload.connectionId,
1369
+ deviceId: event.payload.deviceId ?? null,
1370
+ deviceIdHint: event.payload.deviceIdHint ?? null,
1371
+ remoteNodeId: event.payload.remoteNodeId ?? null,
1372
+ state: event.payload.state,
1373
+ transportState: event.payload.transportState ?? null,
1374
+ protocolState: event.payload.protocolState ?? null,
1375
+ routable: event.payload.routable ?? null,
1376
+ });
1377
+ void this.getConnectionStates()
1378
+ .then((states) => {
1379
+ states.forEach((state) => callback(state));
1380
+ })
1381
+ .catch((error) => {
1382
+ console.warn('[NativeIpcBridge] Failed to refresh Rust-owned connection states after trigger:', error);
1383
+ });
1384
+ });
1385
+ try {
1386
+ const states = await this.getConnectionStates();
1387
+ if (states.length > 0) {
1388
+ openRtcDebug('[NativeIpcBridge] replaying current native connection states', states.map((state) => ({
1389
+ connectionId: state.connectionId,
1390
+ deviceId: state.deviceId ?? null,
1391
+ deviceIdHint: state.deviceIdHint ?? null,
1392
+ remoteNodeId: state.remoteNodeId ?? null,
1393
+ state: state.state,
1394
+ transportState: state.transportState ?? null,
1395
+ protocolState: state.protocolState ?? null,
1396
+ routable: state.routable ?? null,
1397
+ })));
1398
+ }
1399
+ states.forEach((state) => callback(state));
1400
+ }
1401
+ catch (error) {
1402
+ console.warn('[NativeIpcBridge] Failed to replay native connection states after event subscription:', error);
1403
+ }
1404
+ return () => {
1405
+ stopped = true;
1406
+ stopSubscription();
1407
+ };
1408
+ }
1409
+ return () => {
1410
+ stopped = true;
1411
+ stopSubscription();
1412
+ };
1413
+ }
1414
+ /**
1415
+ * Subscribe to raw messages forwarded from native iroh connections.
1416
+ * The Rust backend emits `iroh://message` Tauri events for messages that
1417
+ * arrive on the main stream of an incoming connection. This lets the TS
1418
+ * Client.handleMessage() run token validation on native-side connections.
1419
+ *
1420
+ * Payload: { connectionId: string, streamId: string, data: number[] }
1421
+ * The `data` array is the raw frame bytes; for TS-originated messages it is
1422
+ * [4-byte-len BE][1-byte-type][JSON payload].
1423
+ */
1424
+ async onIncomingNativeMessage(callback) {
1425
+ if (!(await this.canUseTauriIpc())) {
1426
+ return () => { };
1427
+ }
1428
+ const unlisten = await this.listenIpc('iroh://message', (event) => {
1429
+ if (!event?.payload?.data)
1430
+ return;
1431
+ // Only process messages from the main stream; data-channel streams
1432
+ // are handled separately and should not go through handleMessage.
1433
+ if (event.payload.streamId !== 'main')
1434
+ return;
1435
+ try {
1436
+ const bytes = new Uint8Array(event.payload.data);
1437
+ // After read_framed_message strips the 4-byte length prefix, the
1438
+ // data is: [1-byte-type=0x00][JSON payload].
1439
+ // Minimum: 2 bytes (type + at least one byte of JSON).
1440
+ if (bytes.length < 2)
1441
+ return;
1442
+ const typeId = bytes[0];
1443
+ if (typeId !== 0)
1444
+ return; // Only process user messages (type=0)
1445
+ const jsonBytes = bytes.slice(1);
1446
+ const json = new TextDecoder().decode(jsonBytes);
1447
+ const msg = JSON.parse(json);
1448
+ callback(event.payload.connectionId, event.payload.remoteNodeId ?? null, msg);
1449
+ }
1450
+ catch {
1451
+ // Malformed or non-JSON message — ignore
1452
+ }
1453
+ });
1454
+ return typeof unlisten === 'function' ? unlisten : () => { };
1455
+ }
1456
+ async getTransferHistory(limit = 50) {
1457
+ if (await this.canUseTauriIpc()) {
1458
+ return this.invokeIpc('get_transfer_history', { limit });
1459
+ }
1460
+ return [];
1461
+ }
1462
+ async deleteTransferJob(jobId) {
1463
+ if (await this.canUseTauriIpc()) {
1464
+ await this.invokeIpc('delete_transfer_job', { jobId });
1465
+ }
1466
+ }
1467
+ }
1468
+ NativeIpcBridge.AUTH_REQUIRED_WAIT_MS = 15000;
1469
+ NativeIpcBridge.CONNECTION_EVENT_SUBSCRIBE_WINDOW_MS = 15000;
1470
+ NativeIpcBridge.CONNECTION_EVENT_RETRY_MIN_DELAY_MS = 250;
1471
+ NativeIpcBridge.CONNECTION_EVENT_RETRY_MAX_DELAY_MS = 1000;
1472
+ NativeIpcBridge.TAURI_IPC_NEGATIVE_CACHE_MS = 2000;