@desklink/react-native 0.5.2 → 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 CHANGED
@@ -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,9 +231,10 @@ 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
@@ -312,6 +314,15 @@ interface Signaling {
312
314
  candidates, state and revocation. Pixels and input do **not** use this
313
315
  channel — they are the desktop's own WebRTC session.
314
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
+
315
326
  ## Shipping on iOS
316
327
 
317
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
- 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.3",
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",
@@ -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
  });
@@ -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,7 +145,10 @@ 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;
@@ -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
@@ -103,6 +103,8 @@ export interface CursorSample {
103
103
  * the new session corrupts or kills a healthy recovery.
104
104
  */
105
105
  export type SessionEvent =
106
+ /** Optional carrier notification, distinct from the native peer's `closed`. */
107
+ | { kind: 'carrier-closed'; sessionId?: never }
106
108
  | { kind: 'description'; description: RtcDescription; sessionId?: string }
107
109
  | { kind: 'candidate'; candidate: RtcCandidate; sessionId?: string }
108
110
  | ({ kind: 'cursor' } & CursorSample)
@@ -120,10 +122,13 @@ export type SessionEvent =
120
122
  * The package calls these and nothing else, so an application can carry them
121
123
  * over its existing encrypted request path, a socket, or an in-process bridge.
122
124
  * `subscribe` delivers engine notifications until the returned function runs.
125
+ * The application's authorize() resolves only once this carrier is open.
123
126
  */
124
127
  export interface Signaling {
125
128
  /**
126
- * Send one request. A rejection may carry a stable `code` — the engine's or
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
127
132
  * the host's own token — which the package reads to classify a refusal, so a
128
133
  * refusal is not retried as though it were a network failure.
129
134
  */
@@ -804,7 +804,8 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
804
804
  try {
805
805
  authorization = await optionsRef.current.authorize();
806
806
  } catch (error) {
807
- refuse(error instanceof Error ? error.message : 'the desktop was not authorized', 'permission');
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);
808
809
  return;
809
810
  }
810
811
  if (token !== generationToken.current) return;
@@ -1023,6 +1024,9 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
1023
1024
  // Readiness is not "a track arrived": it is a frame on screen.
1024
1025
  return;
1025
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;
1026
1030
  attempts.current = 0;
1027
1031
  restartAttempts.current = 0;
1028
1032
  restartCycles.current = 0;