@desklink/react-native 0.3.0 → 0.3.1

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
@@ -130,11 +130,12 @@ heat and battery.
130
130
  stamps every frame alike, so the iOS peer declines that hint and keeps the
131
131
  receiver's usual jitter buffer. `fitToView()` shows the whole desktop again.
132
132
  - **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.
133
+ somewhere, the view draws an outlined arrow there at a readable size, over a
134
+ picture whose own cursor is a few pixels tall or not captured at all. On iOS,
135
+ the arrow tip marks the desktop pointer position. Android hides the extra
136
+ mark when a mouse is attached, iOS when an iPad trackpad or mouse drives it,
137
+ and the `device` profile draws no mark on any platform — the screen being
138
+ touched shows the finger itself.
138
139
  - **The picture above the keyboard.** While the phone's keyboard is up, the
139
140
  picture sits above it and above the room the app keeps for its own controls
140
141
  (`keyboardClearance`). Android follows the keyboard animation and keeps the
@@ -290,15 +291,24 @@ a scroll:
290
291
 
291
292
  ```sh
292
293
  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
294
+ env -u DISPLAY -u WAYLAND_DISPLAY DESKLINK_IOS_MAC=user@mac npm run test:ios --workspace @desklink/react-native
294
295
  ```
295
296
 
296
297
  The Mac needs Xcode with an iOS simulator runtime, CocoaPods, node and
297
298
  [`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
299
+ the run builds or caches stays in `~/desklink-ios` there. The run creates a
300
+ uniquely named simulator and deletes it during cleanup. For host desktop isolation, see the
301
+ [private-lab guidance](../../README.md#develop). When the selected
299
302
  Xcode has no simulator platform, point `DESKLINK_IOS_XCODE` at one that does;
300
303
  `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:
304
+ script's header lists the other settings. To capture the cursor tip proof, set
305
+ `DESKLINK_IOS_POINTER_PROOF=before` or `after`; the run writes a 4x crop, device
306
+ frame, proof metadata, and host click log under `DESKLINK_IOS_OUT`. Set
307
+ `DESKLINK_IOS_APP` to reuse an app bundle under `DESKLINK_IOS_DIR` when running
308
+ with `DESKLINK_IOS_SKIP_BUILD=1`. Run once with `before` before the change and
309
+ once with `after` after it, on both iPhone and iPad simulators, to compare the
310
+ pointer crop and verify the host click is within two desktop pixels of the tip.
311
+ To try the example by hand against any bridge:
302
312
 
303
313
  ```sh
304
314
  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.3.1",
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
@@ -41,6 +41,7 @@ export interface SurfaceGeometry {
41
41
  }
42
42
 
43
43
  export interface SessionOpenRequest {
44
+ cursor?: 'embedded' | 'hidden';
44
45
  permissions: Permission[];
45
46
  maxWidth?: number;
46
47
  maxHeight?: number;
@@ -52,6 +53,7 @@ export interface SessionOpenRequest {
52
53
  }
53
54
 
54
55
  export interface SessionOpenResult {
56
+ cursor?: { mode: 'embedded' | 'hidden' | 'metadata' | 'unavailable'; positions: boolean };
55
57
  sessionId: string;
56
58
  generation: number;
57
59
  source: { kind: string; width: number; height: number; origin: { x: number; y: number } };
@@ -69,6 +71,14 @@ export interface RtcCandidate {
69
71
  sdpMLineIndex?: number | null;
70
72
  }
71
73
 
74
+ export interface CursorSample {
75
+ sessionId: string;
76
+ x: number;
77
+ y: number;
78
+ visible: boolean;
79
+ timestamp_us: number;
80
+ }
81
+
72
82
  /**
73
83
  * Engine → client lifecycle events, already unwrapped from the local protocol.
74
84
  *
@@ -81,6 +91,7 @@ export interface RtcCandidate {
81
91
  export type SessionEvent =
82
92
  | { kind: 'description'; description: RtcDescription; sessionId?: string }
83
93
  | { kind: 'candidate'; candidate: RtcCandidate; sessionId?: string }
94
+ | ({ kind: 'cursor' } & CursorSample)
84
95
  | { kind: 'state'; capture: string; transport: string; firstFrame: boolean; sessionId?: string }
85
96
  | { kind: 'restoreToken'; token: string; sessionId?: string }
86
97
  /**
@@ -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,
@@ -178,6 +179,7 @@ export interface DesktopSessionOptions {
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;
@@ -707,6 +719,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
707
719
  openedResult = await authorization.signaling.request<SessionOpenResult>('session.open', {
708
720
  permissions: authorization.session.permissions,
709
721
  max_width: authorization.session.maxWidth,
722
+ ...(authorization.session.cursor === undefined ? {} : { cursor: authorization.session.cursor }),
710
723
  max_height: authorization.session.maxHeight,
711
724
  bitrate_kbps: authorization.session.bitrateKbps,
712
725
  max_fps: authorization.session.maxFps,
@@ -725,6 +738,9 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
725
738
  try {
726
739
  if (token !== generationToken.current) return;
727
740
  opened.current = openedResult;
741
+ for (const sample of pendingCursorEvents.current.splice(0)) {
742
+ if (sample.sessionId === openedResult.sessionId) optionsRef.current.onCursor?.(sample);
743
+ }
728
744
  update({ geometry: openedResult.geometry, status: 'connecting' });
729
745
  if (token !== generationToken.current) return;
730
746