@desklink/react-native 0.5.2 → 0.5.4
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 +52 -12
- package/android/build.gradle +0 -1
- package/android/src/main/java/expo/modules/desklink/DesklinkModule.kt +4 -0
- package/android/src/main/java/expo/modules/desklink/DesktopSession.kt +20 -12
- 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.tsx +15 -6
- package/src/DesktopView.tsx +8 -2
- package/src/native.ios.ts +12 -7
- package/src/native.web.ts +12 -2
- package/src/protocol.ts +10 -2
- package/src/useDesktopSession.ts +396 -136
- package/src/DesktopView.ios.spec.tsx +0 -529
- package/src/controls.spec.tsx +0 -205
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<p align="center">
|
|
4
4
|
<a href="https://www.npmjs.com/package/@desklink/react-native"><img alt="npm" src="https://img.shields.io/npm/v/@desklink/react-native?style=flat&label=npm" /></a>
|
|
5
5
|
<a href="https://github.com/umeranjum17/desklink/actions/workflows/ci.yml"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/umeranjum17/desklink/ci.yml?style=flat&branch=main" /></a>
|
|
6
|
-
<a href="LICENSE"><img alt="Apache 2.0" src="https://img.shields.io/badge/license-Apache--2.0-666?style=flat" /></a>
|
|
6
|
+
<a href="https://github.com/umeranjum17/desklink/blob/main/packages/desktop-client/LICENSE"><img alt="Apache 2.0" src="https://img.shields.io/badge/license-Apache--2.0-666?style=flat" /></a>
|
|
7
7
|
<img alt="Android, iOS and web" src="https://img.shields.io/badge/Android%20%7C%20iOS%20%7C%20web-111?style=flat" />
|
|
8
8
|
</p>
|
|
9
9
|
|
|
@@ -20,7 +20,7 @@ the application.
|
|
|
20
20
|
## Install
|
|
21
21
|
|
|
22
22
|
The npm badge at the top of this page tracks the registry version, and npm
|
|
23
|
-
verifies package integrity on install. See the [changelog](
|
|
23
|
+
verifies package integrity on install. See the [changelog](https://github.com/umeranjum17/desklink/blob/main/CHANGELOG.md)
|
|
24
24
|
for release changes. Supported platforms: Android, iOS and web.
|
|
25
25
|
|
|
26
26
|
The package contains native code and is not an Expo Go package. Inside this
|
|
@@ -156,7 +156,8 @@ view.
|
|
|
156
156
|
VP9. Neither platform links a second WebRTC stack.
|
|
157
157
|
- **A sharp, zoomable picture.** In portrait the default is fit-width;
|
|
158
158
|
in landscape it covers the screen below the status bar (as far as the 2.5
|
|
159
|
-
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
|
|
160
161
|
content; pinch to zoom before panning when the whole desktop already fits.
|
|
161
162
|
Pass the screen's safe-area `insets` (for example from
|
|
162
163
|
`react-native-safe-area-context`) so the default starts below the status bar
|
|
@@ -230,17 +231,22 @@ view.
|
|
|
230
231
|
instead of sitting at 44 pt. An app may draw its own keys
|
|
231
232
|
from `modifiers` instead.
|
|
232
233
|
- **Readiness is a rendered frame.** Android marks the first draw; iOS marks
|
|
233
|
-
the first `RTCView` video-dimensions callback and ignores later ones
|
|
234
|
-
|
|
235
|
-
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.
|
|
236
238
|
- **Connection states, as they happen.** The session status is `idle`,
|
|
237
239
|
`opening`, `connecting`, `live`, `reconnecting`, `ended` or `failed`. The
|
|
238
240
|
peer's ICE state and the engine's `session.state` both feed it: a
|
|
239
241
|
`disconnected` path reads as `reconnecting` within about a second of the
|
|
240
242
|
drop — never a frozen picture that still says live — and a recovered path
|
|
241
243
|
reads as `live` again once the picture is advancing again (ICE `connected`
|
|
242
|
-
alone does not restore `live` while the picture is frozen).
|
|
243
|
-
`failed`
|
|
244
|
+
alone does not restore `live` while the picture is frozen). The peer's own ICE
|
|
245
|
+
`failed` — a lost media path, not a verdict on the session — holds the session
|
|
246
|
+
and its handle for ~30 s (the engine keeps it for about 28 s after the path
|
|
247
|
+
dies), so a roam longer than the client's own ~15 s `failed` still returns to
|
|
248
|
+
the same session; an SDP or candidate failure, which means the negotiation
|
|
249
|
+
itself is broken, reopens with fresh authority instead of staying stuck.
|
|
244
250
|
Every event carries its engine session id where the carrier preserves it,
|
|
245
251
|
and the session ignores anything naming a session it no longer holds, so a
|
|
246
252
|
previous generation's queued offer or revocation cannot corrupt or kill a
|
|
@@ -270,7 +276,28 @@ view.
|
|
|
270
276
|
reopens back off across ~60 s too instead of spending one attempt while the
|
|
271
277
|
network is still down. Renegotiation transients from a restart the session
|
|
272
278
|
asked for itself are expected, not a new outage: they neither flip the
|
|
273
|
-
status nor trigger another restart for a short grace window.
|
|
279
|
+
status nor trigger another restart for a short grace window. A picture
|
|
280
|
+
that keeps moving holds off the heartbeat watchdog for up to ~20 s, since
|
|
281
|
+
the control channel lags the video after an outage.
|
|
282
|
+
- **Roaming keeps the session.** The signalling carrier is not the session:
|
|
283
|
+
when it closes, or a restart request on it gets no reply within 1.5 s, the
|
|
284
|
+
session drops that carrier for good (calling its optional `close()`) and
|
|
285
|
+
keeps calling `authorize()` — one dial at a time, 250 ms after a failure,
|
|
286
|
+
5 s each, with no budget and no backoff. The lost carrier does not change the
|
|
287
|
+
status by itself: the status follows the picture and the peer's transport, so
|
|
288
|
+
it reads `reconnecting` only once the picture stops or the stall watchdog
|
|
289
|
+
fires. While the loop runs, a session that has shown a picture never reports
|
|
290
|
+
`failed` or a transport failure; one that never showed a picture reports the
|
|
291
|
+
first-frame transport failure after 20 s and the loop stops. A new carrier
|
|
292
|
+
picks up the same session after a 300 ms grace for the old ICE pair: if the
|
|
293
|
+
picture advanced during it, there is no restart (and a `reconnecting`
|
|
294
|
+
session returns to `live`); otherwise the session moves to `reconnecting`
|
|
295
|
+
and asks for an ICE restart. Failures caused
|
|
296
|
+
by the dead carrier wait for the next one rather than ending the session.
|
|
297
|
+
A carrier that comes back after the host's 30 s reattach window opens a new
|
|
298
|
+
session at once. A `session` or `generation` refusal (the desktop no longer
|
|
299
|
+
holds the session) also reopens at once. An `authorize()` refusal still ends
|
|
300
|
+
the session: a revoked pairing cannot roam back.
|
|
274
301
|
- **Input starts disarmed.** This is a local arm against accidental touches,
|
|
275
302
|
not a permission: the session already has input. `setInputEnabled(true)` arms
|
|
276
303
|
pointer, keyboard and clipboard input; `setInputEnabled(false)` blocks new
|
|
@@ -297,7 +324,7 @@ view.
|
|
|
297
324
|
The one thing the app must supply. It carries the engine's local protocol over
|
|
298
325
|
whatever authenticated channel the app already has.
|
|
299
326
|
|
|
300
|
-
See the [BYOKit integration guidance](
|
|
327
|
+
See the [BYOKit integration guidance](https://github.com/umeranjum17/desklink/blob/main/README.md#why-desklink-exists)
|
|
301
328
|
for the application's pairing, identity and signaling transport.
|
|
302
329
|
|
|
303
330
|
```ts
|
|
@@ -312,6 +339,19 @@ interface Signaling {
|
|
|
312
339
|
candidates, state and revocation. Pixels and input do **not** use this
|
|
313
340
|
channel — they are the desktop's own WebRTC session.
|
|
314
341
|
|
|
342
|
+
`authorize()` must wait for the authenticated carrier to open before returning
|
|
343
|
+
its `Signaling`. It rejects with `code: 'transport'` for a network or carrier
|
|
344
|
+
failure; any other rejection is a refusal that ends the session, so a roam only
|
|
345
|
+
keeps the session when the adapter tags its outages. When that carrier is gone,
|
|
346
|
+
reject pending and future requests with `code: 'transport'`; do not queue them
|
|
347
|
+
indefinitely. An adapter may emit
|
|
348
|
+
`{ kind: 'carrier-closed' }` once to notify subscribers of that loss, and may
|
|
349
|
+
offer `close()`, which the package calls on a carrier it has given up on. This is
|
|
350
|
+
not the native peer's `closed` event or an engine revocation. Preserve the
|
|
351
|
+
engine's `session.revoked` code: `transport` indicates path loss; other codes
|
|
352
|
+
(or no code) are final. The demo adapter bounds connection setup to five seconds
|
|
353
|
+
and closes its socket and pending queue when disposed.
|
|
354
|
+
|
|
315
355
|
## Shipping on iOS
|
|
316
356
|
|
|
317
357
|
What the consuming app — not this package — must get right before review.
|
|
@@ -323,7 +363,7 @@ What the consuming app — not this package — must get right before review.
|
|
|
323
363
|
`NSBonjourServices` is only needed if the app adds its own Bonjour
|
|
324
364
|
discovery — desklink discovers no hosts. Relay-only sessions never prompt.
|
|
325
365
|
For app-supplied routes, see the
|
|
326
|
-
[BYOKit integration guidance](
|
|
366
|
+
[BYOKit integration guidance](https://github.com/umeranjum17/desklink/blob/main/README.md#why-desklink-exists).
|
|
327
367
|
See [TN3179](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy).
|
|
328
368
|
- **Export compliance.** The viewer's DTLS-SRTP comes from libwebrtc bundled
|
|
329
369
|
with `react-native-webrtc`, not from the OS, so the app must check the
|
|
@@ -384,7 +424,7 @@ the run builds or caches stays in `~/desklink-ios` there. The run creates a
|
|
|
384
424
|
uniquely named simulator and deletes it during cleanup. Set
|
|
385
425
|
`DESKLINK_IOS_DEVICE` to the name or udid of one of the Mac's own simulators to
|
|
386
426
|
run on that instead and leave it in place. For host desktop isolation, see the
|
|
387
|
-
[private-lab guidance](
|
|
427
|
+
[private-lab guidance](https://github.com/umeranjum17/desklink/blob/main/README.md#develop). When the selected
|
|
388
428
|
Xcode has no simulator platform, point `DESKLINK_IOS_XCODE` at one that does;
|
|
389
429
|
`DESKLINK_IOS_RUNTIME` picks the iOS version (for example `18.6`); the
|
|
390
430
|
script's header lists the other settings. To capture the cursor tip proof, set
|
package/android/build.gradle
CHANGED
|
@@ -33,7 +33,6 @@ dependencies {
|
|
|
33
33
|
implementation project(':expo-modules-core')
|
|
34
34
|
implementation 'androidx.core:core-ktx:1.17.0'
|
|
35
35
|
implementation "org.jetbrains.kotlin:kotlin-stdlib:${kotlinVersion}"
|
|
36
|
-
testImplementation 'junit:junit:4.13.2'
|
|
37
36
|
// The app's existing WebRTC binding, so the org.webrtc classes and the single
|
|
38
37
|
// native library are shared rather than a second copy being linked in. This
|
|
39
38
|
// package never mutates that binding's global decoder factory.
|
|
@@ -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 ->
|
|
@@ -111,7 +111,7 @@ class DesktopSession(
|
|
|
111
111
|
emit("ready", emptyMap())
|
|
112
112
|
} catch (error: Throwable) {
|
|
113
113
|
Log.w(TAG, "Could not start the desktop session", error)
|
|
114
|
-
fail("transport", error.message ?: "the desktop session could not start")
|
|
114
|
+
fail("transport", error.message ?: "the desktop session could not start", "session")
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
117
|
}
|
|
@@ -146,7 +146,7 @@ class DesktopSession(
|
|
|
146
146
|
val connection = peer ?: return@execute
|
|
147
147
|
val target = epoch
|
|
148
148
|
if (type != "offer") {
|
|
149
|
-
fail("transport", "the engine must send an offer")
|
|
149
|
+
fail("transport", "the engine must send an offer", "sdp")
|
|
150
150
|
return@execute
|
|
151
151
|
}
|
|
152
152
|
connection.setRemoteDescription(
|
|
@@ -171,23 +171,23 @@ class DesktopSession(
|
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
override fun onCreateFailure(error: String?) = fail("transport", error)
|
|
175
|
-
override fun onSetFailure(error: String?) = fail("transport", error)
|
|
174
|
+
override fun onCreateFailure(error: String?) = fail("transport", error, "sdp")
|
|
175
|
+
override fun onSetFailure(error: String?) = fail("transport", error, "sdp")
|
|
176
176
|
},
|
|
177
177
|
answer,
|
|
178
178
|
)
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
override fun onSetSuccess() = Unit
|
|
182
|
-
override fun onCreateFailure(error: String?) = fail("transport", error)
|
|
183
|
-
override fun onSetFailure(error: String?) = fail("transport", error)
|
|
182
|
+
override fun onCreateFailure(error: String?) = fail("transport", error, "sdp")
|
|
183
|
+
override fun onSetFailure(error: String?) = fail("transport", error, "sdp")
|
|
184
184
|
},
|
|
185
185
|
MediaConstraints(),
|
|
186
186
|
)
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
-
override fun onCreateFailure(error: String?) = fail("transport", error)
|
|
190
|
-
override fun onSetFailure(error: String?) = fail("transport", error)
|
|
189
|
+
override fun onCreateFailure(error: String?) = fail("transport", error, "sdp")
|
|
190
|
+
override fun onSetFailure(error: String?) = fail("transport", error, "sdp")
|
|
191
191
|
},
|
|
192
192
|
SessionDescription(SessionDescription.Type.OFFER, sdp),
|
|
193
193
|
)
|
|
@@ -233,7 +233,7 @@ class DesktopSession(
|
|
|
233
233
|
ByteBuffer.wrap(message.toString().toByteArray(StandardCharsets.UTF_8)), false,
|
|
234
234
|
))
|
|
235
235
|
if (!sent) {
|
|
236
|
-
fail("transport", "the desktop control channel could not send")
|
|
236
|
+
fail("transport", "the desktop control channel could not send", "control")
|
|
237
237
|
close()
|
|
238
238
|
}
|
|
239
239
|
}
|
|
@@ -425,7 +425,7 @@ class DesktopSession(
|
|
|
425
425
|
ui.postDelayed(sample, 2000)
|
|
426
426
|
}
|
|
427
427
|
if (state == PeerConnection.IceConnectionState.FAILED) {
|
|
428
|
-
fail("transport", "the connection to the desktop was lost")
|
|
428
|
+
fail("transport", "the connection to the desktop was lost", "ice")
|
|
429
429
|
}
|
|
430
430
|
}
|
|
431
431
|
|
|
@@ -495,8 +495,16 @@ class DesktopSession(
|
|
|
495
495
|
ui.post { onEvent(name, payload) }
|
|
496
496
|
}
|
|
497
497
|
|
|
498
|
-
|
|
499
|
-
|
|
498
|
+
/**
|
|
499
|
+
* One native failure, with its `reason` so the hook can tell an ICE failure
|
|
500
|
+
* (the peer's path died, which the session can be held across) from an SDP,
|
|
501
|
+
* candidate or control failure (the session itself is broken, which stays
|
|
502
|
+
* terminal).
|
|
503
|
+
*/
|
|
504
|
+
private fun fail(code: String, message: String?, reason: String? = null) {
|
|
505
|
+
val payload = mutableMapOf<String, Any?>("code" to code, "message" to (message ?: "unknown"))
|
|
506
|
+
if (reason != null) payload["reason"] = reason
|
|
507
|
+
ui.post { onEvent("failure", payload) }
|
|
500
508
|
}
|
|
501
509
|
|
|
502
510
|
companion object {
|
|
@@ -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
package/src/DesktopView.ios.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
-
import { Image, Keyboard, StyleSheet, TextInput, View, type GestureResponderEvent, type LayoutChangeEvent, type ViewProps } from 'react-native';
|
|
2
|
+
import { Image, Keyboard, StyleSheet, TextInput, View, useWindowDimensions, type GestureResponderEvent, type LayoutChangeEvent, type ViewProps } from 'react-native';
|
|
3
3
|
import pointerArrow from './pointer.png';
|
|
4
4
|
import { requireNativeViewManager, requireOptionalNativeModule } from 'expo-modules-core';
|
|
5
5
|
import type { DesktopViewProps } from './DesktopView';
|
|
@@ -67,7 +67,7 @@ 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 });
|
|
@@ -145,18 +145,27 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
|
|
|
145
145
|
const visibleBottom = Math.min(top + safeHeight, bounds.height - (keyboardOverlap ? keyboardOverlap + keyboardClearance : 0));
|
|
146
146
|
const visibleHeight = Math.max(1, visibleBottom - top);
|
|
147
147
|
const fit = size.width && size.height ? Math.min(safeWidth / size.width, visibleHeight / size.height) : 1;
|
|
148
|
-
|
|
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
|
|
149
152
|
? safeWidth / size.width
|
|
150
153
|
: Math.max(bounds.width / size.width, (bounds.height - top) / size.height);
|
|
151
154
|
const fill = size.width && size.height ? Math.max(fit, Math.min(MAX_SCALE, defaultScale)) : 1;
|
|
152
155
|
const scale = zoom === null ? fill : fit * zoom;
|
|
153
156
|
/** The zoom over the whole-desktop fit the picture is at now. */
|
|
154
157
|
const zoomed = fit > 0 ? scale / fit : 1;
|
|
155
|
-
// A new desktop size fills the view again, and a
|
|
156
|
-
//
|
|
158
|
+
// A new desktop size fills the view again, and a rotation starts again at
|
|
159
|
+
// the desktop's top-left corner, where a page's first words are. Only an
|
|
160
|
+
// orientation change resets it: the application making room for the
|
|
161
|
+
// keyboard also changes the view, by taking height away, and that must
|
|
162
|
+
// move the picture (below) rather than snap it back to the corner and
|
|
163
|
+
// leave the field the tap just put the caret in hidden under the keyboard.
|
|
164
|
+
const window = useWindowDimensions();
|
|
165
|
+
const portrait = window.width <= window.height;
|
|
157
166
|
React.useEffect(() => { setZoom(null); setOffset(TOP_LEFT); }, [size.width, size.height]);
|
|
158
167
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
159
|
-
React.useEffect(() => { if (zoom === null) setOffset(TOP_LEFT); }, [
|
|
168
|
+
React.useEffect(() => { if (zoom === null) setOffset(TOP_LEFT); }, [portrait]);
|
|
160
169
|
const pictureWidth = size.width * scale;
|
|
161
170
|
const pictureHeight = size.height * scale;
|
|
162
171
|
const point = (x: number, y: number, clamp = false): Point | null => {
|
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({
|