openrtc 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -0
- package/dist/OpenRTC.d.ts +71 -0
- package/dist/OpenRTC.js +130 -0
- package/dist/api/MediaTransport.d.ts +7 -0
- package/dist/api/MediaTransport.js +262 -0
- package/dist/api/PlutoPeerConnection.d.ts +50 -0
- package/dist/api/PlutoPeerConnection.js +434 -0
- package/dist/api/PlutoWebSocket.d.ts +24 -0
- package/dist/api/PlutoWebSocket.js +113 -0
- package/dist/api/PlutoWebTransport.d.ts +29 -0
- package/dist/api/PlutoWebTransport.js +105 -0
- package/dist/auth/host.d.ts +73 -0
- package/dist/auth/host.js +513 -0
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/index.js +1 -0
- package/dist/auth/internal.d.ts +1 -0
- package/dist/auth/internal.js +1 -0
- package/dist/compat/index.d.ts +20 -0
- package/dist/compat/index.js +20 -0
- package/dist/core/Client.d.ts +352 -0
- package/dist/core/Client.js +3689 -0
- package/dist/core/Connection.d.ts +67 -0
- package/dist/core/Connection.js +404 -0
- package/dist/core/IEngineBridge.d.ts +18 -0
- package/dist/core/IEngineBridge.js +1 -0
- package/dist/core/ISignalingTransport.d.ts +13 -0
- package/dist/core/ISignalingTransport.js +1 -0
- package/dist/core/PeerLifecycleManager.d.ts +43 -0
- package/dist/core/PeerLifecycleManager.js +330 -0
- package/dist/core/PeerProjectionStore.d.ts +43 -0
- package/dist/core/PeerProjectionStore.js +327 -0
- package/dist/core/PlutoRTC.d.ts +13 -0
- package/dist/core/PlutoRTC.js +26 -0
- package/dist/core/Room.d.ts +47 -0
- package/dist/core/Room.js +376 -0
- package/dist/core/Signaling.d.ts +39 -0
- package/dist/core/Signaling.js +147 -0
- package/dist/core/bridges/BridgeCodecs.d.ts +19 -0
- package/dist/core/bridges/BridgeCodecs.js +317 -0
- package/dist/core/bridges/BrowserFirestoreRooms.d.ts +36 -0
- package/dist/core/bridges/BrowserFirestoreRooms.js +169 -0
- package/dist/core/bridges/BrowserFirestoreSignaling.d.ts +111 -0
- package/dist/core/bridges/BrowserFirestoreSignaling.js +1010 -0
- package/dist/core/bridges/NativeIpcBridge.d.ts +156 -0
- package/dist/core/bridges/NativeIpcBridge.js +1472 -0
- package/dist/core/bridges/RtdbPresence.d.ts +122 -0
- package/dist/core/bridges/RtdbPresence.js +201 -0
- package/dist/core/bridges/TauriBridge.d.ts +8 -0
- package/dist/core/bridges/TauriBridge.js +7 -0
- package/dist/core/bridges/WasmBridge.d.ts +233 -0
- package/dist/core/bridges/WasmBridge.js +1811 -0
- package/dist/core/config.d.ts +31 -0
- package/dist/core/config.js +138 -0
- package/dist/core/transports/ITransport.d.ts +12 -0
- package/dist/core/transports/ITransport.js +1 -0
- package/dist/core/transports/LocalDaemonBridge.d.ts +41 -0
- package/dist/core/transports/LocalDaemonBridge.js +227 -0
- package/dist/core/transports/TauriSignaling.d.ts +28 -0
- package/dist/core/transports/TauriSignaling.js +373 -0
- package/dist/core/transports/TransportFactory.d.ts +9 -0
- package/dist/core/transports/TransportFactory.js +59 -0
- package/dist/core/transports/WasmSignaling.d.ts +78 -0
- package/dist/core/transports/WasmSignaling.js +522 -0
- package/dist/core/transports/WebRTCTransport.d.ts +23 -0
- package/dist/core/transports/WebRTCTransport.js +132 -0
- package/dist/core/trust.d.ts +23 -0
- package/dist/core/trust.js +99 -0
- package/dist/core/types.d.ts +559 -0
- package/dist/core/types.js +19 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/moq/MoQMediaTransport.d.ts +6 -0
- package/dist/moq/MoQMediaTransport.js +106 -0
- package/dist/moq/MoQPublisher.d.ts +22 -0
- package/dist/moq/MoQPublisher.js +179 -0
- package/dist/moq/MoQSession.d.ts +35 -0
- package/dist/moq/MoQSession.js +166 -0
- package/dist/moq/MoQSubscriber.d.ts +19 -0
- package/dist/moq/MoQSubscriber.js +235 -0
- package/dist/moq/common.d.ts +47 -0
- package/dist/moq/common.js +304 -0
- package/dist/moq/messages.d.ts +105 -0
- package/dist/moq/messages.js +367 -0
- package/dist/presets/plutoHosted.d.ts +13 -0
- package/dist/presets/plutoHosted.js +18 -0
- package/dist/runtime/DelegatingRuntimeAdapter.d.ts +68 -0
- package/dist/runtime/DelegatingRuntimeAdapter.js +250 -0
- package/dist/runtime/IpcRuntimeAdapter.d.ts +9 -0
- package/dist/runtime/IpcRuntimeAdapter.js +13 -0
- package/dist/runtime/RuntimeClient.d.ts +257 -0
- package/dist/runtime/RuntimeClient.js +1484 -0
- package/dist/runtime/TauriRuntimeAdapter.d.ts +1 -0
- package/dist/runtime/TauriRuntimeAdapter.js +1 -0
- package/dist/runtime/WasmRuntimeAdapter.d.ts +6 -0
- package/dist/runtime/WasmRuntimeAdapter.js +11 -0
- package/dist/runtime/factories.d.ts +24 -0
- package/dist/runtime/factories.js +66 -0
- package/dist/runtime/index.d.ts +6 -0
- package/dist/runtime/index.js +5 -0
- package/dist/runtime/ipc.d.ts +8 -0
- package/dist/runtime/ipc.js +65 -0
- package/dist/runtime/ipcContract.d.ts +46 -0
- package/dist/runtime/ipcContract.js +1 -0
- package/dist/runtime/tauri.d.ts +16 -0
- package/dist/runtime/tauri.js +22 -0
- package/dist/runtime/types.d.ts +110 -0
- package/dist/runtime/types.js +1 -0
- package/dist/transports/TauriTransport.d.ts +17 -0
- package/dist/transports/TauriTransport.js +49 -0
- package/dist/transports/WebTransport.d.ts +23 -0
- package/dist/transports/WebTransport.js +71 -0
- package/dist/utils/debug.d.ts +4 -0
- package/dist/utils/debug.js +33 -0
- package/dist/utils/runtime.d.ts +1 -0
- package/dist/utils/runtime.js +10 -0
- package/package.json +97 -0
- package/wasm/README.md +64 -0
- package/wasm/openrtc.d.ts +284 -0
- package/wasm/openrtc.js +2132 -0
- package/wasm/openrtc_bg.wasm +0 -0
- package/wasm/openrtc_bg.wasm.d.ts +110 -0
- package/wasm/package.json +16 -0
|
@@ -0,0 +1,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;
|