@desklink/react-native 0.1.0 → 0.2.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 +58 -26
- package/android/build.gradle +1 -1
- package/android/src/main/java/expo/modules/desklink/DesklinkModule.kt +4 -0
- package/android/src/main/java/expo/modules/desklink/DesktopView.kt +61 -3
- package/package.json +4 -1
- package/src/DesktopView.ios.tsx +227 -0
- package/src/DesktopView.tsx +9 -2
- package/src/DesktopView.web.tsx +14 -2
- package/src/keyboardDelta.ts +7 -0
- package/src/native.ios.ts +186 -0
- package/src/native.ts +2 -7
- package/src/native.web.ts +86 -5
- package/src/protocol.ts +31 -8
- package/src/stickyModifiers.ts +6 -4
- package/src/useDesktopSession.ts +422 -57
package/src/useDesktopSession.ts
CHANGED
|
@@ -27,8 +27,73 @@ import {
|
|
|
27
27
|
/** How long a clipboard round trip may take before it is reported as lost. */
|
|
28
28
|
const CLIPBOARD_TIMEOUT_MS = 4000;
|
|
29
29
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
30
|
+
/**
|
|
31
|
+
* A drop heals on its own more often than not, so the first ICE restart waits
|
|
32
|
+
* out brief blips; the retries then back off. The whole schedule spans about a
|
|
33
|
+
* minute, so a 30–60 s outage still recovers when the network comes back.
|
|
34
|
+
*/
|
|
35
|
+
const RESTART_AFTER_MS = 2000;
|
|
36
|
+
export const RESTART_BACKOFF_MS = [2000, 4000, 8000, 16000, 30000];
|
|
37
|
+
/**
|
|
38
|
+
* A heartbeat missed this long means the path is stalled, even when ICE has
|
|
39
|
+
* not said so yet. Twice the engine's ping interval, with room for jitter.
|
|
40
|
+
*/
|
|
41
|
+
const STALL_AFTER_MS = 1200;
|
|
42
|
+
/**
|
|
43
|
+
* After the session itself asks for a restart, renegotiation transients are
|
|
44
|
+
* expected: ICE may flap and heartbeats may pause while the new generation
|
|
45
|
+
* comes up. These are not a new outage, so they neither flip the status nor
|
|
46
|
+
* trigger another restart while the window lasts.
|
|
47
|
+
*/
|
|
48
|
+
const RESTART_GRACE_MS = 2500;
|
|
49
|
+
/** How long a restart offer may go unanswered before the next attempt. */
|
|
50
|
+
const RESTART_REPLY_MS = 5000;
|
|
51
|
+
/**
|
|
52
|
+
* Full session reopens back off across about a minute, holding at patient
|
|
53
|
+
* 8 s retries: a return must never wait out a 32 s gap, and an attempt every
|
|
54
|
+
* 8 s costs nothing next to a live desktop.
|
|
55
|
+
*/
|
|
56
|
+
export const REOPEN_BACKOFF_MS = [1000, 2000, 4000, 8000, 8000, 8000, 8000, 8000];
|
|
57
|
+
|
|
58
|
+
/** The delay before attempt `n` (0-based), or null when the schedule is spent. */
|
|
59
|
+
export function backoffDelay(schedule: readonly number[], attempt: number): number | null {
|
|
60
|
+
return attempt < schedule.length ? schedule[attempt]! : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type ConnectionStateName =
|
|
64
|
+
| 'new'
|
|
65
|
+
| 'checking'
|
|
66
|
+
| 'connected'
|
|
67
|
+
| 'completed'
|
|
68
|
+
| 'disconnected'
|
|
69
|
+
| 'failed'
|
|
70
|
+
| 'closed';
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* What one ICE/engine-transport state means for the session status, or null
|
|
74
|
+
* when it changes nothing. `disconnected` surfaces as `reconnecting` at once —
|
|
75
|
+
* event-driven, so within about a second of the drop — and `connected` moves a
|
|
76
|
+
* session that had shown frames back to `live`. `failed` is terminal and is
|
|
77
|
+
* handled by the failure path, not here.
|
|
78
|
+
*/
|
|
79
|
+
export function connectionStatusFor(
|
|
80
|
+
state: string,
|
|
81
|
+
status: SessionSnapshot['status'],
|
|
82
|
+
presented: boolean,
|
|
83
|
+
): SessionSnapshot['status'] | null {
|
|
84
|
+
switch (state) {
|
|
85
|
+
case 'disconnected':
|
|
86
|
+
return status === 'live' || status === 'connecting' ? 'reconnecting' : null;
|
|
87
|
+
case 'connected':
|
|
88
|
+
case 'completed':
|
|
89
|
+
if (status !== 'reconnecting') return null;
|
|
90
|
+
// Frames were never shown: this is still the first connection, not
|
|
91
|
+
// a recovery, so it stays `connecting` until a frame renders.
|
|
92
|
+
return presented ? 'live' : 'connecting';
|
|
93
|
+
default:
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
32
97
|
|
|
33
98
|
/**
|
|
34
99
|
* Without a relay there is no route between two networks: ICE only ever finds
|
|
@@ -58,9 +123,14 @@ const OPEN_FAILURE_CODES: Record<string, SessionFailure['code']> = {
|
|
|
58
123
|
transport: 'transport',
|
|
59
124
|
};
|
|
60
125
|
|
|
61
|
-
function classifyOpenFailure(error: unknown): SessionFailure['code'] {
|
|
126
|
+
export function classifyOpenFailure(error: unknown): SessionFailure['code'] {
|
|
62
127
|
const code = (error as { code?: unknown } | null)?.code;
|
|
63
|
-
if (typeof code === 'string')
|
|
128
|
+
if (typeof code === 'string') {
|
|
129
|
+
// An engine that never answers — stopped, wedged, or unreachable — is
|
|
130
|
+
// a transport problem a retry may fix, not a platform verdict.
|
|
131
|
+
if (code === 'engine' || code === 'timeout') return 'transport';
|
|
132
|
+
return OPEN_FAILURE_CODES[code] ?? 'platform';
|
|
133
|
+
}
|
|
64
134
|
// No token at all: fall back to the message, treating anything that is not a
|
|
65
135
|
// permission refusal as a transport failure a retry may fix.
|
|
66
136
|
const message = error instanceof Error ? error.message : '';
|
|
@@ -125,7 +195,7 @@ export interface DesktopSession {
|
|
|
125
195
|
/** Hold the screen in landscape while the desktop is shown, or follow the phone again. */
|
|
126
196
|
setOrientation: (mode: 'landscape' | 'auto') => void;
|
|
127
197
|
send: (message: ControlMessage) => void;
|
|
128
|
-
/**
|
|
198
|
+
/** Desktop modifiers as sticky keys: off, armed for the next key, or locked. */
|
|
129
199
|
modifiers: StickyModifiers;
|
|
130
200
|
/** Tap a sticky modifier: off, then armed for the next key, then locked, then off. */
|
|
131
201
|
tapModifier: (name: StickyModifier) => void;
|
|
@@ -165,6 +235,13 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
165
235
|
const nativeRef = useRef<string | null>(null);
|
|
166
236
|
const inputEnabled = useRef(false);
|
|
167
237
|
const pendingClipboard = useRef(new Map<string, (reply: { text: string; truncated: boolean; error?: string }) => void>());
|
|
238
|
+
/**
|
|
239
|
+
* The engine's offer and candidates can beat the open result back: the
|
|
240
|
+
* subscription starts before `session.open`, so anything that arrives
|
|
241
|
+
* before the native session exists waits here and is replayed into it.
|
|
242
|
+
*/
|
|
243
|
+
const pendingOffer = useRef<{ sdp: string; sessionId?: string } | null>(null);
|
|
244
|
+
const pendingCandidates = useRef<Array<{ candidate: string; sdpMid: string | null; sdpMLineIndex: number | null; sessionId?: string }>>([]);
|
|
168
245
|
const attempts = useRef(0);
|
|
169
246
|
/**
|
|
170
247
|
* The pending reconnect attempt. It lives here rather than in the effect
|
|
@@ -172,6 +249,31 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
172
249
|
* its dependencies, so React would run its cleanup and cancel the retry.
|
|
173
250
|
*/
|
|
174
251
|
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
252
|
+
/** The pending ICE-restart attempt; cancelled on recovery or failure. */
|
|
253
|
+
const restartTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
254
|
+
/** The pending stall watchdog, armed by heartbeats. */
|
|
255
|
+
const stallTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
256
|
+
/** When the last heartbeat arrived, or 0 before the first one. */
|
|
257
|
+
const lastPingAt = useRef(0);
|
|
258
|
+
/** Renegotiation transients are not a new outage before this time. */
|
|
259
|
+
const restartGraceUntil = useRef(0);
|
|
260
|
+
/** ICE-restart attempts spent on the current drop. */
|
|
261
|
+
const restartAttempts = useRef(0);
|
|
262
|
+
/**
|
|
263
|
+
* Successful restarts since the last heartbeat: when ICE is up but the
|
|
264
|
+
* path stays quiet, enough of these in a row means the session itself is
|
|
265
|
+
* wedged and only a reopen heals it.
|
|
266
|
+
*/
|
|
267
|
+
const restartCycles = useRef(0);
|
|
268
|
+
/** The last ICE state the peer or the engine reported. */
|
|
269
|
+
const iceConnected = useRef(false);
|
|
270
|
+
/** The engine's restore token, for a consent-free reopen. Unset by hand. */
|
|
271
|
+
const restoreToken = useRef<string | null>(null);
|
|
272
|
+
/** The latest status and presentation, read by event handlers. */
|
|
273
|
+
const statusRef = useRef<SessionSnapshot['status']>('idle');
|
|
274
|
+
const presentedRef = useRef(false);
|
|
275
|
+
/** The latest snapshot; mirrored eagerly so timers read exact state. */
|
|
276
|
+
const snapshotRef = useRef<SessionSnapshot>(IDLE);
|
|
175
277
|
const diagnostics = useRef<Record<string, string | number | boolean>>({});
|
|
176
278
|
/** Guards every asynchronous callback against a session that already ended. */
|
|
177
279
|
const generationToken = useRef(0);
|
|
@@ -179,11 +281,15 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
179
281
|
optionsRef.current = options;
|
|
180
282
|
|
|
181
283
|
const update = useCallback((patch: Partial<SessionSnapshot>) => {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
284
|
+
// Mirrored eagerly: timers read these between renders, and React may
|
|
285
|
+
// defer the render itself. (Calling onStateChange here also keeps it
|
|
286
|
+
// to one call per update, where the updater form could double-fire.)
|
|
287
|
+
const next = { ...snapshotRef.current, ...patch };
|
|
288
|
+
snapshotRef.current = next;
|
|
289
|
+
statusRef.current = next.status;
|
|
290
|
+
presentedRef.current = next.presented;
|
|
291
|
+
setSnapshot(next);
|
|
292
|
+
optionsRef.current.onStateChange?.(next);
|
|
187
293
|
}, []);
|
|
188
294
|
|
|
189
295
|
const fail = useCallback((failure: SessionFailure) => {
|
|
@@ -195,6 +301,177 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
195
301
|
fail({ code, message });
|
|
196
302
|
}, [fail]);
|
|
197
303
|
|
|
304
|
+
/** A transport loss is one event no matter how many sides report it: the
|
|
305
|
+
* peer's `failed` state and its `failure` event would otherwise spend the
|
|
306
|
+
* reopen budget twice for the same outage. */
|
|
307
|
+
const transportFailed = useCallback((message: string = UNREACHABLE_DESKTOP) => {
|
|
308
|
+
if (statusRef.current === 'failed') return;
|
|
309
|
+
refuse(message, 'transport');
|
|
310
|
+
}, [refuse]);
|
|
311
|
+
|
|
312
|
+
const cancelRestart = useCallback(() => {
|
|
313
|
+
if (restartTimer.current !== null) clearTimeout(restartTimer.current);
|
|
314
|
+
restartTimer.current = null;
|
|
315
|
+
}, []);
|
|
316
|
+
|
|
317
|
+
const cancelReconnect = useCallback(() => {
|
|
318
|
+
if (reconnectTimer.current !== null) clearTimeout(reconnectTimer.current);
|
|
319
|
+
reconnectTimer.current = null;
|
|
320
|
+
}, []);
|
|
321
|
+
|
|
322
|
+
const cancelStall = useCallback(() => {
|
|
323
|
+
if (stallTimer.current !== null) clearTimeout(stallTimer.current);
|
|
324
|
+
stallTimer.current = null;
|
|
325
|
+
}, []);
|
|
326
|
+
|
|
327
|
+
const scheduleRestartRef = useRef<(delay: number) => void>(() => undefined);
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Spend one restart from the backoff schedule, or stop asking when the
|
|
331
|
+
* schedule is spent: ICE's own failure then moves the session to a full
|
|
332
|
+
* reopen. Only refs and module constants, so stable callbacks may hold it.
|
|
333
|
+
*/
|
|
334
|
+
const spendRestartAttempt = useCallback((): void => {
|
|
335
|
+
if (statusRef.current !== 'reconnecting' || nativeRef.current == null) return;
|
|
336
|
+
const wait = backoffDelay(RESTART_BACKOFF_MS, restartAttempts.current);
|
|
337
|
+
if (wait === null) return;
|
|
338
|
+
restartAttempts.current += 1;
|
|
339
|
+
scheduleRestartRef.current(wait);
|
|
340
|
+
}, []);
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Queue the next ICE restart after `delay`, or do nothing when an attempt
|
|
344
|
+
* is already queued or the session is gone. The status is validated when
|
|
345
|
+
* the timer fires rather than here: the caller just moved the session to
|
|
346
|
+
* `reconnecting` and that update has not been processed yet.
|
|
347
|
+
*
|
|
348
|
+
* A restart asks the engine to re-offer on the same peer connection; the
|
|
349
|
+
* offer arrives as the usual description event and is answered as usual.
|
|
350
|
+
* The session never reopens for it.
|
|
351
|
+
*/
|
|
352
|
+
const scheduleRestart = useCallback((delay: number) => {
|
|
353
|
+
if (restartTimer.current !== null) return;
|
|
354
|
+
if (nativeRef.current == null) return;
|
|
355
|
+
restartTimer.current = setTimeout(() => {
|
|
356
|
+
restartTimer.current = null;
|
|
357
|
+
if (statusRef.current !== 'reconnecting') return;
|
|
358
|
+
const openedRef = opened.current;
|
|
359
|
+
const channel = signaling.current;
|
|
360
|
+
if (openedRef == null || channel == null) {
|
|
361
|
+
spendRestartAttempt();
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
void (async () => {
|
|
365
|
+
try {
|
|
366
|
+
await channel.request('session.restart_ice', {
|
|
367
|
+
session_id: openedRef.sessionId,
|
|
368
|
+
generation: openedRef.generation,
|
|
369
|
+
});
|
|
370
|
+
} catch {
|
|
371
|
+
spendRestartAttempt();
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (statusRef.current !== 'reconnecting') return;
|
|
375
|
+
restartGraceUntil.current = Date.now() + RESTART_GRACE_MS;
|
|
376
|
+
restartCycles.current += 1;
|
|
377
|
+
// Media is up but the path stays quiet after clean restarts:
|
|
378
|
+
// the session itself is wedged and only a reopen heals it.
|
|
379
|
+
// (While ICE is down, patience: the network may still come
|
|
380
|
+
// back, and the backoff schedule owns that case.)
|
|
381
|
+
if (restartCycles.current >= 2 && iceConnected.current) {
|
|
382
|
+
transportFailed('the desktop stopped responding; reopening the session');
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
// The engine re-offers through the usual description event,
|
|
386
|
+
// which the session answers as usual; without recovery by the
|
|
387
|
+
// reply deadline, spend the next attempt. A live transport
|
|
388
|
+
// answers in milliseconds, so hold it to a short leash.
|
|
389
|
+
const replyMs = iceConnected.current ? 2000 : RESTART_REPLY_MS;
|
|
390
|
+
restartTimer.current = setTimeout(() => {
|
|
391
|
+
restartTimer.current = null;
|
|
392
|
+
spendRestartAttempt();
|
|
393
|
+
}, replyMs);
|
|
394
|
+
})();
|
|
395
|
+
}, delay);
|
|
396
|
+
}, [spendRestartAttempt, transportFailed]);
|
|
397
|
+
|
|
398
|
+
useEffect(() => {
|
|
399
|
+
scheduleRestartRef.current = scheduleRestart;
|
|
400
|
+
checkStallRef.current = checkStall;
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
const checkStallRef = useRef(() => {});
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* One ICE or engine-transport state, from either reporter. The mapping is
|
|
407
|
+
* idempotent, so the peer's event and the engine's `session.state` telling
|
|
408
|
+
* the same story neither double-counts nor fights.
|
|
409
|
+
*/
|
|
410
|
+
const onTransportState = useCallback((state: string, trackIce = true) => {
|
|
411
|
+
// Synthetic stall reports say nothing about ICE itself.
|
|
412
|
+
if (trackIce) iceConnected.current = state === 'connected' || state === 'completed';
|
|
413
|
+
const next = connectionStatusFor(state, statusRef.current, presentedRef.current);
|
|
414
|
+
if (next === null) return;
|
|
415
|
+
// Our own restart's transients are expected, not a new outage.
|
|
416
|
+
if (next === 'reconnecting' && Date.now() < restartGraceUntil.current) return;
|
|
417
|
+
update({ status: next });
|
|
418
|
+
if (next === 'reconnecting') {
|
|
419
|
+
// Only a path that once carried frames is worth restarting: an
|
|
420
|
+
// initial connection that never got there has nothing to save.
|
|
421
|
+
if (presentedRef.current) {
|
|
422
|
+
restartAttempts.current = 0;
|
|
423
|
+
scheduleRestart(RESTART_AFTER_MS);
|
|
424
|
+
}
|
|
425
|
+
} else {
|
|
426
|
+
cancelRestart();
|
|
427
|
+
if (next === 'live') {
|
|
428
|
+
// The path is back but may be quiet: a missing heartbeat
|
|
429
|
+
// from here is a stalled session, not a healthy idle one.
|
|
430
|
+
cancelStall();
|
|
431
|
+
stallTimer.current = setTimeout(() => checkStallRef.current(), STALL_AFTER_MS);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}, [cancelRestart, cancelStall, scheduleRestart, update]);
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* One stall check: quiet too long while the session should be talking
|
|
438
|
+
* means the path is stalled, even when ICE has not said so yet. Only
|
|
439
|
+
* refs and module constants, so stable callbacks may hold it.
|
|
440
|
+
*/
|
|
441
|
+
const checkStall = useCallback((): void => {
|
|
442
|
+
stallTimer.current = null;
|
|
443
|
+
if (lastPingAt.current === 0) return;
|
|
444
|
+
// A timer that fires a shade early re-arms for the remainder rather
|
|
445
|
+
// than dying: a one-shot watchdog must not miss by milliseconds.
|
|
446
|
+
const quietMs = Date.now() - lastPingAt.current;
|
|
447
|
+
if (quietMs < STALL_AFTER_MS) {
|
|
448
|
+
stallTimer.current = setTimeout(() => checkStallRef.current(), STALL_AFTER_MS - quietMs);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const status = statusRef.current;
|
|
452
|
+
if (status !== 'live' && status !== 'connecting' && status !== 'reconnecting') return;
|
|
453
|
+
if (Date.now() < restartGraceUntil.current) {
|
|
454
|
+
// Our own restart's transients: recheck when the grace ends.
|
|
455
|
+
stallTimer.current = setTimeout(() => checkStallRef.current(), restartGraceUntil.current - Date.now());
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
onTransportState('disconnected', false);
|
|
459
|
+
}, [onTransportState]);
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* One engine heartbeat: the path works. It clears a stall the watchdog
|
|
463
|
+
* saw, then rearms the watchdog — which stays disarmed on engines that
|
|
464
|
+
* predate heartbeats, since `lastPingAt` never leaves 0 for them.
|
|
465
|
+
*/
|
|
466
|
+
const notePing = useCallback(() => {
|
|
467
|
+
lastPingAt.current = Date.now();
|
|
468
|
+
restartCycles.current = 0;
|
|
469
|
+
cancelStall();
|
|
470
|
+
// A heartbeat is the engine alive and the path working.
|
|
471
|
+
if (statusRef.current === 'reconnecting') onTransportState('connected');
|
|
472
|
+
stallTimer.current = setTimeout(checkStall, STALL_AFTER_MS);
|
|
473
|
+
}, [cancelStall, checkStall, onTransportState]);
|
|
474
|
+
|
|
198
475
|
const send = useCallback((message: ControlMessage) => {
|
|
199
476
|
const id = nativeRef.current;
|
|
200
477
|
if (id == null || nativeDesklink === null || (!inputEnabled.current && message.kind !== 'release_all')) return;
|
|
@@ -218,7 +495,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
218
495
|
|
|
219
496
|
const applyModifiers = useCallback((next: StickyModifiers) => {
|
|
220
497
|
const current = modifiersRef.current;
|
|
221
|
-
if (next.Control === current.Control && next.Shift === current.Shift) return;
|
|
498
|
+
if (next.Control === current.Control && next.Shift === current.Shift && next.Alt === current.Alt && next.Meta === current.Meta) return;
|
|
222
499
|
modifiersRef.current = next;
|
|
223
500
|
setModifiers(next);
|
|
224
501
|
// The phone's keyboard is the session's own only while a modifier waits
|
|
@@ -276,6 +553,10 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
276
553
|
inputEnabled.current = false;
|
|
277
554
|
for (const resolve of pendingClipboard.current.values()) resolve({ text: '', truncated: false, error: 'the session ended' });
|
|
278
555
|
pendingClipboard.current.clear();
|
|
556
|
+
pendingOffer.current = null;
|
|
557
|
+
pendingCandidates.current = [];
|
|
558
|
+
restartGraceUntil.current = 0;
|
|
559
|
+
restartCycles.current = 0;
|
|
279
560
|
opened.current = null;
|
|
280
561
|
signaling.current = null;
|
|
281
562
|
setNativeId(null);
|
|
@@ -302,15 +583,19 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
302
583
|
}, []);
|
|
303
584
|
|
|
304
585
|
const teardown = useCallback(async (reason: string, closeRemote: boolean) => {
|
|
586
|
+
cancelReconnect();
|
|
587
|
+
cancelRestart();
|
|
588
|
+
cancelStall();
|
|
589
|
+
restoreToken.current = null;
|
|
305
590
|
const openedRef = opened.current;
|
|
306
591
|
const owner = signaling.current;
|
|
307
592
|
discardSession();
|
|
308
593
|
if (closeRemote) await endRemote(openedRef, owner);
|
|
309
594
|
update({ status: 'ended', presented: false, failure: null });
|
|
310
595
|
void reason;
|
|
311
|
-
}, [endRemote, discardSession, update]);
|
|
596
|
+
}, [cancelReconnect, cancelRestart, cancelStall, endRemote, discardSession, update]);
|
|
312
597
|
|
|
313
|
-
const
|
|
598
|
+
const establish = useCallback(async () => {
|
|
314
599
|
if (nativeRef.current != null) return;
|
|
315
600
|
const token = ++generationToken.current;
|
|
316
601
|
if (!(await loadPlatform())) {
|
|
@@ -330,6 +615,69 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
330
615
|
if (token !== generationToken.current) return;
|
|
331
616
|
signaling.current = authorization.signaling;
|
|
332
617
|
|
|
618
|
+
// Subscribed before the open, because the engine's offer and its first
|
|
619
|
+
// candidates can beat the open result back. Anything that arrives
|
|
620
|
+
// before the native session exists is stashed and replayed into it.
|
|
621
|
+
authorization.signaling.subscribe((event) => {
|
|
622
|
+
if (token !== generationToken.current) return;
|
|
623
|
+
const native = nativeRef.current;
|
|
624
|
+
const held = opened.current;
|
|
625
|
+
const current = event.sessionId === undefined || held == null || held.sessionId === event.sessionId;
|
|
626
|
+
switch (event.kind) {
|
|
627
|
+
case 'description':
|
|
628
|
+
if (event.description.type !== 'offer') return;
|
|
629
|
+
if (native == null || held == null) {
|
|
630
|
+
pendingOffer.current = { sdp: event.description.sdp, sessionId: event.sessionId };
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
if (!current) return;
|
|
634
|
+
// A mid-session re-offer is a restart this session asked
|
|
635
|
+
// for, through its policy or directly: renegotiation
|
|
636
|
+
// transients after it are expected, not a new outage.
|
|
637
|
+
if (presentedRef.current) restartGraceUntil.current = Date.now() + RESTART_GRACE_MS;
|
|
638
|
+
nativeDesklink?.setRemoteDescription(native, 'offer', event.description.sdp);
|
|
639
|
+
return;
|
|
640
|
+
case 'candidate':
|
|
641
|
+
if (native == null || held == null) {
|
|
642
|
+
pendingCandidates.current.push({
|
|
643
|
+
candidate: event.candidate.candidate,
|
|
644
|
+
sdpMid: event.candidate.sdpMid ?? null,
|
|
645
|
+
sdpMLineIndex: event.candidate.sdpMLineIndex ?? null,
|
|
646
|
+
sessionId: event.sessionId,
|
|
647
|
+
});
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
if (!current) return;
|
|
651
|
+
nativeDesklink?.addRemoteCandidate(
|
|
652
|
+
native,
|
|
653
|
+
event.candidate.candidate,
|
|
654
|
+
event.candidate.sdpMid ?? null,
|
|
655
|
+
event.candidate.sdpMLineIndex ?? null,
|
|
656
|
+
);
|
|
657
|
+
return;
|
|
658
|
+
case 'state':
|
|
659
|
+
if (!current) return;
|
|
660
|
+
diagnostics.current.transport = event.transport;
|
|
661
|
+
// Before the session exists there is nothing to map onto.
|
|
662
|
+
if (held != null) onTransportState(String(event.transport ?? '').toLowerCase());
|
|
663
|
+
update({ diagnostics: { ...diagnostics.current } });
|
|
664
|
+
return;
|
|
665
|
+
case 'restoreToken':
|
|
666
|
+
// The live session's grant for a consent-free reopen.
|
|
667
|
+
if (current && held != null) restoreToken.current = event.token;
|
|
668
|
+
return;
|
|
669
|
+
case 'revoked':
|
|
670
|
+
// Nothing held: the reopen policy owns recovery, and a
|
|
671
|
+
// dead session's late revocation must not end it.
|
|
672
|
+
if (held == null || !current) return;
|
|
673
|
+
void teardown(event.reason, false);
|
|
674
|
+
update({ status: 'ended', failure: { code: 'revoked', message: event.reason } });
|
|
675
|
+
return;
|
|
676
|
+
default:
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
|
|
333
681
|
let openedResult: SessionOpenResult;
|
|
334
682
|
try {
|
|
335
683
|
openedResult = await authorization.signaling.request<SessionOpenResult>('session.open', {
|
|
@@ -339,7 +687,9 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
339
687
|
bitrate_kbps: authorization.session.bitrateKbps,
|
|
340
688
|
max_fps: authorization.session.maxFps,
|
|
341
689
|
ice_servers: serializeIceServers(authorization.session.iceServers),
|
|
342
|
-
|
|
690
|
+
// A restore token the live session earned buys a consent-free
|
|
691
|
+
// reopen; the app's own grant wins when it names one.
|
|
692
|
+
restore_token: authorization.session.restoreToken ?? restoreToken.current ?? undefined,
|
|
343
693
|
ttl_seconds: authorization.session.ttlSeconds,
|
|
344
694
|
});
|
|
345
695
|
} catch (error) {
|
|
@@ -370,35 +720,20 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
370
720
|
platform?.setInputEnabled(id, inputEnabled.current);
|
|
371
721
|
setNativeId(id);
|
|
372
722
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
event.candidate.sdpMLineIndex ?? null,
|
|
388
|
-
);
|
|
389
|
-
return;
|
|
390
|
-
case 'state':
|
|
391
|
-
diagnostics.current.transport = event.transport;
|
|
392
|
-
update({ diagnostics: { ...diagnostics.current } });
|
|
393
|
-
return;
|
|
394
|
-
case 'revoked':
|
|
395
|
-
void teardown(event.reason, false);
|
|
396
|
-
update({ status: 'ended', failure: { code: 'revoked', message: event.reason } });
|
|
397
|
-
return;
|
|
398
|
-
default:
|
|
399
|
-
return;
|
|
400
|
-
}
|
|
401
|
-
});
|
|
723
|
+
// Anything the engine sent before the native session existed.
|
|
724
|
+
// A stashed message naming another session is a previous
|
|
725
|
+
// generation's leftover and is dropped, not replayed.
|
|
726
|
+
const stashedOffer = pendingOffer.current;
|
|
727
|
+
pendingOffer.current = null;
|
|
728
|
+
if (stashedOffer !== null
|
|
729
|
+
&& (stashedOffer.sessionId === undefined || stashedOffer.sessionId === openedResult.sessionId)) {
|
|
730
|
+
platform?.setRemoteDescription(id, 'offer', stashedOffer.sdp);
|
|
731
|
+
}
|
|
732
|
+
for (const candidate of pendingCandidates.current.splice(0)) {
|
|
733
|
+
if (candidate.sessionId !== undefined && candidate.sessionId !== openedResult.sessionId) continue;
|
|
734
|
+
platform?.addRemoteCandidate(id, candidate.candidate, candidate.sdpMid, candidate.sdpMLineIndex);
|
|
735
|
+
}
|
|
736
|
+
|
|
402
737
|
if (token === generationToken.current) established = true;
|
|
403
738
|
} catch (error) {
|
|
404
739
|
if (token === generationToken.current) {
|
|
@@ -410,7 +745,20 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
410
745
|
await endRemote(openedResult, authorization.signaling);
|
|
411
746
|
}
|
|
412
747
|
}
|
|
413
|
-
}, [discardSession, endRemote, refuse, teardown, update]);
|
|
748
|
+
}, [discardSession, endRemote, refuse, teardown, update, onTransportState]);
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Open (or reopen) the session with fresh authority. A deliberate call
|
|
752
|
+
* starts a fresh reopen budget; the automatic retry calls `establish`
|
|
753
|
+
* directly so its budget survives.
|
|
754
|
+
*/
|
|
755
|
+
const connect = useCallback(async () => {
|
|
756
|
+
attempts.current = 0;
|
|
757
|
+
restartAttempts.current = 0;
|
|
758
|
+
restartCycles.current = 0;
|
|
759
|
+
cancelRestart();
|
|
760
|
+
await establish();
|
|
761
|
+
}, [cancelRestart, establish]);
|
|
414
762
|
|
|
415
763
|
// Native events: answer, candidates, control replies, presentation, failure.
|
|
416
764
|
useEffect(() => {
|
|
@@ -455,11 +803,18 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
455
803
|
return;
|
|
456
804
|
case 'presented':
|
|
457
805
|
attempts.current = 0;
|
|
806
|
+
restartAttempts.current = 0;
|
|
807
|
+
restartCycles.current = 0;
|
|
808
|
+
cancelRestart();
|
|
458
809
|
update({ status: 'live', presented: true, failure: null });
|
|
459
810
|
return;
|
|
460
811
|
case 'control': {
|
|
461
812
|
const reply = parseControlReply(String(event.payload.message ?? ''));
|
|
462
813
|
if (reply == null) return;
|
|
814
|
+
if (reply.kind === 'ping') {
|
|
815
|
+
notePing();
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
463
818
|
if (reply.kind === 'hello') {
|
|
464
819
|
diagnostics.current.protocol = reply.protocol;
|
|
465
820
|
if (reply.protocol !== PROTOCOL_VERSION) {
|
|
@@ -496,13 +851,21 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
496
851
|
}
|
|
497
852
|
return;
|
|
498
853
|
}
|
|
854
|
+
case 'ice': {
|
|
855
|
+
// The peer's connection state, as it happens: Android
|
|
856
|
+
// and iOS already emit this, and web matches them.
|
|
857
|
+
const state = String(event.payload.state ?? '').toLowerCase();
|
|
858
|
+
if (state === 'failed') transportFailed();
|
|
859
|
+
else onTransportState(state);
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
499
862
|
case 'keyboard':
|
|
500
863
|
// Typing the platform held back for an armed modifier.
|
|
501
864
|
if (typeof event.payload.key === 'string') pressKey(event.payload.key)();
|
|
502
865
|
else if (typeof event.payload.text === 'string') typeText(event.payload.text);
|
|
503
866
|
return;
|
|
504
867
|
case 'failure':
|
|
505
|
-
|
|
868
|
+
transportFailed();
|
|
506
869
|
return;
|
|
507
870
|
case 'closed':
|
|
508
871
|
return;
|
|
@@ -516,7 +879,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
516
879
|
dropped = true;
|
|
517
880
|
subscription?.remove();
|
|
518
881
|
};
|
|
519
|
-
}, [
|
|
882
|
+
}, [transportFailed, onTransportState, cancelRestart, spendRestartAttempt, notePing, teardown, update, pressKey, typeText]);
|
|
520
883
|
|
|
521
884
|
const copyRemoteToLocal = useCallback(async () => {
|
|
522
885
|
if (!inputEnabled.current) throw new Error('Desktop control is off.');
|
|
@@ -551,19 +914,18 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
551
914
|
if (reply.error != null) throw new Error(reply.error);
|
|
552
915
|
}, []);
|
|
553
916
|
|
|
554
|
-
const cancelReconnect = useCallback(() => {
|
|
555
|
-
if (reconnectTimer.current !== null) clearTimeout(reconnectTimer.current);
|
|
556
|
-
reconnectTimer.current = null;
|
|
557
|
-
}, []);
|
|
558
|
-
|
|
559
917
|
const close = useCallback(async (reason = 'closed by the user') => {
|
|
560
918
|
cancelReconnect();
|
|
561
919
|
attempts.current = 0;
|
|
920
|
+
restartAttempts.current = 0;
|
|
921
|
+
restartCycles.current = 0;
|
|
562
922
|
await teardown(reason, true);
|
|
563
923
|
}, [cancelReconnect, teardown]);
|
|
564
924
|
|
|
565
|
-
//
|
|
566
|
-
// that was revoked must not be reopened
|
|
925
|
+
// Reopen with fresh authority after a transport failure, backing off
|
|
926
|
+
// across about a minute: a session that was revoked must not be reopened
|
|
927
|
+
// on the strength of the old grant, and an outage that outlasts one
|
|
928
|
+
// attempt must not spend the whole budget while the network is still down.
|
|
567
929
|
useEffect(() => {
|
|
568
930
|
if (snapshot.status !== 'failed') return;
|
|
569
931
|
if (snapshot.failure?.code !== 'transport') return;
|
|
@@ -575,17 +937,20 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
575
937
|
const openedRef = opened.current;
|
|
576
938
|
discardSession();
|
|
577
939
|
void endRemote(openedRef, owner);
|
|
578
|
-
|
|
940
|
+
const wait = backoffDelay(REOPEN_BACKOFF_MS, attempts.current);
|
|
941
|
+
if (wait === null) return;
|
|
579
942
|
attempts.current += 1;
|
|
580
943
|
update({ status: 'reconnecting' });
|
|
581
944
|
reconnectTimer.current = setTimeout(() => {
|
|
582
945
|
reconnectTimer.current = null;
|
|
583
|
-
void
|
|
584
|
-
},
|
|
585
|
-
}, [
|
|
946
|
+
void establish();
|
|
947
|
+
}, wait);
|
|
948
|
+
}, [establish, discardSession, endRemote, snapshot.status, snapshot.failure, update]);
|
|
586
949
|
|
|
587
950
|
useEffect(() => () => {
|
|
588
951
|
cancelReconnect();
|
|
952
|
+
cancelRestart();
|
|
953
|
+
cancelStall();
|
|
589
954
|
generationToken.current += 1;
|
|
590
955
|
const id = nativeRef.current;
|
|
591
956
|
nativeRef.current = null;
|