@desklink/react-native 0.5.3 → 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
@@ -241,8 +241,12 @@ view.
241
241
  `disconnected` path reads as `reconnecting` within about a second of the
242
242
  drop — never a frozen picture that still says live — and a recovered path
243
243
  reads as `live` again once the picture is advancing again (ICE `connected`
244
- alone does not restore `live` while the picture is frozen). A path that reports
245
- `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.
246
250
  Every event carries its engine session id where the carrier preserves it,
247
251
  and the session ignores anything naming a session it no longer holds, so a
248
252
  previous generation's queued offer or revocation cannot corrupt or kill a
@@ -272,7 +276,28 @@ view.
272
276
  reopens back off across ~60 s too instead of spending one attempt while the
273
277
  network is still down. Renegotiation transients from a restart the session
274
278
  asked for itself are expected, not a new outage: they neither flip the
275
- 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.
276
301
  - **Input starts disarmed.** This is a local arm against accidental touches,
277
302
  not a permission: the session already has input. `setInputEnabled(true)` arms
278
303
  pointer, keyboard and clipboard input; `setInputEnabled(false)` blocks new
@@ -299,7 +324,7 @@ view.
299
324
  The one thing the app must supply. It carries the engine's local protocol over
300
325
  whatever authenticated channel the app already has.
301
326
 
302
- 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)
303
328
  for the application's pairing, identity and signaling transport.
304
329
 
305
330
  ```ts
@@ -315,9 +340,13 @@ candidates, state and revocation. Pixels and input do **not** use this
315
340
  channel — they are the desktop's own WebRTC session.
316
341
 
