@desklink/react-native 0.5.1 → 0.5.3
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 +47 -11
- package/android/src/main/java/expo/modules/desklink/DesklinkModule.kt +4 -0
- package/android/src/main/java/expo/modules/desklink/DesktopView.kt +18 -3
- package/app.plugin.js +224 -1
- package/package.json +1 -1
- package/src/DesktopView.ios.spec.tsx +4 -3
- package/src/DesktopView.ios.tsx +45 -7
- package/src/DesktopView.tsx +8 -2
- package/src/native.web.ts +9 -0
- package/src/protocol.ts +13 -1
- package/src/useDesktopSession.ts +187 -18
package/README.md
CHANGED
|
@@ -127,6 +127,24 @@ the iOS receiver asks the engine for H.264, which iOS decodes in hardware
|
|
|
127
127
|
iOS decodes in software — so a 1440p or 4K picture on such a host costs CPU,
|
|
128
128
|
heat and battery.
|
|
129
129
|
|
|
130
|
+
### Paths with no UDP route
|
|
131
|
+
|
|
132
|
+
`session.loopbackTcp` asks the engine to also offer ICE over TCP on its own
|
|
133
|
+
`127.0.0.1`, for a client whose only way to the computer is a forward of that
|
|
134
|
+
loopback — an SSH tunnel, `adb reverse`:
|
|
135
|
+
|
|
136
|
+
```tsx
|
|
137
|
+
session: { loopbackTcp: true },
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
It is off by default, like the wire field it sets. A client with a UDP path
|
|
141
|
+
keeps it: ICE prefers UDP whenever it has one. The engine picks that port per
|
|
142
|
+
session, so the forward has to follow it (`adb reverse tcp:<port> tcp:<port>`,
|
|
143
|
+
`ssh -L <port>:127.0.0.1:<port> <computer>`) with `<port>` from the
|
|
144
|
+
`session.candidate` the engine offers. Without it a connection that never
|
|
145
|
+
forms fails after 20 seconds with that reason, rather than sitting on an empty
|
|
146
|
+
view.
|
|
147
|
+
|
|
130
148
|
### What the package guarantees
|
|
131
149
|
|
|
132
150
|
- **Hardware decoding where the platform has it.** Android and web decode the
|
|
@@ -138,7 +156,8 @@ heat and battery.
|
|
|
138
156
|
VP9. Neither platform links a second WebRTC stack.
|
|
139
157
|
- **A sharp, zoomable picture.** In portrait the default is fit-width;
|
|
140
158
|
in landscape it covers the screen below the status bar (as far as the 2.5
|
|
141
|
-
zoom limit allows). The
|
|
159
|
+
zoom limit allows). The `cover` prop opts portrait into the same
|
|
160
|
+
coverage, cropping the overflowing sides instead of letterboxing. The picture stays undistorted. One finger pans cropped
|
|
142
161
|
content; pinch to zoom before panning when the whole desktop already fits.
|
|
143
162
|
Pass the screen's safe-area `insets` (for example from
|
|
144
163
|
`react-native-safe-area-context`) so the default starts below the status bar
|
|
@@ -164,8 +183,10 @@ heat and battery.
|
|
|
164
183
|
touched shows the finger itself.
|
|
165
184
|
- **The picture above the keyboard.** While the phone's keyboard is up, the
|
|
166
185
|
picture sits above it and above the room the app keeps for its own controls
|
|
167
|
-
(`keyboardClearance`).
|
|
168
|
-
|
|
186
|
+
(`keyboardClearance`). Only the part of the view the keyboard really covers
|
|
187
|
+
counts, and a picture taller than what is left moves so the pointer, where
|
|
188
|
+
a tap just put the caret, stays in sight. Android also follows the keyboard
|
|
189
|
+
animation frame by frame; iOS moves once, as the keyboard shows or hides.
|
|
169
190
|
- **Contained geometry.** Touch maps through the picture's actual placement.
|
|
170
191
|
On the whole desktop, a one-finger drag starting in the letterbox does not move the pointer; a
|
|
171
192
|
second finger on the picture can still start a pinch or scroll. A drag that
|
|
@@ -210,33 +231,39 @@ heat and battery.
|
|
|
210
231
|
instead of sitting at 44 pt. An app may draw its own keys
|
|
211
232
|
from `modifiers` instead.
|
|
212
233
|
- **Readiness is a rendered frame.** Android marks the first draw; iOS marks
|
|
213
|
-
the first `RTCView` video-dimensions callback and ignores later ones
|
|
214
|
-
|
|
215
|
-
a
|
|
234
|
+
the first `RTCView` video-dimensions callback and ignores later ones; web
|
|
235
|
+
marks the first presented frame and ignores later repaints, so a resize,
|
|
236
|
+
remount, or renegotiation repaint during a reconnect does not read as live.
|
|
237
|
+
No platform marks a track or ICE connection as live.
|
|
216
238
|
- **Connection states, as they happen.** The session status is `idle`,
|
|
217
239
|
`opening`, `connecting`, `live`, `reconnecting`, `ended` or `failed`. The
|
|
218
240
|
peer's ICE state and the engine's `session.state` both feed it: a
|
|
219
241
|
`disconnected` path reads as `reconnecting` within about a second of the
|
|
220
242
|
drop — never a frozen picture that still says live — and a recovered path
|
|
221
|
-
reads as `live` again once
|
|
243
|
+
reads as `live` again once the picture is advancing again (ICE `connected`
|
|
244
|
+
alone does not restore `live` while the picture is frozen). A path that reports
|
|
222
245
|
`failed` reopens the session with fresh authority instead of staying stuck.
|
|
223
246
|
Every event carries its engine session id where the carrier preserves it,
|
|
224
247
|
and the session ignores anything naming a session it no longer holds, so a
|
|
225
248
|
previous generation's queued offer or revocation cannot corrupt or kill a
|
|
226
249
|
healthy recovery.
|
|
227
|
-
Detection is
|
|
250
|
+
Detection is three-layered: ICE and engine `session.state` events, the
|
|
228
251
|
engine's twice-a-second control-channel heartbeat (`{"kind":"ping"}`) — a
|
|
229
252
|
path that stops heartbeating reads as stalled within ~1.5 s even while ICE
|
|
230
253
|
consent timers are still making up their minds, and resumed heartbeats read
|
|
231
|
-
as recovered
|
|
232
|
-
|
|
254
|
+
as recovered — and a half-second poll of the receiver's own video counters
|
|
255
|
+
while a picture is shown: a picture stopped for ~2 s reads as not `live`
|
|
256
|
+
even while signalling, ICE and control all answer. Clients on engines that
|
|
257
|
+
predate the heartbeat simply never arm that watchdog.
|
|
233
258
|
- **ICE restarts before reopens.** When the path drops after frames were
|
|
234
259
|
shown, the session asks the engine (`session.restart_ice`) to re-offer on
|
|
235
260
|
the same peer connection: the fresh offer arrives as the usual
|
|
236
261
|
`session.description` event and is answered as usual, all without reopening
|
|
237
262
|
the session. The first restart waits ~2 s for blips that heal alone;
|
|
238
263
|
retries back off across ~60 s, so a 30–60 s outage still recovers when the
|
|
239
|
-
network returns
|
|
264
|
+
network returns — except while the picture is frozen, when the short ~2 s
|
|
265
|
+
retry keeps being spent without consuming the reopen budget until frames
|
|
266
|
+
move again. When the restart schedule is spent without the peer
|
|
240
267
|
reporting `failed`, the session reopens instead of sitting in
|
|
241
268
|
`reconnecting`. An engine revocation with code `transport` (the path was
|
|
242
269
|
lost) also reopens the session; any other revocation code, or none, ends
|
|
@@ -287,6 +314,15 @@ interface Signaling {
|
|
|
287
314
|
candidates, state and revocation. Pixels and input do **not** use this
|
|
288
315
|
channel — they are the desktop's own WebRTC session.
|
|
289
316
|
|
|
317
|
+
`authorize()` must wait for the authenticated carrier to open before returning
|
|
318
|
+
its `Signaling`. When that carrier is gone, reject pending and future requests
|
|
319
|
+
with `code: 'transport'`; do not queue them indefinitely. An adapter may emit
|
|
320
|
+
`{ kind: 'carrier-closed' }` once to notify subscribers of that loss. This is
|
|
321
|
+
not the native peer's `closed` event or an engine revocation. Preserve the
|
|
322
|
+
engine's `session.revoked` code: `transport` indicates path loss; other codes
|
|
323
|
+
(or no code) are final. The demo adapter bounds connection setup to five seconds
|
|
324
|
+
and closes its socket and pending queue when disposed.
|
|
325
|
+
|
|
290
326
|
## Shipping on iOS
|
|
291
327
|
|
|
292
328
|
What the consuming app — not this package — must get right before review.
|
|
@@ -145,6 +145,10 @@ class DesklinkModule : Module() {
|
|
|
145
145
|
view.setGestures(gestures ?: "desktop")
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
Prop("cover") { view: DesktopView, cover: Boolean? ->
|
|
149
|
+
view.setCover(cover ?: false)
|
|
150
|
+
}
|
|
151
|
+
|
|
148
152
|
OnViewDestroys { view: DesktopView -> view.release() }
|
|
149
153
|
|
|
150
154
|
OnViewDidUpdateProps { view: DesktopView ->
|
|
@@ -114,6 +114,19 @@ class DesktopView(context: Context, appContext: AppContext) : ExpoView(context,
|
|
|
114
114
|
private var fitted = true
|
|
115
115
|
private var filling = true
|
|
116
116
|
private var filledScale = 0f
|
|
117
|
+
/**
|
|
118
|
+
* Opt-in cover: the picture covers the view in every orientation, cropping
|
|
119
|
+
* the overflowing axis, instead of fitting the portrait width. Off by
|
|
120
|
+
* default; the default path below is untouched.
|
|
121
|
+
*/
|
|
122
|
+
private var cover = false
|
|
123
|
+
|
|
124
|
+
fun setCover(value: Boolean) {
|
|
125
|
+
if (value == cover) return
|
|
126
|
+
cover = value
|
|
127
|
+
filledScale = 0f
|
|
128
|
+
layoutPicture()
|
|
129
|
+
}
|
|
117
130
|
|
|
118
131
|
/** What the system covers at each edge (bars, cutout), in pixels: left, top, right, bottom. */
|
|
119
132
|
private var insetLeft = 0f
|
|
@@ -459,9 +472,11 @@ class DesktopView(context: Context, appContext: AppContext) : ExpoView(context,
|
|
|
459
472
|
}
|
|
460
473
|
|
|
461
474
|
private fun fillScale(): Float {
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
475
|
+
// Portrait fits the width unless the app opted into cover; landscape
|
|
476
|
+
// covers. Either way the fill never goes below the fit or above the zoom cap.
|
|
477
|
+
val wide = if (width <= height && !cover) safeWidth() else width.toFloat()
|
|
478
|
+
val fill = max(wide / surfaceWidth, (height - insetTop) / surfaceHeight)
|
|
479
|
+
return max(fitScale(), min(fill, MAX_SCALE))
|
|
465
480
|
}
|
|
466
481
|
|
|
467
482
|
/** Re-derive the placement from `filling`, `fitted`, the zoom limits and the edges. */
|
package/app.plugin.js
CHANGED
|
@@ -7,10 +7,233 @@
|
|
|
7
7
|
* without it.
|
|
8
8
|
*
|
|
9
9
|
* "plugins": ["@desklink/react-native"]
|
|
10
|
+
*
|
|
11
|
+
* The plugin also repairs `expo-modules-jsi` 57.x for Xcode 26 (Swift 6.2).
|
|
12
|
+
* Two defects, both fatal, both fixed upstream in expo-modules-jsi 58 (SDK 58
|
|
13
|
+
* is out of scope and the 57 line has no fixed patch release):
|
|
14
|
+
*
|
|
15
|
+
* 1. `RuntimeScheduler.h` annotates two C++ constructors with
|
|
16
|
+
* `SWIFT_RETURNS_RETAINED`, which Swift 6.2 rejects as a hard error
|
|
17
|
+
* ("not returning a SWIFT_SHARED_REFERENCE type"), so no 57-line app
|
|
18
|
+
* compiles. Mirrored from 58: private constructors plus static `create`
|
|
19
|
+
* factories carrying the annotation, with the Swift call sites moved over.
|
|
20
|
+
* 2. `JavaScriptRuntime.swift` captures raw JSI pointers through
|
|
21
|
+
* `nonisolated(unsafe)` locals into `JavaScriptActor.assumeIsolated`
|
|
22
|
+
* closures, which Swift 6.2 rejects ("sending ... risks causing data
|
|
23
|
+
* races") now that `assumeIsolated` takes a `sending` closure. Mirrored
|
|
24
|
+
* from 58: an `UncheckedSendable` box plus `.value` at the use sites.
|
|
25
|
+
*
|
|
26
|
+
* Applied to the installed package at prebuild time, before `pod install`
|
|
27
|
+
* compiles it. Scoped to the 57 line: other versions skip untouched.
|
|
28
|
+
*/
|
|
29
|
+
const fs = require('fs');
|
|
30
|
+
const path = require('path');
|
|
31
|
+
|
|
32
|
+
const JSI_HEADER = 'apple/Sources/ExpoModulesJSI-Cxx/include/RuntimeScheduler.h';
|
|
33
|
+
const JSI_SWIFT = 'apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRuntime.swift';
|
|
34
|
+
const JSI_BOX = 'apple/Sources/ExpoModulesJSI/Utilities/UncheckedSendable.swift';
|
|
35
|
+
|
|
36
|
+
// The exact 57.x constructor block: constructors carrying the annotation that
|
|
37
|
+
// Swift 6.2 rejects. (\u2014 is the em dash in the original comment.)
|
|
38
|
+
const BROKEN_CONSTRUCTORS = `public:
|
|
39
|
+
/**
|
|
40
|
+
Constructs a scheduler bound to a host-provided native RuntimeScheduler.
|
|
41
|
+
\`scheduleTask\` dispatches through \`fn\`, which the host implements against
|
|
42
|
+
the real react::RuntimeScheduler.
|
|
43
|
+
*/
|
|
44
|
+
SWIFT_RETURNS_RETAINED RuntimeScheduler(void *scheduler, ScheduleFn fn) noexcept
|
|
45
|
+
: nativeScheduler(scheduler), scheduleFn(fn) {}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
Constructs a no-op scheduler. Scheduled tasks run synchronously on the
|
|
49
|
+
caller's thread \u2014 intended for standalone runtimes (e.g. tests) that have
|
|
50
|
+
no React scheduler.
|
|
51
|
+
*/
|
|
52
|
+
SWIFT_RETURNS_RETAINED RuntimeScheduler() {}`;
|
|
53
|
+
|
|
54
|
+
// The 58-shaped replacement: unannotated private constructors plus annotated
|
|
55
|
+
// static factories, which every Swift version accepts.
|
|
56
|
+
const FIXED_CONSTRUCTORS = ` RuntimeScheduler(void *scheduler, ScheduleFn fn) noexcept
|
|
57
|
+
: nativeScheduler(scheduler), scheduleFn(fn) {}
|
|
58
|
+
|
|
59
|
+
RuntimeScheduler() noexcept {}
|
|
60
|
+
|
|
61
|
+
public:
|
|
62
|
+
// Swift creates instances through the static \`create\` functions below, not
|
|
63
|
+
// the constructors: \`SWIFT_RETURNS_RETAINED\` on a constructor is a hard
|
|
64
|
+
// error on Swift 6.2 (Xcode 26), while a static function returning the shared
|
|
65
|
+
// reference accepts the annotation on every Swift version. This mirrors the
|
|
66
|
+
// fix Expo ships in expo-modules-jsi 58; the 57 line has no such release.
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
Creates a scheduler bound to a host-provided native RuntimeScheduler.
|
|
70
|
+
\`scheduleTask\` dispatches through \`fn\`, which the host implements against
|
|
71
|
+
the real react::RuntimeScheduler.
|
|
72
|
+
*/
|
|
73
|
+
static RuntimeScheduler *create(void *scheduler, ScheduleFn fn) noexcept SWIFT_RETURNS_RETAINED {
|
|
74
|
+
return new RuntimeScheduler(scheduler, fn);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
Creates a no-op scheduler. Scheduled tasks run synchronously on the
|
|
79
|
+
caller's thread \u2014 intended for standalone runtimes (e.g. tests) that have
|
|
80
|
+
no React scheduler.
|
|
81
|
+
*/
|
|
82
|
+
static RuntimeScheduler *create() noexcept SWIFT_RETURNS_RETAINED {
|
|
83
|
+
return new RuntimeScheduler();
|
|
84
|
+
}`;
|
|
85
|
+
|
|
86
|
+
// Verbatim from expo-modules-jsi 58's Utilities/UncheckedSendable.swift: an
|
|
87
|
+
// allocation-free `@unchecked Sendable` box. Swift 6.2 still reports
|
|
88
|
+
// "sending ... risks causing data races" for `nonisolated(unsafe)` locals
|
|
89
|
+
// captured by a `sending` closure, while a captured `Sendable` value passes.
|
|
90
|
+
const UNCHECKED_SENDABLE = `/// Immutable wrapper that makes a non-Sendable value capturable by a \`@Sendable\` closure without compiler enforcement.
|
|
91
|
+
/// Unlike \`NonisolatedUnsafeVar\`, it is a struct, so wrapping a value doesn't allocate.
|
|
92
|
+
///
|
|
93
|
+
/// Use it instead of a \`nonisolated(unsafe) let\` local captured by such a closure: Swift 6.2 still reports
|
|
94
|
+
/// "sending '...' risks causing data races" for those, while a capture of a \`Sendable\` value passes on every version.
|
|
95
|
+
/// The caller is responsible for making sure the value is never accessed concurrently.
|
|
96
|
+
internal struct UncheckedSendable<Value>: @unchecked Sendable {
|
|
97
|
+
let value: Value
|
|
98
|
+
|
|
99
|
+
init(_ value: Value) {
|
|
100
|
+
self.value = value
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
`;
|
|
104
|
+
|
|
105
|
+
function countOf(text, snippet) {
|
|
106
|
+
return text.split(snippet).length - 1;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function replaceOnceOrThrow(text, oldText, newText, what) {
|
|
110
|
+
if (countOf(text, oldText) !== 1) {
|
|
111
|
+
throw new Error('[desklink] expo-modules-jsi ' + what + ': expected one match, refusing to patch.');
|
|
112
|
+
}
|
|
113
|
+
return text.replace(oldText, newText);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Apply the 58-shaped Xcode 26 fixes to the installed expo-modules-jsi.
|
|
118
|
+
* Idempotent: a second prebuild (or an upstream-fixed copy) is a no-op.
|
|
119
|
+
* Scoped to the 57 line; anything else is left untouched. Throws on an
|
|
120
|
+
* unrecognised 57 layout rather than shipping a guess.
|
|
10
121
|
*/
|
|
11
|
-
|
|
122
|
+
function patchExpoModulesJsi(projectRoot) {
|
|
123
|
+
const root = path.join(projectRoot, 'node_modules/expo-modules-jsi');
|
|
124
|
+
const header = path.join(root, JSI_HEADER);
|
|
125
|
+
if (!fs.existsSync(header)) {
|
|
126
|
+
console.log('[desklink] expo-modules-jsi header not installed, skipping Xcode 26 fix');
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
let version = '';
|
|
130
|
+
try {
|
|
131
|
+
version = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version ?? '';
|
|
132
|
+
} catch { /* unknown version: the layout checks below decide */ }
|
|
133
|
+
if (!version.startsWith('57.')) {
|
|
134
|
+
if (version === '') {
|
|
135
|
+
throw new Error('[desklink] expo-modules-jsi has no readable version; refusing to patch blind.');
|
|
136
|
+
}
|
|
137
|
+
console.log(`[desklink] expo-modules-jsi ${version} is not a 57 release, skipping Xcode 26 fix`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const before = fs.readFileSync(header, 'utf8');
|
|
141
|
+
if (!before.includes('static RuntimeScheduler *create(')) {
|
|
142
|
+
if (countOf(before, BROKEN_CONSTRUCTORS) !== 1) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
'[desklink] expo-modules-jsi ' + version + ' RuntimeScheduler.h has an unrecognised layout; ' +
|
|
145
|
+
'refusing to patch. Update this plugin workaround for the installed version.'
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
fs.writeFileSync(header, before.replace(BROKEN_CONSTRUCTORS, FIXED_CONSTRUCTORS));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The header's only constructor callers are the Swift file beside it:
|
|
152
|
+
// point them at the factories. Member accesses (`.Priority(`, `.ScheduleFn`)
|
|
153
|
+
// never match `RuntimeScheduler(`.
|
|
154
|
+
const swift = path.join(root, JSI_SWIFT);
|
|
155
|
+
if (!fs.existsSync(swift)) {
|
|
156
|
+
throw new Error('[desklink] expo-modules-jsi ' + version + ' ships no ' + JSI_SWIFT + '.');
|
|
157
|
+
}
|
|
158
|
+
let swiftText = fs.readFileSync(swift, 'utf8');
|
|
159
|
+
if (!swiftText.includes('expo.RuntimeScheduler.create(')) {
|
|
160
|
+
const migrated = swiftText.replace(/expo\.RuntimeScheduler\(/g, 'expo.RuntimeScheduler.create(');
|
|
161
|
+
if (migrated === swiftText || /expo\.RuntimeScheduler\(/.test(migrated)) {
|
|
162
|
+
throw new Error('[desklink] expo-modules-jsi JavaScriptRuntime.swift call sites not fully migrated.');
|
|
163
|
+
}
|
|
164
|
+
swiftText = migrated;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Capture the JSI pointers through UncheckedSendable instead of
|
|
168
|
+
// `nonisolated(unsafe)`, which Swift 6.2 rejects at `sending` closures.
|
|
169
|
+
if (!swiftText.includes('resultPtr.value')) {
|
|
170
|
+
if (countOf(swiftText, 'nonisolated(unsafe) let resultPtr = resultPtr') !== 3) {
|
|
171
|
+
throw new Error('[desklink] expo-modules-jsi JavaScriptRuntime.swift pointer captures have an unrecognised layout.');
|
|
172
|
+
}
|
|
173
|
+
swiftText = replaceOnceOrThrow(swiftText,
|
|
174
|
+
'let propertyName = String(cString: propertyName)\n nonisolated(unsafe) let resultPtr = resultPtr',
|
|
175
|
+
'let propertyName = String(cString: propertyName)\n let resultPtr = UncheckedSendable(resultPtr)',
|
|
176
|
+
'getter capture');
|
|
177
|
+
swiftText = swiftText.split(
|
|
178
|
+
' nonisolated(unsafe) let thisPtr = thisPtr\n' +
|
|
179
|
+
' nonisolated(unsafe) let argumentsPtr = argumentsPtr\n' +
|
|
180
|
+
' nonisolated(unsafe) let resultPtr = resultPtr').join(
|
|
181
|
+
' let thisPtr = UncheckedSendable(thisPtr)\n' +
|
|
182
|
+
' let argumentsPtr = UncheckedSendable(argumentsPtr)\n' +
|
|
183
|
+
' let resultPtr = UncheckedSendable(resultPtr)');
|
|
184
|
+
if (countOf(swiftText, 'let thisPtr = UncheckedSendable(thisPtr)') !== 2) {
|
|
185
|
+
throw new Error('[desklink] expo-modules-jsi JavaScriptRuntime.swift trampoline captures not fully migrated.');
|
|
186
|
+
}
|
|
187
|
+
const writes = countOf(swiftText, 'writeJSIValue(to: resultPtr)');
|
|
188
|
+
if (writes !== 3) {
|
|
189
|
+
throw new Error('[desklink] expo-modules-jsi JavaScriptRuntime.swift result writes have an unrecognised layout.');
|
|
190
|
+
}
|
|
191
|
+
swiftText = swiftText.replace(/writeJSIValue\(to: resultPtr\)/g, 'writeJSIValue(to: resultPtr.value)');
|
|
192
|
+
swiftText = replaceOnceOrThrow(swiftText,
|
|
193
|
+
'UnsafeMutablePointer(mutating: thisPtr)', 'UnsafeMutablePointer(mutating: thisPtr.value)',
|
|
194
|
+
'owning-this use');
|
|
195
|
+
const starts = countOf(swiftText, 'start: argumentsPtr, count:');
|
|
196
|
+
if (starts !== 2) {
|
|
197
|
+
throw new Error('[desklink] expo-modules-jsi JavaScriptRuntime.swift buffer starts have an unrecognised layout.');
|
|
198
|
+
}
|
|
199
|
+
swiftText = swiftText.replace(/start: argumentsPtr, count:/g, 'start: argumentsPtr.value, count:');
|
|
200
|
+
swiftText = replaceOnceOrThrow(swiftText,
|
|
201
|
+
'JavaScriptUnownedValue(runtime.pointee, thisPtr)', 'JavaScriptUnownedValue(runtime.pointee, thisPtr.value)',
|
|
202
|
+
'unowned-this use');
|
|
203
|
+
swiftText = replaceOnceOrThrow(swiftText,
|
|
204
|
+
'so the `nonisolated(unsafe)` capture is sound.', 'so capturing them through `UncheckedSendable` is sound.',
|
|
205
|
+
'trampoline comment');
|
|
206
|
+
fs.writeFileSync(swift, swiftText);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const box = path.join(root, JSI_BOX);
|
|
210
|
+
if (!fs.existsSync(box)) {
|
|
211
|
+
fs.writeFileSync(box, UNCHECKED_SENDABLE);
|
|
212
|
+
}
|
|
213
|
+
console.log('[desklink] patched expo-modules-jsi ' + version + ' for Xcode 26');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function withDesklinkPointer(config) {
|
|
12
217
|
return {
|
|
13
218
|
...config,
|
|
14
219
|
ios: { ...config.ios, infoPlist: { ...config.ios?.infoPlist, UIApplicationSupportsIndirectInputEvents: true } },
|
|
15
220
|
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function withExpoJsiSwiftFix(config) {
|
|
224
|
+
let withDangerousMod;
|
|
225
|
+
try {
|
|
226
|
+
withDangerousMod = require('expo/config-plugins').withDangerousMod;
|
|
227
|
+
} catch {
|
|
228
|
+
throw new Error('[desklink] expo/config-plugins is required to apply the Xcode 26 patch.');
|
|
229
|
+
}
|
|
230
|
+
return withDangerousMod(config, ['ios', async (config) => {
|
|
231
|
+
patchExpoModulesJsi(config.modRequest.projectRoot);
|
|
232
|
+
return config;
|
|
233
|
+
}]);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
module.exports = function withDesklink(config) {
|
|
237
|
+
return withExpoJsiSwiftFix(withDesklinkPointer(config));
|
|
16
238
|
};
|
|
239
|
+
module.exports.patchExpoModulesJsi = patchExpoModulesJsi;
|
package/package.json
CHANGED
|
@@ -508,6 +508,7 @@ describe('iOS proof picture bounds', () => {
|
|
|
508
508
|
it('measures contiguous paper without including the disconnected home indicator', () => {
|
|
509
509
|
for (const density of [1, 3]) {
|
|
510
510
|
const png = { width: 390 * density, height: 844 * density, data: new Uint8Array(390 * 844 * density * density * 4) };
|
|
511
|
+
const right = (390 * density - 1) / density;
|
|
511
512
|
const paper = (left: number, top: number, right: number, bottom: number) => {
|
|
512
513
|
for (let y = top * density; y < bottom * density; y++) for (let x = left * density; x < right * density; x++) {
|
|
513
514
|
const i = (y * png.width + x) * 4;
|
|
@@ -516,14 +517,14 @@ describe('iOS proof picture bounds', () => {
|
|
|
516
517
|
};
|
|
517
518
|
paper(0, 47, 390, 266);
|
|
518
519
|
paper(125, 825, 265, 830);
|
|
519
|
-
expect(pictureBox(png, 390)).toEqual({ top: 47, left: 0, bottom: 266 });
|
|
520
|
+
expect(pictureBox(png, 390)).toEqual({ top: 47, left: 0, bottom: 266, right });
|
|
520
521
|
png.data.fill(0);
|
|
521
522
|
paper(0, 203, 390, 642);
|
|
522
523
|
paper(125, 825, 265, 830);
|
|
523
|
-
expect(pictureBox(png, 390)).toEqual({ top: 203, left: 0, bottom: 642 });
|
|
524
|
+
expect(pictureBox(png, 390)).toEqual({ top: 203, left: 0, bottom: 642, right });
|
|
524
525
|
png.data.fill(0);
|
|
525
526
|
paper(47, 0, 390, 844);
|
|
526
|
-
expect(pictureBox(png, 390)).toEqual({ top: 0, left: 47, bottom: 844 });
|
|
527
|
+
expect(pictureBox(png, 390)).toEqual({ top: 0, left: 47, bottom: 844, right });
|
|
527
528
|
}
|
|
528
529
|
});
|
|
529
530
|
});
|
package/src/DesktopView.ios.tsx
CHANGED
|
@@ -67,11 +67,16 @@ function loadRTCView(): React.ComponentType<Record<string, unknown>> | null {
|
|
|
67
67
|
/** An offset that the edge clamp turns into the desktop's top-left corner. */
|
|
68
68
|
const TOP_LEFT: Point = { x: Infinity, y: Infinity };
|
|
69
69
|
|
|
70
|
-
export function DesktopView({ sessionId, style, placeholder, accessibilityLabel, keyboardClearance = 0, insets, gestures = 'desktop', onPictureFrame }: DesktopViewProps) {
|
|
70
|
+
export function DesktopView({ sessionId, style, placeholder, accessibilityLabel, keyboardClearance = 0, insets, gestures = 'desktop', cover = false, onPictureFrame }: DesktopViewProps) {
|
|
71
71
|
const [revision, refresh] = React.useReducer((n: number) => n + 1, 0);
|
|
72
72
|
const [bounds, setBounds] = React.useState({ width: 0, height: 0 });
|
|
73
73
|
const [size, setSize] = React.useState(() => getDesktopSize(sessionId) ?? { width: 0, height: 0 });
|
|
74
|
-
|
|
74
|
+
/** How much of the view's bottom the keyboard covers, measured against where the view is on screen. */
|
|
75
|
+
const [keyboardOverlap, setKeyboardOverlap] = React.useState(0);
|
|
76
|
+
/** The keyboard's top on screen while it is up. */
|
|
77
|
+
const keyboardTop = React.useRef<number | null>(null);
|
|
78
|
+
/** Where the desktop's pointer was last sent, in desktop pixels: where a tap just put the caret. */
|
|
79
|
+
const pointerAt = React.useRef<Point | null>(null);
|
|
75
80
|
/** Zoom over the whole-desktop fit; null fills the view, the default. */
|
|
76
81
|
const [zoom, setZoom] = React.useState<number | null>(null);
|
|
77
82
|
/** From the middle of the uncovered part of the view; clamped to the picture's edges when shown. */
|
|
@@ -100,9 +105,11 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
|
|
|
100
105
|
return observeDesktopSurface(() => { refresh(); setEnabled(desktopInputEnabled(sessionId)); });
|
|
101
106
|
}, [sessionId]);
|
|
102
107
|
React.useEffect(() => {
|
|
103
|
-
const show = Keyboard.addListener('keyboardWillShow', (event) =>
|
|
104
|
-
const hide = Keyboard.addListener('keyboardWillHide', () =>
|
|
108
|
+
const show = Keyboard.addListener('keyboardWillShow', (event) => { keyboardTop.current = event.endCoordinates.screenY; measureKeyboard(); });
|
|
109
|
+
const hide = Keyboard.addListener('keyboardWillHide', () => { keyboardTop.current = null; setKeyboardOverlap(0); });
|
|
105
110
|
return () => { show.remove(); hide.remove(); };
|
|
111
|
+
// `measureKeyboard` reads refs only.
|
|
112
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
106
113
|
}, []);
|
|
107
114
|
React.useEffect(() => {
|
|
108
115
|
// A held drag belongs to the old meaning: let go of the button rather
|
|
@@ -135,10 +142,13 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
|
|
|
135
142
|
const left = insets?.left ?? 0;
|
|
136
143
|
const safeWidth = Math.max(1, bounds.width - left - (insets?.right ?? 0));
|
|
137
144
|
const safeHeight = Math.max(1, bounds.height - top - (insets?.bottom ?? 0));
|
|
138
|
-
const visibleBottom = Math.min(top + safeHeight, bounds.height - (
|
|
145
|
+
const visibleBottom = Math.min(top + safeHeight, bounds.height - (keyboardOverlap ? keyboardOverlap + keyboardClearance : 0));
|
|
139
146
|
const visibleHeight = Math.max(1, visibleBottom - top);
|
|
140
147
|
const fit = size.width && size.height ? Math.min(safeWidth / size.width, visibleHeight / size.height) : 1;
|
|
141
|
-
|
|
148
|
+
// Portrait fits the width unless the app opted into cover, when it covers
|
|
149
|
+
// like landscape does (as `DesktopView.kt` does); either way the fill never
|
|
150
|
+
// goes below the fit or above the zoom cap.
|
|
151
|
+
const defaultScale = bounds.width <= bounds.height && !cover
|
|
142
152
|
? safeWidth / size.width
|
|
143
153
|
: Math.max(bounds.width / size.width, (bounds.height - top) / size.height);
|
|
144
154
|
const fill = size.width && size.height ? Math.max(fit, Math.min(MAX_SCALE, defaultScale)) : 1;
|
|
@@ -169,6 +179,21 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
|
|
|
169
179
|
const shown = clampOffset(offset.x, offset.y);
|
|
170
180
|
const originX = left + (safeWidth - pictureWidth) / 2 + shown.x;
|
|
171
181
|
const originY = top + (zoom === null && pictureHeight <= visibleHeight ? 0 : (visibleHeight - pictureHeight) / 2) + shown.y;
|
|
182
|
+
// The keyboard took (or gave back) the bottom of the view. A picture taller
|
|
183
|
+
// than what is left is moved so the pointer, where a tap just put the
|
|
184
|
+
// caret, stays in sight, as `DesktopView.kt` does.
|
|
185
|
+
const shownBottom = React.useRef(visibleBottom);
|
|
186
|
+
React.useLayoutEffect(() => {
|
|
187
|
+
const before = shownBottom.current;
|
|
188
|
+
shownBottom.current = visibleBottom;
|
|
189
|
+
const at = pointerAt.current;
|
|
190
|
+
if (Math.abs(visibleBottom - before) < 0.5 || !at) return;
|
|
191
|
+
const y = originY + (at.y + 0.5) * scale;
|
|
192
|
+
const margin = Math.min(56, visibleBottom / 4);
|
|
193
|
+
if (y > visibleBottom - margin) setOffset(clampOffset(shown.x, shown.y - (y - (visibleBottom - margin))));
|
|
194
|
+
// Only a change in what the keyboard covers moves the picture.
|
|
195
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
196
|
+
}, [visibleBottom]);
|
|
172
197
|
// An application's controls can sit under the picture instead of over it,
|
|
173
198
|
// so it needs to know where the picture ended. Reported only when the
|
|
174
199
|
// picture moved: the callback sets state, and a fresh callback each render
|
|
@@ -189,6 +214,7 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
|
|
|
189
214
|
};
|
|
190
215
|
const pointer = (phase: string, at: Point, button?: number, mark = true) => {
|
|
191
216
|
send({ kind: 'pointer', phase, x: at.x, y: at.y, ...(button ? { button } : {}) });
|
|
217
|
+
pointerAt.current = at;
|
|
192
218
|
// A device screen shows the touch itself; a cursor would be a second finger that lies.
|
|
193
219
|
// A trackpad or mouse has the system's own pointer on screen.
|
|
194
220
|
if (!mark) setCursor(null);
|
|
@@ -426,7 +452,19 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
|
|
|
426
452
|
wheel.current.y += -dy / scale / PIXELS_PER_DETENT;
|
|
427
453
|
flushWheel(false);
|
|
428
454
|
};
|
|
429
|
-
|
|
455
|
+
/**
|
|
456
|
+
* The keyboard covers only the part of the view below its top: the view can
|
|
457
|
+
* end above the screen's bottom. The hidden input sits at the view's bottom
|
|
458
|
+
* edge, so it says where that edge is on screen.
|
|
459
|
+
*/
|
|
460
|
+
const measureKeyboard = () => {
|
|
461
|
+
const keyboardAt = keyboardTop.current;
|
|
462
|
+
if (keyboardAt === null) return;
|
|
463
|
+
input.current?.measureInWindow((_x, y, _width, height) => {
|
|
464
|
+
if (keyboardTop.current === keyboardAt) setKeyboardOverlap(Math.max(0, y + height - keyboardAt));
|
|
465
|
+
});
|
|
466
|
+
};
|
|
467
|
+
const onLayout = (event: LayoutChangeEvent) => { setBounds(event.nativeEvent.layout); measureKeyboard(); };
|
|
430
468
|
const surface: ViewProps = {
|
|
431
469
|
style: [styles.surface, style], onLayout, accessible: accessibilityLabel !== undefined, accessibilityLabel,
|
|
432
470
|
onStartShouldSetResponder: () => enabled, onMoveShouldSetResponder: () => enabled,
|
package/src/DesktopView.tsx
CHANGED
|
@@ -35,6 +35,11 @@ export interface DesktopViewProps {
|
|
|
35
35
|
* (one finger scrolls), or a device screen (one finger presses and drags).
|
|
36
36
|
*/
|
|
37
37
|
gestures?: GestureProfile;
|
|
38
|
+
/**
|
|
39
|
+
* Cover the view in every orientation, cropping the overflowing axis,
|
|
40
|
+
* instead of fitting the portrait width. Off by default.
|
|
41
|
+
*/
|
|
42
|
+
cover?: boolean;
|
|
38
43
|
/**
|
|
39
44
|
* Where the picture ended, in the view's own points, whenever it moves.
|
|
40
45
|
* A wide desktop on a tall screen leaves a band under the picture; this is
|
|
@@ -53,6 +58,7 @@ interface NativeSurfaceProps {
|
|
|
53
58
|
/** `insets` as top, left, bottom, right. */
|
|
54
59
|
insets?: number[];
|
|
55
60
|
gestures?: GestureProfile;
|
|
61
|
+
cover?: boolean;
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
// The Expo view is resolved at module load; on a platform without it the
|
|
@@ -69,7 +75,7 @@ const NativeSurface: React.ComponentType<NativeSurfaceProps> | null = desktopAva
|
|
|
69
75
|
* belong to the application, which mounts this wherever it wants the desktop
|
|
70
76
|
* to appear.
|
|
71
77
|
*/
|
|
72
|
-
export function DesktopView({ sessionId, style, placeholder, accessibilityLabel, keyboardClearance = 0, insets, gestures = 'desktop' }: DesktopViewProps) {
|
|
78
|
+
export function DesktopView({ sessionId, style, placeholder, accessibilityLabel, keyboardClearance = 0, insets, gestures = 'desktop', cover = false }: DesktopViewProps) {
|
|
73
79
|
if (NativeSurface == null || sessionId == null) {
|
|
74
80
|
return (
|
|
75
81
|
<View style={[styles.surface, style]}>
|
|
@@ -78,7 +84,7 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
|
|
|
78
84
|
);
|
|
79
85
|
}
|
|
80
86
|
return <NativeSurface style={[styles.surface, style]} sessionId={sessionId} accessible={accessibilityLabel !== undefined} accessibilityLabel={accessibilityLabel} keyboardClearance={keyboardClearance}
|
|
81
|
-
insets={[insets?.top ?? 0, insets?.left ?? 0, insets?.bottom ?? 0, insets?.right ?? 0]} gestures={gestures} />;
|
|
87
|
+
insets={[insets?.top ?? 0, insets?.left ?? 0, insets?.bottom ?? 0, insets?.right ?? 0]} gestures={gestures} cover={cover} />;
|
|
82
88
|
}
|
|
83
89
|
|
|
84
90
|
const styles = StyleSheet.create({
|
package/src/native.web.ts
CHANGED
|
@@ -127,6 +127,8 @@ interface WebSession {
|
|
|
127
127
|
chordsDown: Set<string>;
|
|
128
128
|
/** Re-measure what the keyboard covers; null until a surface is attached. */
|
|
129
129
|
followKeyboard: (() => void) | null;
|
|
130
|
+
/** Whether the first frame was already reported; renegotiation must not re-mark a stale picture. */
|
|
131
|
+
presented: boolean;
|
|
130
132
|
/** Candidates that arrived before the offer; applied once it is set. */
|
|
131
133
|
remoteDescriptionSet: boolean;
|
|
132
134
|
pendingCandidates: RTCIceCandidateInit[];
|
|
@@ -960,6 +962,7 @@ export const nativeDesklink: NativeDesklinkModule = {
|
|
|
960
962
|
compositionSent: '',
|
|
961
963
|
composing: false,
|
|
962
964
|
chordsDown: new Set<string>(),
|
|
965
|
+
presented: false,
|
|
963
966
|
remoteDescriptionSet: false,
|
|
964
967
|
pendingCandidates: [],
|
|
965
968
|
detach: null,
|
|
@@ -982,6 +985,12 @@ export const nativeDesklink: NativeDesklinkModule = {
|
|
|
982
985
|
// track and produce nothing.
|
|
983
986
|
const presented = (): void => {
|
|
984
987
|
if (sessions.get(id) !== session) return;
|
|
988
|
+
// Readiness is the first frame. Renegotiation re-fires ontrack
|
|
989
|
+
// and the compositor repaints the frame it already holds, and a
|
|
990
|
+
// repaint is not a new frame: a second mark would read a stale
|
|
991
|
+
// picture as live and reset the recovery budget mid-outage.
|
|
992
|
+
if (session.presented) return;
|
|
993
|
+
session.presented = true;
|
|
985
994
|
video.style.opacity = '1';
|
|
986
995
|
emit(id, 'presented', {});
|
|
987
996
|
};
|
package/src/protocol.ts
CHANGED
|
@@ -56,6 +56,13 @@ export interface SessionOpenRequest {
|
|
|
56
56
|
iceServers?: IceServerConfig[];
|
|
57
57
|
restoreToken?: string;
|
|
58
58
|
ttlSeconds?: number;
|
|
59
|
+
/**
|
|
60
|
+
* Ask the engine to also offer ICE over TCP on its own loopback, for a
|
|
61
|
+
* client whose only way to this computer is a forward of that loopback
|
|
62
|
+
* (an SSH tunnel, `adb reverse`). A client with a UDP path keeps it: ICE
|
|
63
|
+
* prefers UDP whenever it has one.
|
|
64
|
+
*/
|
|
65
|
+
loopbackTcp?: boolean;
|
|
59
66
|
}
|
|
60
67
|
|
|
61
68
|
export interface SessionOpenResult {
|
|
@@ -96,6 +103,8 @@ export interface CursorSample {
|
|
|
96
103
|
* the new session corrupts or kills a healthy recovery.
|
|
97
104
|
*/
|
|
98
105
|
export type SessionEvent =
|
|
106
|
+
/** Optional carrier notification, distinct from the native peer's `closed`. */
|
|
107
|
+
| { kind: 'carrier-closed'; sessionId?: never }
|
|
99
108
|
| { kind: 'description'; description: RtcDescription; sessionId?: string }
|
|
100
109
|
| { kind: 'candidate'; candidate: RtcCandidate; sessionId?: string }
|
|
101
110
|
| ({ kind: 'cursor' } & CursorSample)
|
|
@@ -113,10 +122,13 @@ export type SessionEvent =
|
|
|
113
122
|
* The package calls these and nothing else, so an application can carry them
|
|
114
123
|
* over its existing encrypted request path, a socket, or an in-process bridge.
|
|
115
124
|
* `subscribe` delivers engine notifications until the returned function runs.
|
|
125
|
+
* The application's authorize() resolves only once this carrier is open.
|
|
116
126
|
*/
|
|
117
127
|
export interface Signaling {
|
|
118
128
|
/**
|
|
119
|
-
* Send one request.
|
|
129
|
+
* Send one request. Reject pending and future requests with `code: 'transport'`
|
|
130
|
+
* once the carrier is gone; never queue requests on a dead carrier.
|
|
131
|
+
* A rejection may carry a stable `code` — the engine's or
|
|
120
132
|
* the host's own token — which the package reads to classify a refusal, so a
|
|
121
133
|
* refusal is not retried as though it were a network failure.
|
|
122
134
|
*/
|
package/src/useDesktopSession.ts
CHANGED
|
@@ -57,6 +57,35 @@ const STALL_AFTER_MS = 1200;
|
|
|
57
57
|
const RESTART_GRACE_MS = 2500;
|
|
58
58
|
/** How long a restart offer may go unanswered before the next attempt. */
|
|
59
59
|
const RESTART_REPLY_MS = 5000;
|
|
60
|
+
/**
|
|
61
|
+
* How long the media path may carry nothing at all before a session that says
|
|
62
|
+
* `live` stops saying so. The engine's transport state reaches the client over
|
|
63
|
+
* the signalling channel, which is not part of the media path, so a dead link
|
|
64
|
+
* with signalling up reads as `connected` over a picture that has stopped: live
|
|
65
|
+
* has to mean the picture is advancing, not that someone answered.
|
|
66
|
+
*/
|
|
67
|
+
const FROZEN_AFTER_MS = 2000;
|
|
68
|
+
/** How often the media path is read while the session claims to be live. */
|
|
69
|
+
const MEDIA_POLL_MS = 500;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* What the receiver has taken in, summed over its video streams: new decoded
|
|
73
|
+
* frames, and packets that arrived at all. Either moving means the picture is
|
|
74
|
+
* advancing; a still screen that keeps no frames moving is not a dead path, so
|
|
75
|
+
* both counts are read rather than frames alone.
|
|
76
|
+
*/
|
|
77
|
+
function mediaCounts(rows: Array<Record<string, unknown>>): { frames: number; packets: number } | null {
|
|
78
|
+
let frames = 0;
|
|
79
|
+
let packets = 0;
|
|
80
|
+
let streams = 0;
|
|
81
|
+
for (const row of rows) {
|
|
82
|
+
if (row.mediaType !== undefined && row.mediaType !== 'video') continue;
|
|
83
|
+
streams += 1;
|
|
84
|
+
frames += typeof row.framesDecoded === 'number' ? row.framesDecoded : 0;
|
|
85
|
+
packets += typeof row.packetsReceived === 'number' ? row.packetsReceived : 0;
|
|
86
|
+
}
|
|
87
|
+
return streams === 0 ? null : { frames, packets };
|
|
88
|
+
}
|
|
60
89
|
/**
|
|
61
90
|
* Full session reopens back off across about a minute, holding at patient
|
|
62
91
|
* 8 s retries: a return must never wait out a 32 s gap, and an attempt every
|
|
@@ -94,13 +123,17 @@ export type ConnectionStateName =
|
|
|
94
123
|
* What one ICE/engine-transport state means for the session status, or null
|
|
95
124
|
* when it changes nothing. `disconnected` surfaces as `reconnecting` at once —
|
|
96
125
|
* event-driven, so within about a second of the drop — and `connected` moves a
|
|
97
|
-
* session
|
|
98
|
-
* handled by the failure path, not here.
|
|
126
|
+
* session whose picture is advancing back to `live`. `failed` is terminal and
|
|
127
|
+
* is handled by the failure path, not here.
|
|
128
|
+
*
|
|
129
|
+
* `advancing` is the media path, not the transport: a transport that answers
|
|
130
|
+
* with a still image is not a live desktop, so a session whose picture has
|
|
131
|
+
* stopped stays out of `live` whatever the transport reports.
|
|
99
132
|
*/
|
|
100
133
|
export function connectionStatusFor(
|
|
101
134
|
state: string,
|
|
102
135
|
status: SessionSnapshot['status'],
|
|
103
|
-
|
|
136
|
+
advancing: boolean,
|
|
104
137
|
): SessionSnapshot['status'] | null {
|
|
105
138
|
switch (state) {
|
|
106
139
|
case 'disconnected':
|
|
@@ -108,9 +141,9 @@ export function connectionStatusFor(
|
|
|
108
141
|
case 'connected':
|
|
109
142
|
case 'completed':
|
|
110
143
|
if (status !== 'reconnecting') return null;
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
return
|
|
144
|
+
// No picture is moving: this is still the first connection, not a
|
|
145
|
+
// recovery, so it stays `connecting` until frames advance again.
|
|
146
|
+
return advancing ? 'live' : 'connecting';
|
|
114
147
|
default:
|
|
115
148
|
return null;
|
|
116
149
|
}
|
|
@@ -124,6 +157,15 @@ export function connectionStatusFor(
|
|
|
124
157
|
const UNREACHABLE_DESKTOP =
|
|
125
158
|
'The phone must reach the desktop directly, on the same network or its tailnet. Other networks need a relay route supplied by the app.';
|
|
126
159
|
|
|
160
|
+
/**
|
|
161
|
+
* A connection that never shows a frame is a connection that never formed, and
|
|
162
|
+
* saying so beats an empty picture: on a path that only reaches the desktop
|
|
163
|
+
* through a tunnel or a USB forward, the app has to ask for the loopback route.
|
|
164
|
+
*/
|
|
165
|
+
export const FIRST_FRAME_AFTER_MS = 20_000;
|
|
166
|
+
const NO_PICTURE =
|
|
167
|
+
'The desktop never sent a picture. On a path that only reaches the desktop through a tunnel or a USB forward, open the session with loopbackTcp: true.';
|
|
168
|
+
|
|
127
169
|
/**
|
|
128
170
|
* The engine's and host's own refusal tokens, mapped to this package's failure
|
|
129
171
|
* codes. A token the engine did not send is a deliberate refusal, not a network
|
|
@@ -282,8 +324,18 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
282
324
|
const restartTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
283
325
|
/** The pending stall watchdog, armed by heartbeats. */
|
|
284
326
|
const stallTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
327
|
+
/** Armed while a first connection waits for its first frame. */
|
|
328
|
+
const firstFrameTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
285
329
|
/** When the last heartbeat arrived, or 0 before the first one. */
|
|
286
330
|
const lastPingAt = useRef(0);
|
|
331
|
+
/** The pending media read, armed while the session claims to be live. */
|
|
332
|
+
const mediaTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
333
|
+
/** The receiver's counts at the last media read, to see the next one move. */
|
|
334
|
+
const mediaRef = useRef<{ frames: number; packets: number } | null>(null);
|
|
335
|
+
/** When the media path last moved, or null while it has never moved. */
|
|
336
|
+
const mediaMovingAt = useRef<number | null>(null);
|
|
337
|
+
/** Whether the picture of an open session has stopped advancing. */
|
|
338
|
+
const frozen = useRef(false);
|
|
287
339
|
/** Renegotiation transients are not a new outage before this time. */
|
|
288
340
|
const restartGraceUntil = useRef(0);
|
|
289
341
|
/** ICE-restart attempts spent on the current drop. */
|
|
@@ -355,6 +407,24 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
355
407
|
stallTimer.current = null;
|
|
356
408
|
}, []);
|
|
357
409
|
|
|
410
|
+
const cancelFirstFrame = useCallback(() => {
|
|
411
|
+
if (firstFrameTimer.current !== null) clearTimeout(firstFrameTimer.current);
|
|
412
|
+
firstFrameTimer.current = null;
|
|
413
|
+
}, []);
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* A first connection that stays frameless for this long is not going to
|
|
417
|
+
* arrive: report why instead of leaving an empty picture on screen.
|
|
418
|
+
*/
|
|
419
|
+
const armFirstFrame = useCallback(() => {
|
|
420
|
+
cancelFirstFrame();
|
|
421
|
+
firstFrameTimer.current = setTimeout(() => {
|
|
422
|
+
firstFrameTimer.current = null;
|
|
423
|
+
if (presentedRef.current || statusRef.current !== 'connecting') return;
|
|
424
|
+
transportFailed(NO_PICTURE);
|
|
425
|
+
}, FIRST_FRAME_AFTER_MS);
|
|
426
|
+
}, [cancelFirstFrame, transportFailed]);
|
|
427
|
+
|
|
358
428
|
const scheduleRestartRef = useRef<(delay: number) => void>(() => undefined);
|
|
359
429
|
|
|
360
430
|
/**
|
|
@@ -364,7 +434,11 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
364
434
|
*/
|
|
365
435
|
const spendRestartAttempt = useCallback((): void => {
|
|
366
436
|
if (statusRef.current !== 'reconnecting' || nativeRef.current == null) return;
|
|
367
|
-
|
|
437
|
+
// A frozen picture is the media path itself, and a restart is what
|
|
438
|
+
// rebuilds it, so the short delay keeps being spent until frames move
|
|
439
|
+
// again. Backing off to half a minute while the picture is stopped
|
|
440
|
+
// leaves the user looking at a still image long after the link is back.
|
|
441
|
+
const wait = frozen.current ? RESTART_AFTER_MS : backoffDelay(RESTART_BACKOFF_MS, restartAttempts.current);
|
|
368
442
|
if (wait === null) {
|
|
369
443
|
transportFailed('the desktop did not come back; reopening the session');
|
|
370
444
|
return;
|
|
@@ -412,7 +486,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
412
486
|
// the session itself is wedged and only a reopen heals it.
|
|
413
487
|
// (While ICE is down, patience: the network may still come
|
|
414
488
|
// back, and the backoff schedule owns that case.)
|
|
415
|
-
if (restartCycles.current >= 2 && iceConnected.current) {
|
|
489
|
+
if (restartCycles.current >= 2 && iceConnected.current && !frozen.current) {
|
|
416
490
|
transportFailed('the desktop stopped responding; reopening the session');
|
|
417
491
|
return;
|
|
418
492
|
}
|
|
@@ -420,7 +494,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
420
494
|
// which the session answers as usual; without recovery by the
|
|
421
495
|
// reply deadline, spend the next attempt. A live transport
|
|
422
496
|
// answers in milliseconds, so hold it to a short leash.
|
|
423
|
-
const replyMs = iceConnected.current ? 2000 : RESTART_REPLY_MS;
|
|
497
|
+
const replyMs = frozen.current ? RESTART_AFTER_MS : iceConnected.current ? 2000 : RESTART_REPLY_MS;
|
|
424
498
|
restartTimer.current = setTimeout(() => {
|
|
425
499
|
restartTimer.current = null;
|
|
426
500
|
spendRestartAttempt();
|
|
@@ -432,22 +506,36 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
432
506
|
useEffect(() => {
|
|
433
507
|
scheduleRestartRef.current = scheduleRestart;
|
|
434
508
|
checkStallRef.current = checkStall;
|
|
509
|
+
mediaWatchRef.current = checkMedia;
|
|
435
510
|
});
|
|
436
511
|
|
|
437
512
|
const checkStallRef = useRef(() => {});
|
|
513
|
+
const mediaWatchRef = useRef<() => void>(() => undefined);
|
|
514
|
+
|
|
515
|
+
/** Arm one media read; each tick decides whether to arm another. */
|
|
516
|
+
const armMedia = useCallback((): void => {
|
|
517
|
+
if (mediaTimer.current !== null) return;
|
|
518
|
+
mediaTimer.current = setTimeout(() => mediaWatchRef.current(), MEDIA_POLL_MS);
|
|
519
|
+
}, []);
|
|
438
520
|
|
|
439
521
|
/**
|
|
440
522
|
* One ICE or engine-transport state, from either reporter. The mapping is
|
|
441
523
|
* idempotent, so the peer's event and the engine's `session.state` telling
|
|
442
524
|
* the same story neither double-counts nor fights.
|
|
443
525
|
*/
|
|
444
|
-
const onTransportState = useCallback((state: string, trackIce = true) => {
|
|
526
|
+
const onTransportState = useCallback((state: string, trackIce = true, skipGrace = false) => {
|
|
445
527
|
// Synthetic stall reports say nothing about ICE itself.
|
|
446
528
|
if (trackIce) iceConnected.current = state === 'connected' || state === 'completed';
|
|
447
|
-
const
|
|
448
|
-
if (
|
|
529
|
+
const mapped = connectionStatusFor(state, statusRef.current, presentedRef.current && !frozen.current);
|
|
530
|
+
if (mapped === null) return;
|
|
531
|
+
// A transport that answers while the picture is frozen is not a
|
|
532
|
+
// recovery. For a session that has shown frames that stays `reconnecting`,
|
|
533
|
+
// rather than falling back to the first connection it has left behind.
|
|
534
|
+
const next = mapped === 'connecting' && statusRef.current === 'reconnecting' && presentedRef.current
|
|
535
|
+
? 'reconnecting'
|
|
536
|
+
: mapped;
|
|
449
537
|
// Our own restart's transients are expected, not a new outage.
|
|
450
|
-
if (next === 'reconnecting' && Date.now() < restartGraceUntil.current) return;
|
|
538
|
+
if (!skipGrace && next === 'reconnecting' && Date.now() < restartGraceUntil.current) return;
|
|
451
539
|
update({ status: next });
|
|
452
540
|
if (next === 'reconnecting') {
|
|
453
541
|
// Only a path that once carried frames is worth restarting: an
|
|
@@ -463,9 +551,66 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
463
551
|
// from here is a stalled session, not a healthy idle one.
|
|
464
552
|
cancelStall();
|
|
465
553
|
stallTimer.current = setTimeout(() => checkStallRef.current(), STALL_AFTER_MS);
|
|
554
|
+
// And the picture is what says live, so it is watched from here.
|
|
555
|
+
armMedia();
|
|
466
556
|
}
|
|
467
557
|
}
|
|
468
|
-
}, [cancelRestart, cancelStall, scheduleRestart, update]);
|
|
558
|
+
}, [cancelRestart, cancelStall, scheduleRestart, update, armMedia]);
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* One read of the receiver's own counts: is the picture actually advancing?
|
|
562
|
+
*
|
|
563
|
+
* The engine's transport state arrives over the signalling channel, which
|
|
564
|
+
* is not the media path, so a link that carries nothing while signalling
|
|
565
|
+
* answers can hand the session a `connected` it must not believe. This is
|
|
566
|
+
* the check that keeps `live` honest, and the one that ends the freeze:
|
|
567
|
+
* frames moving again put the session back to `live` on the same session.
|
|
568
|
+
*/
|
|
569
|
+
const checkMedia = useCallback((): void => {
|
|
570
|
+
mediaTimer.current = null;
|
|
571
|
+
// A session that has shown a picture is watched for as long as it has
|
|
572
|
+
// one: a freeze that arrives while the session is already reconnecting
|
|
573
|
+
// is exactly the case where waiting for the transport to say so would
|
|
574
|
+
// say it for the rest of the outage.
|
|
575
|
+
if (!presentedRef.current) return;
|
|
576
|
+
armMedia();
|
|
577
|
+
const id = nativeRef.current;
|
|
578
|
+
if (id == null || nativeDesklink?.getStats == null) return;
|
|
579
|
+
const token = generationToken.current;
|
|
580
|
+
void nativeDesklink.getStats(id).then((raw) => {
|
|
581
|
+
if (token !== generationToken.current || nativeRef.current !== id) return;
|
|
582
|
+
let counts: { frames: number; packets: number } | null;
|
|
583
|
+
try {
|
|
584
|
+
counts = mediaCounts(JSON.parse(raw));
|
|
585
|
+
} catch {
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
// No video stats yet: there is nothing here to judge a picture by.
|
|
589
|
+
if (counts === null) return;
|
|
590
|
+
const previous = mediaRef.current;
|
|
591
|
+
mediaRef.current = counts;
|
|
592
|
+
const moving = previous === null
|
|
593
|
+
|| counts.frames > previous.frames
|
|
594
|
+
|| counts.packets > previous.packets;
|
|
595
|
+
if (moving) {
|
|
596
|
+
mediaMovingAt.current = Date.now();
|
|
597
|
+
if (!frozen.current) return;
|
|
598
|
+
frozen.current = false;
|
|
599
|
+
// The picture advances again, so the transport can be believed
|
|
600
|
+
// for this session: live returns without waiting for it to say so.
|
|
601
|
+
if (statusRef.current === 'reconnecting') onTransportState('connected', false);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
if (mediaMovingAt.current === null) return;
|
|
605
|
+
if (Date.now() - mediaMovingAt.current < FROZEN_AFTER_MS) return;
|
|
606
|
+
if (frozen.current) return;
|
|
607
|
+
frozen.current = true;
|
|
608
|
+
// The picture has stopped, so the session is not live whatever the
|
|
609
|
+
// transport reports. When the transport has already said so, there
|
|
610
|
+
// is nothing to change: the recovery it started is the right one.
|
|
611
|
+
if (statusRef.current === 'live' || statusRef.current === 'connecting') onTransportState('disconnected', false, true);
|
|
612
|
+
}, () => undefined);
|
|
613
|
+
}, [armMedia, onTransportState]);
|
|
469
614
|
|
|
470
615
|
/**
|
|
471
616
|
* One stall check: quiet too long while the session should be talking
|
|
@@ -598,6 +743,13 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
598
743
|
pendingCursorEvents.current = [];
|
|
599
744
|
restartGraceUntil.current = 0;
|
|
600
745
|
restartCycles.current = 0;
|
|
746
|
+
// A new session has its own media path: the last one's counts and its
|
|
747
|
+
// freeze say nothing about this one.
|
|
748
|
+
if (mediaTimer.current !== null) clearTimeout(mediaTimer.current);
|
|
749
|
+
mediaTimer.current = null;
|
|
750
|
+
mediaRef.current = null;
|
|
751
|
+
mediaMovingAt.current = null;
|
|
752
|
+
frozen.current = false;
|
|
601
753
|
opened.current = null;
|
|
602
754
|
signaling.current = null;
|
|
603
755
|
setNativeId(null);
|
|
@@ -627,6 +779,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
627
779
|
cancelReconnect();
|
|
628
780
|
cancelRestart();
|
|
629
781
|
cancelStall();
|
|
782
|
+
cancelFirstFrame();
|
|
630
783
|
restoreToken.current = null;
|
|
631
784
|
const openedRef = opened.current;
|
|
632
785
|
const owner = signaling.current;
|
|
@@ -634,7 +787,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
634
787
|
if (closeRemote) await endRemote(openedRef, owner);
|
|
635
788
|
update({ status: 'ended', presented: false, failure: null });
|
|
636
789
|
void reason;
|
|
637
|
-
}, [cancelReconnect, cancelRestart, cancelStall, endRemote, discardSession, update]);
|
|
790
|
+
}, [cancelReconnect, cancelRestart, cancelStall, cancelFirstFrame, endRemote, discardSession, update]);
|
|
638
791
|
|
|
639
792
|
const establish = useCallback(async () => {
|
|
640
793
|
if (nativeRef.current != null) return;
|
|
@@ -651,7 +804,8 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
651
804
|
try {
|
|
652
805
|
authorization = await optionsRef.current.authorize();
|
|
653
806
|
} catch (error) {
|
|
654
|
-
|
|
807
|
+
const code = (error as { code?: unknown } | null)?.code === 'transport' ? 'transport' : 'permission';
|
|
808
|
+
refuse(error instanceof Error ? error.message : 'the desktop was not authorized', code);
|
|
655
809
|
return;
|
|
656
810
|
}
|
|
657
811
|
if (token !== generationToken.current) return;
|
|
@@ -751,6 +905,10 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
751
905
|
// reopen; the app's own grant wins when it names one.
|
|
752
906
|
restore_token: authorization.session.restoreToken ?? restoreToken.current ?? undefined,
|
|
753
907
|
ttl_seconds: authorization.session.ttlSeconds,
|
|
908
|
+
// A TCP-only path (an SSH tunnel, `adb reverse`) can only be
|
|
909
|
+
// reached on the engine's loopback, which the engine offers
|
|
910
|
+
// only when the app asks. Off by default, as on the wire.
|
|
911
|
+
...(authorization.session.loopbackTcp ? { loopback_tcp: true } : {}),
|
|
754
912
|
});
|
|
755
913
|
} catch (error) {
|
|
756
914
|
const message = error instanceof Error ? error.message : 'the desktop could not start';
|
|
@@ -765,6 +923,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
765
923
|
if (sample.sessionId === openedResult.sessionId) optionsRef.current.onCursor?.(sample);
|
|
766
924
|
}
|
|
767
925
|
update({ geometry: openedResult.geometry, status: 'connecting' });
|
|
926
|
+
armFirstFrame();
|
|
768
927
|
if (token !== generationToken.current) return;
|
|
769
928
|
|
|
770
929
|
const platform = nativeDesklink;
|
|
@@ -808,7 +967,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
808
967
|
await endRemote(openedResult, authorization.signaling);
|
|
809
968
|
}
|
|
810
969
|
}
|
|
811
|
-
}, [discardSession, endRemote, refuse, teardown, transportFailed, update, onTransportState]);
|
|
970
|
+
}, [armFirstFrame, discardSession, endRemote, refuse, teardown, transportFailed, update, onTransportState]);
|
|
812
971
|
|
|
813
972
|
/**
|
|
814
973
|
* Open (or reopen) the session with fresh authority. A deliberate call
|
|
@@ -865,11 +1024,18 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
865
1024
|
// Readiness is not "a track arrived": it is a frame on screen.
|
|
866
1025
|
return;
|
|
867
1026
|
case 'presented':
|
|
1027
|
+
// One mark per session: a producer that re-marks (a repaint
|
|
1028
|
+
// after renegotiation) must not read a stale picture as live.
|
|
1029
|
+
if (presentedRef.current) return;
|
|
868
1030
|
attempts.current = 0;
|
|
869
1031
|
restartAttempts.current = 0;
|
|
870
1032
|
restartCycles.current = 0;
|
|
871
1033
|
cancelRestart();
|
|
1034
|
+
cancelFirstFrame();
|
|
872
1035
|
update({ status: 'live', presented: true, failure: null });
|
|
1036
|
+
// A first frame is a picture, so from here the client
|
|
1037
|
+
// watches that the picture keeps coming.
|
|
1038
|
+
armMedia();
|
|
873
1039
|
return;
|
|
874
1040
|
case 'control': {
|
|
875
1041
|
const reply = parseControlReply(String(event.payload.message ?? ''));
|
|
@@ -955,7 +1121,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
955
1121
|
dropped = true;
|
|
956
1122
|
subscription?.remove();
|
|
957
1123
|
};
|
|
958
|
-
}, [transportFailed, onTransportState, cancelRestart, spendRestartAttempt, notePing, teardown, update, pressKey, typeText]);
|
|
1124
|
+
}, [transportFailed, onTransportState, cancelRestart, spendRestartAttempt, notePing, teardown, update, pressKey, typeText, armMedia]);
|
|
959
1125
|
|
|
960
1126
|
const copyRemoteToLocal = useCallback(async (writeLocal: (text: string) => Promise<void>) => {
|
|
961
1127
|
if (!inputEnabled.current) throw new Error('Desktop control is off.');
|
|
@@ -1031,6 +1197,9 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
1031
1197
|
cancelReconnect();
|
|
1032
1198
|
cancelRestart();
|
|
1033
1199
|
cancelStall();
|
|
1200
|
+
cancelFirstFrame();
|
|
1201
|
+
if (mediaTimer.current !== null) clearTimeout(mediaTimer.current);
|
|
1202
|
+
mediaTimer.current = null;
|
|
1034
1203
|
generationToken.current += 1;
|
|
1035
1204
|
const id = nativeRef.current;
|
|
1036
1205
|
nativeRef.current = null;
|