@desklink/react-native 0.3.0 → 0.4.0

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
@@ -58,12 +58,12 @@ changes, and trackpad presses reach the desktop as touches.
58
58
  ## Use
59
59
 
60
60
  ```tsx
61
- import { DesktopView, useDesktopSession, CONTROL_PERMISSIONS } from '@desklink/react-native';
61
+ import { DesktopView, useDesktopSession } from '@desklink/react-native';
62
62
 
63
63
  const desktop = useDesktopSession({
64
64
  authorize: async () => ({
65
65
  signaling, // your authenticated channel to the host
66
- session: { permissions: CONTROL_PERMISSIONS },
66
+ session: {},
67
67
  }),
68
68
  });
69
69
 
@@ -78,6 +78,13 @@ desktop.setOrientation('landscape'); // Android only; iOS apps configure suppo
78
78
  await desktop.pasteLocalToRemote(await Clipboard.getStringAsync());
79
79
  ```
80
80
 
81
+ Pairing is the only trust boundary. `authorize` is the app's pairing check: it
82
+ returns a channel to a paired computer or throws, and it runs again on every
83
+ reconnect, so a revoked pairing cannot reopen. A paired session has view,
84
+ input and clipboard; there are no in-app grants, and `session.permissions` is
85
+ ignored. Only the operating system's own consent (screen recording,
86
+ accessibility, a screen-sharing prompt) still applies on the computer.
87
+
81
88
  A screen that only decides *whether* to offer a desktop does not need the
82
89
  session: import the flag from its own entry, which is a constant on web and a
83
90
  check for the platform module elsewhere, so the hook and the view stay out of
@@ -98,7 +105,7 @@ engine only produces frames on change.
98
105
  Keep the encoded picture inside a 1920×1080 box:
99
106
 
100
107
  ```tsx
101
- session: { permissions: CONTROL_PERMISSIONS, maxWidth: 1920, maxHeight: 1080 },
108
+ session: { maxWidth: 1920, maxHeight: 1080 },
102
109
  ```
103
110
 
104
111
  The engine never upscales, so a smaller desktop is sent at its own size and a
@@ -130,11 +137,12 @@ heat and battery.
130
137
  stamps every frame alike, so the iOS peer declines that hint and keeps the
131
138
  receiver's usual jitter buffer. `fitToView()` shows the whole desktop again.
132
139
  - **A pointer a phone can see.** Once a touch has sent the desktop's pointer
133
- somewhere, the view draws it there at a readable size, over a picture whose
134
- own cursor is a few pixels tall or not captured at all. Android hides the
135
- extra mark when a mouse is attached, iOS when an iPad trackpad or mouse
136
- drives it, and the `device` profile draws no mark on any platform — the
137
- screen being touched shows the finger itself.
140
+ somewhere, the view draws an outlined arrow there at a readable size, over a
141
+ picture whose own cursor is a few pixels tall or not captured at all. On iOS,
142
+ the arrow tip marks the desktop pointer position. Android hides the extra
143
+ mark when a mouse is attached, iOS when an iPad trackpad or mouse drives it,
144
+ and the `device` profile draws no mark on any platform — the screen being
145
+ touched shows the finger itself.
138
146
  - **The picture above the keyboard.** While the phone's keyboard is up, the
139
147
  picture sits above it and above the room the app keeps for its own controls
140
148
  (`keyboardClearance`). Android follows the keyboard animation and keeps the
@@ -211,11 +219,13 @@ heat and battery.
211
219
  network is still down. Renegotiation transients from a restart the session
212
220
  asked for itself are expected, not a new outage: they neither flip the
213
221
  status nor trigger another restart for a short grace window.
214
- - **Control starts off.** `setInputEnabled(true)` enables pointer, keyboard and
215
- clipboard input; `setInputEnabled(false)` blocks new input, releases held keys
216
- and buttons, and resets pending gestures. The app decides when to enable it
217
- after a deliberate action and must disable it on background or lost readiness;
218
- a clipboard write already in flight may still finish.
222
+ - **Input starts disarmed.** This is a local arm against accidental touches,
223
+ not a permission: the session already has input. `setInputEnabled(true)` arms
224
+ pointer, keyboard and clipboard input; `setInputEnabled(false)` blocks new
225
+ input, releases held keys and buttons, and resets pending gestures. The app
226
+ decides when to arm it after a deliberate action and must disarm it on
227
+ background or lost readiness; a clipboard write already in flight may still
228
+ finish.
219
229
  - **Released state.** The app disables input on background and unmount;