317
342
  `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
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
321
350
  not the native peer's `closed` event or an engine revocation. Preserve the
322
351
  engine's `session.revoked` code: `transport` indicates path loss; other codes
323
352
  (or no code) are final. The demo adapter bounds connection setup to five seconds
@@ -334,7 +363,7 @@ What the consuming app — not this package — must get right before review.
334
363
  `NSBonjourServices` is only needed if the app adds its own Bonjour
335
364
  discovery — desklink discovers no hosts. Relay-only sessions never prompt.
336
365
  For app-supplied routes, see the
337
- [BYOKit integration guidance](../../README.md#why-desklink-exists).
366
+ [BYOKit integration guidance](https://github.com/umeranjum17/desklink/blob/main/README.md#why-desklink-exists).
338
367
  See [TN3179](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy).
339
368
  - **Export compliance.** The viewer's DTLS-SRTP comes from libwebrtc bundled
340
369
  with `react-native-webrtc`, not from the OS, so the app must check the
@@ -395,7 +424,7 @@ the run builds or caches stays in `~/desklink-ios` there. The run creates a
395
424
  uniquely named simulator and deletes it during cleanup. Set
396
425
  `DESKLINK_IOS_DEVICE` to the name or udid of one of the Mac's own simulators to
397
426
  run on that instead and leave it in place. For host desktop isolation, see the
398
- [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
399
428
  Xcode has no simulator platform, point `DESKLINK_IOS_XCODE` at one that does;
400
429
  `DESKLINK_IOS_RUNTIME` picks the iOS version (for example `18.6`); the
401
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.
@@ -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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desklink/react-native",
3
- "version": "0.5.3",
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';
@@ -155,11 +155,17 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
155
155
  const scale = zoom === null ? fill : fit * zoom;
156
156
  /** The zoom over the whole-desktop fit the picture is at now. */
157
157
  const zoomed = fit > 0 ? scale / fit : 1;
158
- // A new desktop size fills the view again, and a new fill (a rotation)
159
- // 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;
160
166
  React.useEffect(() => { setZoom(null); setOffset(TOP_LEFT); }, [size.width, size.height]);
161
167
  // eslint-disable-next-line react-hooks/exhaustive-deps
162
- 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]);
163
169
  const pictureWidth = size.width * scale;
164
170
  const pictureHeight = size.height * scale;
165
171
  const point = (x: number, y: number, clamp = false): Point | null => {
package/src/native.ios.ts CHANGED
@@ -95,8 +95,13 @@ export function preferH264(sdp: string): string {
95
95
  return lines.join('\r\n');
96
96
  }
97
97
 
98
- function failure(id: string, error: unknown) {
99
- emit(id, 'failure', { code: 'transport', message: error instanceof Error ? error.message : String(error) });
98
+ /**
99
+ * One native failure, with its `reason` so the hook can tell an ICE failure
100
+ * (the peer's path died, which the session can be held across) from an SDP or
101
+ * candidate failure (the negotiation itself is broken, which stays terminal).
102
+ */
103
+ function failure(id: string, error: unknown, reason: 'ice' | 'sdp' | 'candidate' | 'control') {
104
+ emit(id, 'failure', { code: 'transport', reason, message: error instanceof Error ? error.message : String(error) });
100
105
  }
101
106
 
102
107
  export const nativeDesklink: NativeDesklinkModule = {
@@ -121,7 +126,7 @@ export const nativeDesklink: NativeDesklinkModule = {
121
126
  };
122
127
  peer.oniceconnectionstatechange = () => {
123
128
  emit(id, 'ice', { state: peer.iceConnectionState.toUpperCase() });
124
- if (peer.iceConnectionState === 'failed') failure(id, 'the connection to the desktop was lost');
129
+ if (peer.iceConnectionState === 'failed') failure(id, 'the connection to the desktop was lost', 'ice');
125
130
  };
126
131
  peer.ontrack = (event) => {
127
132
  if (session.closed || event.track.kind !== 'video') return;
@@ -148,7 +153,7 @@ export const nativeDesklink: NativeDesklinkModule = {
148
153
  setRemoteDescription(id, type, sdp) {
149
154
  const session = sessions.get(id);
150
155
  if (!session) return false;
151
- if (type !== 'offer') { failure(id, 'the engine must send an offer'); return false; }
156
+ if (type !== 'offer') { failure(id, 'the engine must send an offer', 'sdp'); return false; }
152
157
  void (async () => {
153
158
  try {
154
159
  await session.peer.setRemoteDescription({ type: 'offer', sdp: preferH264(sdp.replace(PLAYOUT_DELAY, '')) });
@@ -158,7 +163,7 @@ export const nativeDesklink: NativeDesklinkModule = {
158
163
  const answer = await session.peer.createAnswer();
159
164
  await session.peer.setLocalDescription(answer);
160
165
  if (!session.closed) emit(id, 'answer', { sdp: session.peer.localDescription?.sdp ?? answer.sdp });
161
- } catch (error) { if (!session.closed) failure(id, error); }
166
+ } catch (error) { if (!session.closed) failure(id, error, 'sdp'); }
162
167
  })();
163
168
  return true;
164
169
  },
@@ -167,7 +172,7 @@ export const nativeDesklink: NativeDesklinkModule = {
167
172
  if (!session) return false;
168
173
  const ice = { candidate, sdpMid, sdpMLineIndex: sdpMLineIndex ?? 0 };
169
174
  if (!session.remoteSet) session.candidates.push(ice);
170
- else void session.peer.addIceCandidate(ice).catch((error) => failure(id, error));
175
+ else void session.peer.addIceCandidate(ice).catch((error) => failure(id, error, 'candidate'));
171
176
  return true;
172
177
  },
173
178
  sendControl(id, message) {
@@ -178,7 +183,7 @@ export const nativeDesklink: NativeDesklinkModule = {
178
183
  if (!session.input && ['pointer', 'wheel', 'key', 'text', 'clipboard_read', 'clipboard_write'].includes(String(control.kind))) return false;
179
184
  session.channel.send(JSON.stringify({ ...control, seq: ++session.seq }));
180
185
  return true;
181
- } catch (error) { failure(id, error); return false; }
186
+ } catch (error) { failure(id, error, 'control'); return false; }
182
187
  },
183
188
  setInputEnabled(id, enabled) {
184
189
  const session = sessions.get(id);
package/src/native.web.ts CHANGED
@@ -1018,7 +1018,7 @@ export const nativeDesklink: NativeDesklinkModule = {
1018
1018
  peer.onconnectionstatechange = () => {
1019
1019
  if (sessions.get(id) !== session) return;
1020
1020
  if (peer.connectionState === 'failed') {
1021
- emit(id, 'failure', { code: 'transport', message: 'the connection to the desktop was lost' });
1021
+ emit(id, 'failure', { code: 'transport', reason: 'ice', message: 'the connection to the desktop was lost' });
1022
1022
  }
1023
1023
  };
1024
1024
  peer.oniceconnectionstatechange = () => {
@@ -1035,7 +1035,7 @@ export const nativeDesklink: NativeDesklinkModule = {
1035
1035
  const session = sessions.get(id);
1036
1036
  if (session === undefined) return false;
1037
1037
  if (type !== 'offer') {
1038
- emit(id, 'failure', { code: 'transport', message: 'the engine must send an offer' });
1038
+ emit(id, 'failure', { code: 'transport', reason: 'sdp', message: 'the engine must send an offer' });
1039
1039
  return false;
1040
1040
  }
1041
1041
  void (async () => {
@@ -1052,6 +1052,7 @@ export const nativeDesklink: NativeDesklinkModule = {
1052
1052
  } catch (error) {
1053
1053
  emit(id, 'failure', {
1054
1054
  code: 'transport',
1055
+ reason: 'sdp',
1055
1056
  message: error instanceof Error ? error.message : 'the desktop refused our answer',
1056
1057
  });
1057
1058
  }
package/src/protocol.ts CHANGED
@@ -122,7 +122,8 @@ export type SessionEvent =
122
122
  * The package calls these and nothing else, so an application can carry them
123
123
  * over its existing encrypted request path, a socket, or an in-process bridge.
124
124
  * `subscribe` delivers engine notifications until the returned function runs.
125
- * The application's authorize() resolves only once this carrier is open.
125
+ * The application's authorize() resolves only once this carrier is open. Its
126
+ * rejection rules (transport vs. refusal) are in the README's Signaling section.
126
127
  */
127
128
  export interface Signaling {
128
129
  /**
@@ -134,6 +135,8 @@ export interface Signaling {
134
135
  */
135
136
  request<T = unknown>(method: string, params?: Record<string, unknown>): Promise<T>;
136
137
  subscribe(handler: (event: SessionEvent) => void): () => void;
138
+ /** Optional: end this carrier now. Called once the package has declared it dead. */
139
+ close?(): void;
137
140
  }
138
141
 
139
142
  export type SessionStatus =
@@ -178,7 +181,7 @@ export interface SessionSnapshot {
178
181
  }
179
182
 
180
183
  /**
181
- * Control-channel messages. `seq` is monotonic per generation; the engine
184
+ * Control-channel messages. `seq` is monotonic per session; the engine
182
185
  * refuses a repeat, so a replayed gesture cannot move the pointer twice.
183
186
  */
184
187
  export type ControlMessage =