@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 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](../../CHANGELOG.md)
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 picture stays undistorted. One finger pans cropped
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, so a
234
- resize or remount during a reconnect does not read as live. Neither marks
235
- a track or ICE connection as live.
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). A path that reports
243
- `failed` reopens the session with fresh authority instead of staying stuck.
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](../../README.md#why-desklink-exists)
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](../../README.md#why-desklink-exists).
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](../../README.md#develop). When the selected
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
@@ -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
- private fun fail(code: String, message: String?) {
499
- ui.post { onEvent("failure", mapOf("code" to code, "message" to (message ?: "unknown"))) }
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
- val cover = if (width <= height) safeWidth() / surfaceWidth
463
- else max(width.toFloat() / surfaceWidth, (height - insetTop) / surfaceHeight)
464
- return max(fitScale(), min(cover, MAX_SCALE))
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
- module.exports = function withDesklinkPointer(config) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desklink/react-native",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Render and drive a desktop session from a React Native app",
6
6
  "main": "index.ts",
@@ -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
- const defaultScale = bounds.width <= bounds.height
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 new fill (a rotation)
156
- // starts again, at the desktop's top-left corner, where a page's first words are.
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); }, [fill, bounds.width, bounds.height, top, left, insets?.right, insets?.bottom]);
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 => {
@@ -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({