220
230
  session close and a lost control channel release what the desktop was holding.
221
231
  - **Explicit clipboard.** Two methods, called on a user action while control is
@@ -277,7 +287,11 @@ What the consuming app — not this package — must get right before review.
277
287
 
278
288
  `example/` is the smallest app that shows a desktop: a native (non-Expo Go)
279
289
  build with `react-native-webrtc`, connecting to `desklink-host bridge` with the
280
- URL it is launched with. `test/ios-flow.mjs` builds it for the iOS simulator on
290
+ URL it is launched with. Its status line shows plain messages: `Connected`
291
+ once a frame is presented, `Can’t reach your desktop. Reconnecting…` while
292
+ retrying, and `Your desktop ended this session` after revocation. It never
293
+ displays the bridge URL, pairing token, raw failure text or frame metrics.
294
+ `test/ios-flow.mjs` builds it for the iOS simulator on
281
295
  a Mac over ssh, serves a private Xvfb desktop from this machine through the
282
296
  bridge, and checks that the first frame is presented, that the answer puts
283
297
  H.264 first with NACK when the host offers it (VP9 otherwise; both descriptions
@@ -290,15 +304,24 @@ a scroll:
290
304
 
291
305
  ```sh
292
306
  npm run build && cargo build --manifest-path packages/desktop-host/engine/Cargo.toml
293
- DESKLINK_IOS_MAC=user@mac npm run test:ios --workspace @desklink/react-native
307
+ env -u DISPLAY -u WAYLAND_DISPLAY DESKLINK_IOS_MAC=user@mac npm run test:ios --workspace @desklink/react-native
294
308
  ```
295
309
 
296
310
  The Mac needs Xcode with an iOS simulator runtime, CocoaPods, node and
297
311
  [`axe`](https://github.com/cameroncooke/AXe) on its login `PATH`; everything
298
- the run builds or caches stays in `~/desklink-ios` there. When the selected
312
+ the run builds or caches stays in `~/desklink-ios` there. The run creates a
313
+ uniquely named simulator and deletes it during cleanup. For host desktop isolation, see the
314
+ [private-lab guidance](../../README.md#develop). When the selected
299
315
  Xcode has no simulator platform, point `DESKLINK_IOS_XCODE` at one that does;
300
316
  `DESKLINK_IOS_RUNTIME` picks the iOS version (for example `18.6`); the
301
- script's header lists the other settings. To try the example by hand against any bridge:
317
+ script's header lists the other settings. To capture the cursor tip proof, set
318
+ `DESKLINK_IOS_POINTER_PROOF=before` or `after`; the run writes a 4x crop, device
319
+ frame, proof metadata, and host click log under `DESKLINK_IOS_OUT`. Set
320
+ `DESKLINK_IOS_APP` to reuse an app bundle under `DESKLINK_IOS_DIR` when running
321
+ with `DESKLINK_IOS_SKIP_BUILD=1`. Run once with `before` before the change and
322
+ once with `after` after it, on both iPhone and iPad simulators, to compare the
323
+ pointer crop and verify the host click is within two desktop pixels of the tip.
324
+ To try the example by hand against any bridge:
302
325
 
303
326
  ```sh
304
327
  xcrun simctl launch <device> dev.desklink.example -desklinkUrl 'ws://HOST:PORT/desktop?token=TOKEN'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desklink/react-native",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
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",
@@ -18,6 +18,7 @@ vi.mock('react-native', () => ({
18
18
  Keyboard: { addListener: () => ({ remove: () => undefined }), dismiss: () => undefined },
19
19
  StyleSheet: { create: (styles: unknown) => styles },
20
20
  TextInput: 'TextInput',
21
+ Image: 'Image',
21
22
  View: 'View',
22
23
  }));
23
24
 
@@ -204,15 +205,24 @@ describe('DesktopView.ios parity with DesktopView.kt', () => {
204
205
  });
205
206
 
206
207
  it('puts the cursor mark tip on the pointer with a hotspot', async () => {
207
- const { renderer, grant, move } = await openView();
208
+ const { renderer, sent, grant, move, release } = await openView();
208
209
  grant([touch(195, 420)]);
209
210
  move([touch(250, 430)]);
210
- const cursor = renderer.root.findAllByType('View')[1].props.style as unknown[];
211
+ const image = renderer.root.findByType('Image');
212
+ const cursor = image.props.style as unknown[];
211
213
  const mark = cursor[1] as { left: number; top: number };
212
214
  const desktop = { x: Math.floor(250 / SCALE), y: Math.floor((430 - ORIGIN_Y) / SCALE) };
213
215
  expect(mark.left).toBeCloseTo((desktop.x + 0.5) * SCALE - 3, 1);
214
216
  expect(mark.top).toBeCloseTo(ORIGIN_Y + (desktop.y + 0.5) * SCALE - 3, 1);
215
- expect(renderer.root.findAllByType('View')[1].props.accessible).toBe(false);
217
+ expect(image.props.accessible).toBe(false);
218
+ expect(image.props.pointerEvents).toBe('none');
219
+ release();
220
+ grant([touch(mark.left + 3, mark.top + 3)]);
221
+ release();
222
+ expect(sent.filter((message) => message.kind === 'pointer').slice(-2)).toMatchObject([
223
+ { kind: 'pointer', phase: 'down', ...desktop, button: 1 },
224
+ { kind: 'pointer', phase: 'up', ...desktop, button: 1 },
225
+ ]);
216
226
  });
217
227
 
218
228
  it('exposes the surface to VoiceOver only with a label', async () => {
@@ -1,5 +1,6 @@
1
1
  import * as React from 'react';
2
- import { Keyboard, StyleSheet, TextInput, View, type GestureResponderEvent, type LayoutChangeEvent, type ViewProps } from 'react-native';
2
+ import { Image, Keyboard, StyleSheet, TextInput, View, type GestureResponderEvent, type LayoutChangeEvent, type ViewProps } from 'react-native';
3
+ import pointerArrow from './pointer.png';
3
4
  import { requireNativeViewManager, requireOptionalNativeModule } from 'expo-modules-core';
4
5
  import type { DesktopViewProps } from './DesktopView';
5
6
  import { hardwareKey, type HardwareKey } from './hardwareKeys';
@@ -391,7 +392,7 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
391
392
  {sessionId && stream && RTCView && size.width > 0 && size.height > 0 ?
392
393
  <RTCView pointerEvents="none" streamURL={stream.toURL()} objectFit="contain" style={{ position: 'absolute', left: originX, top: originY, width: pictureWidth, height: pictureHeight }}
393
394
  onDimensionsChange={(event) => { if (event.nativeEvent.width > 0 && event.nativeEvent.height > 0) markDesktopPresented(sessionId); }} /> : placeholder}
394
- {cursor && <View pointerEvents="none" accessible={false} style={[styles.cursor, { left: originX + (cursor.x + 0.5) * scale - CURSOR_HOTSPOT, top: originY + (cursor.y + 0.5) * scale - CURSOR_HOTSPOT }]} />}
395
+ {cursor && <Image source={pointerArrow} pointerEvents="none" accessible={false} style={[styles.cursor, { left: originX + (cursor.x + 0.5) * scale - CURSOR_HOTSPOT, top: originY + (cursor.y + 0.5) * scale - CURSOR_HOTSPOT }]} />}
395
396
  <TextInput ref={input} style={styles.input} value={keyboardText} onChangeText={changeText} autoCorrect={false} autoCapitalize="none" submitBehavior="submit" accessible={false}
396
397
  onSubmitEditing={() => {
397
398
  // While the app holds a sticky modifier, Enter is the app's to
@@ -408,6 +409,6 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
408
409
 
409
410
  const styles = StyleSheet.create({
410
411
  surface: { backgroundColor: '#000', overflow: 'hidden' },
411
- cursor: { position: 'absolute', width: 12, height: 18, backgroundColor: '#fff', borderWidth: 2, borderColor: '#000', transform: [{ rotate: '-30deg' }] },
412
+ cursor: { position: 'absolute', width: 28, height: 28 },
412
413
  input: { position: 'absolute', width: 1, height: 1, opacity: 0, left: 0, bottom: 0 },
413
414
  });
Binary file
@@ -0,0 +1,2 @@
1
+ declare const pointer: import('react-native').ImageSourcePropType;
2
+ export default pointer;
@@ -0,0 +1,7 @@
1
+ <!-- Same outline and (3, 3) tip hotspot as web and Android. Generate the
2
+ iOS density assets with rsvg-convert -w SIZE -h SIZE pointer.svg -o FILE:
3
+ SIZE=28/56/84, FILE=pointer.png/pointer@2x.png/pointer@3x.png. -->
4
+ <svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28">
5
+ <path d="M3 3 L3 22.25 L7.73 17.96 L10.92 25.22 L14 23.9 L10.92 16.86 L17.08 16.86 Z" fill="none" stroke="#fff" stroke-width="2.2" stroke-linejoin="round"/>
6
+ <path d="M3 3 L3 22.25 L7.73 17.96 L10.92 25.22 L14 23.9 L10.92 16.86 L17.08 16.86 Z" fill="#000"/>
7
+ </svg>
Binary file
Binary file
package/src/protocol.ts CHANGED
@@ -16,6 +16,11 @@
16
16
 
17
17
  export const PROTOCOL_VERSION = 3;
18
18
 
19
+ /**
20
+ * @deprecated Pairing is the only trust boundary: a paired session always has
21
+ * view, input and clipboard, so there is nothing to ask for. Kept so 0.3 code
22
+ * compiles; removed at 1.0.
23
+ */
19
24
  export type Permission = 'view' | 'control' | 'clipboard';
20
25
 
21
26
  /**
@@ -41,7 +46,9 @@ export interface SurfaceGeometry {
41
46
  }
42
47
 
43
48
  export interface SessionOpenRequest {
44
- permissions: Permission[];
49
+ cursor?: 'embedded' | 'hidden';
50
+ /** @deprecated Ignored: a paired session always has view, input and clipboard. */
51
+ permissions?: Permission[];
45
52
  maxWidth?: number;
46
53
  maxHeight?: number;
47
54
  bitrateKbps?: number;
@@ -52,6 +59,7 @@ export interface SessionOpenRequest {
52
59
  }
53
60
 
54
61
  export interface SessionOpenResult {
62
+ cursor?: { mode: 'embedded' | 'hidden' | 'metadata' | 'unavailable'; positions: boolean };
55
63
  sessionId: string;
56
64
  generation: number;
57
65
  source: { kind: string; width: number; height: number; origin: { x: number; y: number } };
@@ -69,6 +77,14 @@ export interface RtcCandidate {
69
77
  sdpMLineIndex?: number | null;
70
78
  }
71
79
 
80
+ export interface CursorSample {
81
+ sessionId: string;
82
+ x: number;
83
+ y: number;
84
+ visible: boolean;
85
+ timestamp_us: number;
86
+ }
87
+
72
88
  /**
73
89
  * Engine → client lifecycle events, already unwrapped from the local protocol.
74
90
  *
@@ -81,6 +97,7 @@ export interface RtcCandidate {
81
97
  export type SessionEvent =
82
98
  | { kind: 'description'; description: RtcDescription; sessionId?: string }
83
99
  | { kind: 'candidate'; candidate: RtcCandidate; sessionId?: string }
100
+ | ({ kind: 'cursor' } & CursorSample)
84
101
  | { kind: 'state'; capture: string; transport: string; firstFrame: boolean; sessionId?: string }
85
102
  | { kind: 'restoreToken'; token: string; sessionId?: string }
86
103
  /**
@@ -116,6 +133,7 @@ export type SessionStatus =
116
133
  | 'failed';
117
134
 
118
135
  export type SessionFailure =
136
+ /** The app's `authorize()` refused, or the host did: not paired, or the pairing was revoked. */
119
137
  | { code: 'permission'; message: string }
120
138
  /** Nobody approved the desktop's own screen-sharing prompt in time. */
121
139
  | { code: 'consent'; message: string }
@@ -135,6 +153,12 @@ export interface SessionSnapshot {
135
153
  geometry: SurfaceGeometry | null;
136
154
  /** True once an authorized, current-generation frame has actually rendered. */
137
155
  presented: boolean;
156
+ /**
157
+ * `message` is free text from the app's `authorize()`, the host or the
158
+ * engine and may contain a bridge URL or pairing token. Never display it
159
+ * to people; redact sensitive data before logging it. Choose user-facing
160
+ * text from the session state and `code` instead.
161
+ */
138
162
  failure: SessionFailure | null;
139
163
  /** Redacted diagnostics: codec, route, counters. Never identities. */
140
164
  diagnostics: Record<string, string | number | boolean>;
@@ -4,6 +4,7 @@ import { AppState } from 'react-native';
4
4
  import type { NativeDesklinkModule } from './native';
5
5
  import type {
6
6
  ControlMessage,
7
+ CursorSample,
7
8
  IceServerConfig,
8
9
  Permission,
9
10
  SessionFailure,
@@ -171,13 +172,14 @@ async function loadPlatform(): Promise<boolean> {
171
172
 
172
173
  export interface DesktopSessionOptions {
173
174
  /**
174
- * Ask the owning application for a fresh, short-lived authorization and the
175
- * authenticated channel to the host engine. Called for the first connection
176
- * and again for a reconnect: the package never reuses authority it was not
177
- * granted again.
175
+ * The app's pairing check: return the authenticated channel to the host
176
+ * engine of a paired computer, or throw. Pairing is the only trust
177
+ * boundary, so a paired session has view, input and clipboard. Called again
178
+ * on every reconnect, so a revoked pairing cannot reopen.
178
179
  */
179
180
  authorize: () => Promise<{ signaling: Signaling; session: SessionOpenRequest }>;
180
181
  onStateChange?: (snapshot: SessionSnapshot) => void;
182
+ onCursor?: (sample: CursorSample) => void;
181
183
  onError?: (failure: SessionFailure) => void;
182
184
  /**
183
185
  * The engine refused one input message and the session carries on, such as
@@ -253,6 +255,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
253
255
  * before the native session exists waits here and is replayed into it.
254
256
  */
255
257
  const pendingOffer = useRef<{ sdp: string; sessionId?: string } | null>(null);
258
+ const pendingCursorEvents = useRef<CursorSample[]>([]);
256
259
  const pendingCandidates = useRef<Array<{ candidate: string; sdpMid: string | null; sdpMLineIndex: number | null; sessionId?: string }>>([]);
257
260
  const attempts = useRef(0);
258
261
  /**
@@ -572,6 +575,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
572
575
  pendingClipboard.current.clear();
573
576
  pendingOffer.current = null;
574
577
  pendingCandidates.current = [];
578
+ pendingCursorEvents.current = [];
575
579
  restartGraceUntil.current = 0;
576
580
  restartCycles.current = 0;
577
581
  opened.current = null;
@@ -615,6 +619,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
615
619
  const establish = useCallback(async () => {
616
620
  if (nativeRef.current != null) return;
617
621
  const token = ++generationToken.current;
622
+ pendingCursorEvents.current = [];
618
623
  if (!(await loadPlatform())) {
619
624
  if (token === generationToken.current) refuse('this build cannot show a desktop surface');
620
625
  return;
@@ -672,6 +677,13 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
672
677
  event.candidate.sdpMLineIndex ?? null,
673
678
  );
674
679
  return;
680
+ case 'cursor':
681
+ if (held == null) {
682
+ pendingCursorEvents.current.push(event);
683
+ return;
684
+ }
685
+ if (current) optionsRef.current.onCursor?.(event);
686
+ return;
675
687
  case 'state':
676
688
  if (!current) return;
677
689
  diagnostics.current.transport = event.transport;
@@ -705,8 +717,12 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
705
717
  let openedResult: SessionOpenResult;
706
718
  try {
707
719
  openedResult = await authorization.signaling.request<SessionOpenResult>('session.open', {
708
- permissions: authorization.session.permissions,
720
+ // Ignored by 0.4+ engines, whatever the app asked for. A 0.3
721
+ // engine refuses an open without `view`, so the full set stays
722
+ // on the wire until 0.3 hosts leave support.
723
+ permissions: ['view', 'control', 'clipboard'],
709
724
  max_width: authorization.session.maxWidth,
725
+ ...(authorization.session.cursor === undefined ? {} : { cursor: authorization.session.cursor }),
710
726
  max_height: authorization.session.maxHeight,
711
727
  bitrate_kbps: authorization.session.bitrateKbps,
712
728
  max_fps: authorization.session.maxFps,
@@ -725,6 +741,9 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
725
741
  try {
726
742
  if (token !== generationToken.current) return;
727
743
  opened.current = openedResult;
744
+ for (const sample of pendingCursorEvents.current.splice(0)) {
745
+ if (sample.sessionId === openedResult.sessionId) optionsRef.current.onCursor?.(sample);
746
+ }
728
747
  update({ geometry: openedResult.geometry, status: 'connecting' });
729
748
  if (token !== generationToken.current) return;
730
749
 
@@ -1090,5 +1109,5 @@ function randomId(): string {
1090
1109
  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
1091
1110
  }
1092
1111
 
1093
- /** The permissions a normal remote-control session asks for. */
1112
+ /** @deprecated Ignored: a paired session always has view, input and clipboard. Removed at 1.0. */
1094
1113
  export const CONTROL_PERMISSIONS: Permission[] = ['view', 'control', 'clipboard'];