@desklink/react-native 0.5.0 → 0.5.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
@@ -30,6 +30,9 @@ repository it resolves through the workspace; anywhere else, install it from npm
30
30
  npx expo install @desklink/react-native # or yarn add, then prebuild/rebuild
31
31
  ```
32
32
 
33
+ Expo SDK 55 and later are supported, including the SDK a fresh
34
+ `npx create-expo-app` resolves today: no SDK pin is needed.
35
+
33
36
  Android uses an Expo native module; iOS uses the app's existing
34
37
  `react-native-webrtc` peer connection and `RTCView` from JavaScript, plus a small
35
38
  Expo module of its own for the hardware keyboard and an iPad pointer. Both need a
@@ -59,6 +62,7 @@ changes, and trackpad presses reach the desktop as touches.
59
62
 
60
63
  ```tsx
61
64
  import { DesktopView, useDesktopSession } from '@desklink/react-native';
65
+ import * as Clipboard from 'expo-clipboard';
62
66
 
63
67
  const desktop = useDesktopSession({
64
68
  authorize: async () => ({
@@ -75,6 +79,9 @@ await desktop.connect();
75
79
  desktop.setInputEnabled(true);
76
80
  desktop.showKeyboard();
77
81
  desktop.setOrientation('landscape'); // Android only; iOS apps configure supported orientations themselves
82
+ await desktop.copyRemoteToLocal(async (text) => {
83
+ if (!await Clipboard.setStringAsync(text)) throw new Error('Clipboard write refused');
84
+ });
78
85
  await desktop.pasteLocalToRemote(await Clipboard.getStringAsync());
79
86
  // Optional receiver evidence: codec, decoder and video counters; no ICE addresses.
80
87
  const stats = await desktop.getStats();
@@ -135,7 +142,9 @@ heat and battery.
135
142
  content; pinch to zoom before panning when the whole desktop already fits.
136
143
  Pass the screen's safe-area `insets` (for example from
137
144
  `react-native-safe-area-context`) so the default starts below the status bar
138
- and beside a camera cutout. Panning brings each desktop edge inside the safe
145
+ and beside a camera cutout. `onPictureFrame` reports where the picture
146
+ ended whenever it moves, so a tall screen's band below it can carry the
147
+ application's own controls instead of empty space. Panning brings each desktop edge inside the safe
139
148
  area, while landscape coverage extends beneath the cutout and home indicator
140
149
  to limit black bars. A pinch zooms up to 2.5 view
141
150
  pixels per desktop pixel, or out to the whole desktop. On Android the decoded frame is copied once into the view's
@@ -191,7 +200,15 @@ heat and battery.
191
200
  - **The keys a phone lacks.** `modifiers`, `tapModifier` and `pressKey` give
192
201
  sticky Ctrl and Shift: tap arms one for the next key, tap again locks it.
193
202
  While one is armed, the next key or character the phone's keyboard types is
194
- sent as that key's chord, so Ctrl then v is Ctrl+V. The app draws the keys.
203
+ sent as that key's chord, so Ctrl then v is Ctrl+V. `modifiers` holds each
204
+ key's state (`off`, `once` for latched, `lock`) for the app to read.
205
+ `<ModifierKeys modifiers={desktop.modifiers} onTap={desktop.tapModifier} />`
206
+ draws them as a backed key row: off is an outlined key, latched a filled
207
+ one, locked a filled one with a white rim and a bar under its label, and a
208
+ screen reader hears off, latched or locked. Its `grow` prop makes it a
209
+ tablet's row: the keys take the width and the height the row is given
210
+ instead of sitting at 44 pt. An app may draw its own keys
211
+ from `modifiers` instead.
195
212
  - **Readiness is a rendered frame.** Android marks the first draw; iOS marks
196
213
  the first `RTCView` video-dimensions callback and ignores later ones, so a
197
214
  resize or remount during a reconnect does not read as live. Neither marks
@@ -240,6 +257,15 @@ heat and battery.
240
257
  session close and a lost control channel release what the desktop was holding.
241
258
  - **Explicit clipboard.** Two methods, called on a user action while control is
242
259
  enabled. The package never polls the clipboard or uses it as a way to type.
260
+ `copyRemoteToLocal(writeLocal)` awaits the caller-owned local writer, which
261
+ must reject on failure or refusal, before recording completion. The returned
262
+ text and truncation flag describe what was written. `pasteLocalToRemote(text)`
263
+ records completion only after the desktop acknowledges the write.
264
+ `clipboard` is the last transfer that completed, and
265
+ `<ClipboardConfirmation transfer={desktop.clipboard} />` confirms each one
266
+ with a backed pill — where it went and the start of the text —
267
+ that ignores touches and leaves after 1.5 s. Place it in a reserved area
268
+ outside `DesktopView`, keeping that area present when the pill is dismissed.
243
269
 
244
270
  ### `Signaling`
245
271
 
@@ -308,28 +334,44 @@ bridge, and checks that the first frame is presented, that the answer puts
308
334
  H.264 first with NACK when the host offers it (VP9 otherwise; both descriptions
309
335
  are written out), that a tap clicks the host, that
310
336
  the picture keeps following the desktop afterwards, and that hardware keys
311
- pressed on the simulator reach the desktop. With `DESKLINK_IOS_IPAD=1` it runs on
337
+ pressed on the simulator reach the desktop. It then drives the whole
338
+ **phone-only control** journey from the phone's own screen, with no input sent
339
+ to the desktop from this machine: a click, a drag that holds the button and
340
+ selects, a two-finger scroll, typed text, a sticky modifier that chords the next
341
+ key and clears, the clipboard in both directions, and the drawn pointer mark's
342
+ shape and tip. Every step has its own capture, the journey is recorded as one
343
+ screen recording on the simulator, and the phone's own screen is read to prove it
344
+ carries no diagnostic text.
345
+ With `DESKLINK_IOS_IPAD=1` it runs on
312
346
  an iPad simulator and also drives a trackpad through XCUITest
313
347
  (`test/ios-pointer/`): hover, a click-drag that selects text, a right click and
314
348
  a scroll:
315
349
 
316
350
  ```sh
317
351
  npm run build && cargo build --manifest-path packages/desktop-host/engine/Cargo.toml
318
- env -u DISPLAY -u WAYLAND_DISPLAY DESKLINK_IOS_MAC=user@mac npm run test:ios --workspace @desklink/react-native
352
+ env -u DISPLAY -u WAYLAND_DISPLAY DESKLINK_IOS_MAC=user@mac \
353
+ DESKLINK_IOS_DEVICE='fm-iPhone 17 Pro' npm run test:ios --workspace @desklink/react-native
319
354
  ```
320
355
 
321
356
  The Mac needs Xcode with an iOS simulator runtime, CocoaPods, node and
322
357
  [`axe`](https://github.com/cameroncooke/AXe) on its login `PATH`; everything
323
358
  the run builds or caches stays in `~/desklink-ios` there. The run creates a
324
- uniquely named simulator and deletes it during cleanup. For host desktop isolation, see the
359
+ uniquely named simulator and deletes it during cleanup. Set
360
+ `DESKLINK_IOS_DEVICE` to the name or udid of one of the Mac's own simulators to
361
+ run on that instead and leave it in place. For host desktop isolation, see the
325
362
  [private-lab guidance](../../README.md#develop). When the selected
326
363
  Xcode has no simulator platform, point `DESKLINK_IOS_XCODE` at one that does;
327
364
  `DESKLINK_IOS_RUNTIME` picks the iOS version (for example `18.6`); the
328
365
  script's header lists the other settings. To capture the cursor tip proof, set
329
366
  `DESKLINK_IOS_POINTER_PROOF=before` or `after`; the run writes a 4x crop, device
330
367
  frame, proof metadata, and host click log under `DESKLINK_IOS_OUT`. Set
331
- `DESKLINK_IOS_APP` to reuse an app bundle under `DESKLINK_IOS_DIR` when running
332
- with `DESKLINK_IOS_SKIP_BUILD=1`. Run once with `before` before the change and
368
+ `DESKLINK_IOS_APP` to reuse an app bundle under the lane's directory when
369
+ running with `DESKLINK_IOS_SKIP_BUILD=1`. `DESKLINK_IOS_DIR` is the shared Mac
370
+ lab root (`fm-desklink-ios`) and `DESKLINK_IOS_LANE` names this lane's own
371
+ subdirectory within it; the run deletes that subdirectory and nothing else when
372
+ it finishes, so two lanes can build at once without one cleanup deleting the
373
+ other's build. Set `DESKLINK_IOS_KEEP=1` to keep it. Run once with `before`
374
+ before the change and
333
375
  once with `after` after it, on both iPhone and iPad simulators, to compare the
334
376
  pointer crop and verify the host click is within two desktop pixels of the tip.
335
377
  To try the example by hand against any bridge:
@@ -344,7 +386,9 @@ link whose `url` query parameter is the URL-encoded bridge WebSocket address:
344
386
  The example allows the bridge's plaintext private-network connection; consumer
345
387
  apps choose their own transport policy. A new link replaces the open session.
346
388
  The live picture has no diagnostic overlay. Its accessibility status remains
347
- available as `desklink-status`.
389
+ available as `desklink-status`. The key row (`Ctrl`, `Shift`, `Alt`, `Meta`) and
390
+ the `Copy` and `Paste` buttons are the app's own chrome over the picture, and a
391
+ clipboard transfer is confirmed by a pill above them.
348
392
 
349
393
  For device evidence, an optional `report` query parameter accepts a URL-encoded
350
394
  HTTP endpoint. While live, the example posts receiver statistics and connection
@@ -1,9 +1,21 @@
1
1
  apply plugin: 'com.android.library'
2
- apply plugin: 'kotlin-android'
2
+ // expo-modules-core's own Kotlin API is reified through the `pika` compiler
3
+ // plugin, and this plugin is what applies it. Building with a bare
4
+ // `kotlin-android` leaves those reified calls un-inlined, and the app then
5
+ // throws "This function has a reified type parameter ..." out of
6
+ // DesklinkModule.definition() on startup. Expo's plugin covers every SDK, and
7
+ // applies kotlin-android and the default Expo module dependencies itself.
8
+ apply plugin: 'expo-module-gradle-plugin'
3
9
 
4
10
  group = 'expo.modules.desklink'
5
11
  version = '0.1.1'
6
12
 
13
+ // This package ships from npm, not from Expo's Maven repository, so the
14
+ // plugin's publishing task has nothing to publish.
15
+ expoModule {
16
+ canBePublished = false
17
+ }
18
+
7
19
  android {
8
20
  namespace 'expo.modules.desklink'
9
21
  compileSdk rootProject.ext.has('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 36
@@ -681,8 +681,10 @@ class DesktopView(context: Context, appContext: AppContext) : ExpoView(context,
681
681
  return true
682
682
  }
683
683
  // The whole desktop has nowhere to move to, so the finger moves the
684
- // desktop's pointer instead, without a button.
685
- gesture = if (filling || !fitted) Gesture.PAN else if (downOnPicture) Gesture.HOVER else Gesture.LETTERBOX
684
+ // desktop's pointer instead, without a button. A fill that landed on
685
+ // the fit scale has nowhere to move to either, portrait most of all,
686
+ // and it is `fitted`, not the fill, that says there is room.
687
+ gesture = if (!fitted) Gesture.PAN else if (downOnPicture) Gesture.HOVER else Gesture.LETTERBOX
686
688
  lastX = event.x
687
689
  lastY = event.y
688
690
  if (gesture == Gesture.HOVER) hoverTo(active, event.x, event.y)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desklink/react-native",
3
- "version": "0.5.0",
3
+ "version": "0.5.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",
@@ -0,0 +1,57 @@
1
+ import * as React from 'react';
2
+ import { StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
3
+
4
+ import type { ClipboardTransfer } from './useDesktopSession';
5
+
6
+ export interface ClipboardConfirmationProps {
7
+ /** The session's `clipboard`. */
8
+ transfer: ClipboardTransfer | null;
9
+ style?: StyleProp<ViewStyle>;
10
+ }
11
+
12
+ /** How long a confirmation stays up. */
13
+ export const CLIPBOARD_CONFIRMATION_MS = 1500;
14
+ const PREVIEW_CHARS = 24;
15
+
16
+ /**
17
+ * A short pill confirming each clipboard transfer: where it went and the start
18
+ * of the text. Place it in reserved space outside the desktop
19
+ * surface. It ignores touches and goes away on its own.
20
+ */
21
+ export function ClipboardConfirmation({ transfer, style }: ClipboardConfirmationProps) {
22
+ const [shown, setShown] = React.useState<ClipboardTransfer | null>(null);
23
+ const seenId = React.useRef<number | null>(transfer?.id ?? null);
24
+ React.useEffect(() => {
25
+ if (transfer == null) return;
26
+ if (transfer.id === seenId.current) return;
27
+ seenId.current = transfer.id;
28
+ setShown(transfer);
29
+ const timer = setTimeout(() => setShown(null), CLIPBOARD_CONFIRMATION_MS);
30
+ return () => clearTimeout(timer);
31
+ }, [transfer?.id]);
32
+ if (shown == null) return null;
33
+ return (
34
+ <View pointerEvents="none" style={[styles.place, style]}>
35
+ <View testID="desklink-clipboard" accessibilityLiveRegion="polite" style={styles.pill}>
36
+ <Text numberOfLines={1} style={styles.text}>{describeTransfer(shown)}</Text>
37
+ </View>
38
+ </View>
39
+ );
40
+ }
41
+
42
+ /** "Copied to phone · “first words…”" */
43
+ export function describeTransfer({ direction, text }: ClipboardTransfer): string {
44
+ const where = direction === 'to-phone' ? 'Copied to phone' : 'Sent to desktop';
45
+ const flat = text.replace(/\s+/g, ' ').trim();
46
+ const chars = [...flat];
47
+ const preview = chars.length > PREVIEW_CHARS ? `${chars.slice(0, PREVIEW_CHARS).join('')}…` : flat;
48
+ return preview === '' ? `${where}` : `${where} · “${preview}”`;
49
+ }
50
+
51
+ export const PILL_COLORS = { backing: 'rgba(17, 18, 20, 0.94)', text: '#ffffff' };
52
+
53
+ const styles = StyleSheet.create({
54
+ place: { paddingHorizontal: 16, alignItems: 'center' },
55
+ pill: { maxWidth: '100%', paddingHorizontal: 14, paddingVertical: 8, borderRadius: 999, backgroundColor: PILL_COLORS.backing },
56
+ text: { color: PILL_COLORS.text, fontSize: 14, fontWeight: '600' },
57
+ });
@@ -266,6 +266,23 @@ describe('DesktopView.ios parity with DesktopView.kt', () => {
266
266
  expect(sent.filter((message) => message.kind === 'pointer' && message.phase === 'move')).not.toEqual([]);
267
267
  });
268
268
 
269
+ it('carries a one-finger drag from the moment the picture exists, in portrait', async () => {
270
+ // The state a session starts in: the picture at its default fill, before
271
+ // anything has been zoomed. In portrait that fill is the fit, so the
272
+ // picture has nowhere to pan to and the finger belongs to the desktop.
273
+ const { sent, rtcStyle, grant, move, release } = await openView({ whole: false });
274
+ const before = rtcStyle();
275
+ grant([touch(100, 100)]);
276
+ move([touch(160, 120)]);
277
+ move([touch(220, 140)]);
278
+ release();
279
+ const moves = sent.filter((message) => message.kind === 'pointer' && message.phase === 'move');
280
+ expect(moves).toHaveLength(2);
281
+ expect(moves[1].x).toBeGreaterThan(moves[0].x);
282
+ expect(moves[1].y).toBeGreaterThan(moves[0].y);
283
+ expect(rtcStyle()).toEqual(before);
284
+ });
285
+
269
286
  it('zooms around the pinch focus and re-clamps after zooming out', async () => {
270
287
  const { rtcStyle, grant, move } = await openView();
271
288
  grant([touch(100, 400)]);
@@ -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' }: DesktopViewProps) {
70
+ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel, keyboardClearance = 0, insets, gestures = 'desktop', 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 });
@@ -169,6 +169,16 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
169
169
  const shown = clampOffset(offset.x, offset.y);
170
170
  const originX = left + (safeWidth - pictureWidth) / 2 + shown.x;
171
171
  const originY = top + (zoom === null && pictureHeight <= visibleHeight ? 0 : (visibleHeight - pictureHeight) / 2) + shown.y;
172
+ // An application's controls can sit under the picture instead of over it,
173
+ // so it needs to know where the picture ended. Reported only when the
174
+ // picture moved: the callback sets state, and a fresh callback each render
175
+ // would otherwise loop.
176
+ const frame = React.useRef({ top: -1, height: -1 });
177
+ React.useEffect(() => {
178
+ if (frame.current.top === originY && frame.current.height === pictureHeight) return;
179
+ frame.current = { top: originY, height: pictureHeight };
180
+ onPictureFrame?.(frame.current);
181
+ }, [onPictureFrame, originY, pictureHeight]);
172
182
  const send = (control: Record<string, unknown>) => { if (sessionId) nativeDesklink.sendControl(sessionId, JSON.stringify(control)); };
173
183
  const flushWheel = (force: boolean) => {
174
184
  const { x, y } = wheel.current;
@@ -287,7 +297,13 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
287
297
  g.mode = 'scroll';
288
298
  const under = point(at.x, at.y);
289
299
  if (under) pointer('move', under);
290
- } else g.mode = zoom === null || zoomed > 1.001 ? 'pan' : 'hover';
300
+ } else {
301
+ // A whole desktop has nowhere to move to, so the finger moves the
302
+ // desktop's pointer instead. It is the zoom over the fit, not the
303
+ // default fill, that says there is room to pan: a fill that landed
304
+ // on the fit has none.
305
+ g.mode = zoomed > 1.001 ? 'pan' : 'hover';
306
+ }
291
307
  }
292
308
  if (g.mode === 'scroll') {
293
309
  // Content follows the finger: moving it up scrolls the page down.
@@ -35,6 +35,13 @@ 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
+ * Where the picture ended, in the view's own points, whenever it moves.
40
+ * A wide desktop on a tall screen leaves a band under the picture; this is
41
+ * how an application lays its own controls into that band instead of
42
+ * guessing the desktop's aspect.
43
+ */
44
+ onPictureFrame?: (frame: { top: number; height: number }) => void;
38
45
  }
39
46
 
40
47
  interface NativeSurfaceProps {
@@ -0,0 +1,77 @@
1
+ import * as React from 'react';
2
+ import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
3
+
4
+ import type { StickyModifier, StickyModifiers, StickyState } from './stickyModifiers';
5
+
6
+ export interface ModifierKeysProps {
7
+ /** The session's `modifiers`. */
8
+ modifiers: StickyModifiers;
9
+ /** The session's `tapModifier`. */
10
+ onTap: (name: StickyModifier) => void;
11
+ style?: StyleProp<ViewStyle>;
12
+ /**
13
+ * Tablet targets: the keys share the width and take the height the row is
14
+ * given, instead of sitting at 44 pt in a row of a phone's. A row shorter
15
+ * than the keys' minimum keeps the minimum.
16
+ */
17
+ grow?: boolean;
18
+ }
19
+
20
+ const LABELS: Record<StickyModifier, string> = { Control: 'Ctrl', Shift: 'Shift', Alt: 'Alt', Meta: 'Meta' };
21
+ /** What a screen reader says for each state; "once" is the latch the user sees. */
22
+ export const STATE_NAMES: Record<StickyState, string> = { off: 'off', once: 'latched', lock: 'locked' };
23
+
24
+ /**
25
+ * The sticky modifier keys as a row the user can read at a glance.
26
+ *
27
+ * Off is an outlined dark key, latched is a filled key, and locked is a
28
+ * filled key with a white rim and a bar under its label, so the three differ
29
+ * in shape as well as colour. The row carries its own backing, so it reads
30
+ * the same over a light desktop as over a dark one.
31
+ */
32
+ export function ModifierKeys({ modifiers, onTap, style, grow }: ModifierKeysProps) {
33
+ return (
34
+ <View style={[styles.row, grow && styles.grown, style]}>
35
+ {(['Control', 'Shift', 'Alt', 'Meta'] as const).map((name) => {
36
+ const state = modifiers[name];
37
+ return (
38
+ <Pressable
39
+ key={name}
40
+ testID={`desklink-key-${name}`}
41
+ accessibilityRole="button"
42
+ accessibilityLabel={LABELS[name]}
43
+ accessibilityState={{ selected: state !== 'off' }}
44
+ accessibilityValue={{ text: STATE_NAMES[state] }}
45
+ onPress={() => onTap(name)}
46
+ style={[styles.key, grow && styles.keyGrown, state === 'off' ? styles.off : styles.on, state === 'lock' && styles.lock]}
47
+ >
48
+ <Text style={state === 'off' ? styles.offLabel : styles.onLabel}>{LABELS[name]}</Text>
49
+ <View style={[styles.bar, state === 'lock' && styles.barShown]} />
50
+ </Pressable>
51
+ );
52
+ })}
53
+ </View>
54
+ );
55
+ }
56
+
57
+ export const KEY_COLORS = {
58
+ backing: 'rgba(22, 23, 26, 0.94)',
59
+ off: '#2b2d33',
60
+ offLabel: '#c9ccd3',
61
+ on: '#2563eb',
62
+ onLabel: '#ffffff',
63
+ };
64
+
65
+ const styles = StyleSheet.create({
66
+ row: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, padding: 8, backgroundColor: KEY_COLORS.backing },
67
+ grown: { width: '100%', flexGrow: 1, maxHeight: 160 },
68
+ key: { minWidth: 56, maxWidth: '100%', minHeight: 44, paddingHorizontal: 12, borderRadius: 8, borderWidth: 2, alignItems: 'center', justifyContent: 'center' },
69
+ keyGrown: { flexGrow: 1, minHeight: 96, paddingHorizontal: 24 },
70
+ off: { backgroundColor: KEY_COLORS.off, borderColor: '#4b4e57' },
71
+ on: { backgroundColor: KEY_COLORS.on, borderColor: KEY_COLORS.on },
72
+ lock: { borderColor: '#ffffff' },
73
+ offLabel: { color: KEY_COLORS.offLabel, fontSize: 15, fontWeight: '500' },
74
+ onLabel: { color: KEY_COLORS.onLabel, fontSize: 15, fontWeight: '700' },
75
+ bar: { marginTop: 3, width: 18, height: 3, borderRadius: 2, backgroundColor: 'transparent' },
76
+ barShown: { backgroundColor: '#ffffff' },
77
+ });
@@ -0,0 +1,205 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import React from 'react';
3
+ import TestRenderer, { act } from 'react-test-renderer';
4
+
5
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
6
+
7
+ /** The key row and the clipboard pill, rendered with `react-native` primitives as host strings. */
8
+ vi.mock('react-native', () => ({
9
+ StyleSheet: { create: (styles: unknown) => styles, absoluteFill: {} },
10
+ Linking: { getInitialURL: async () => null, addEventListener: () => ({ remove: () => undefined }) },
11
+ Platform: { OS: 'ios' },
12
+ Pressable: 'Pressable',
13
+ ScrollView: 'ScrollView',
14
+ StatusBar: 'StatusBar',
15
+ Text: 'Text',
16
+ View: 'View',
17
+ Settings: { get: () => 'ws://localhost/desktop?token=test' },
18
+ }));
19
+
20
+ vi.mock('react-native-safe-area-context', () => ({
21
+ SafeAreaProvider: ({ children }: { children: React.ReactNode }) => children,
22
+ useSafeAreaInsets: () => ({ top: 0, left: 0, bottom: 0, right: 0 }),
23
+ }));
24
+
25
+ const example = vi.hoisted(() => ({
26
+ write: vi.fn(),
27
+ copy: vi.fn(),
28
+ connect: vi.fn(),
29
+ }));
30
+ vi.mock('expo-clipboard', () => ({ setStringAsync: example.write }));
31
+ vi.mock('@desklink/react-native', async () => ({
32
+ CONTROL_PERMISSIONS: ['view', 'control', 'clipboard'],
33
+ ClipboardConfirmation: (await import('./ClipboardConfirmation')).ClipboardConfirmation,
34
+ ModifierKeys: (await import('./ModifierKeys')).ModifierKeys,
35
+ DesktopView: 'DesktopView',
36
+ useDesktopSession: () => ({
37
+ snapshot: { status: 'live' },
38
+ nativeId: 'example-session',
39
+ modifiers: { Control: 'off', Shift: 'off', Alt: 'off', Meta: 'off' },
40
+ clipboard: null,
41
+ connect: example.connect,
42
+ setInputEnabled: vi.fn(),
43
+ copyRemoteToLocal: example.copy,
44
+ tapModifier: vi.fn(),
45
+ }),
46
+ }));
47
+
48
+ import { KEY_COLORS, ModifierKeys } from './ModifierKeys';
49
+ import { CLIPBOARD_CONFIRMATION_MS, ClipboardConfirmation, PILL_COLORS, describeTransfer } from './ClipboardConfirmation';
50
+ import { NO_MODIFIERS } from './stickyModifiers';
51
+ import type { ClipboardTransfer } from './useDesktopSession';
52
+ import App from '../example/App';
53
+
54
+ type Style = Record<string, unknown>;
55
+ const flatten = (style: unknown): Style =>
56
+ Array.isArray(style) ? Object.assign({}, ...style.map(flatten)) : style ? (style as Style) : {};
57
+
58
+ /** WCAG contrast of two colours, the first drawn with its alpha over `under`. */
59
+ function contrast(top: string, bottom: string, under = '#000000'): number {
60
+ const rgba = (c: string) => {
61
+ const m = c.match(/rgba?\(([^)]+)\)/);
62
+ if (m) { const [r, g, b, a = 1] = m[1].split(',').map(Number); return [r, g, b, a]; }
63
+ return [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16)).concat(1);
64
+ };
65
+ const over = (c: string) => {
66
+ const [r, g, b, a] = rgba(c);
67
+ const [ur, ug, ub] = rgba(under);
68
+ return [r * a + ur * (1 - a), g * a + ug * (1 - a), b * a + ub * (1 - a)];
69
+ };
70
+ const lum = (rgb: number[]) => {
71
+ const [r, g, b] = rgb.map((v) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; });
72
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
73
+ };
74
+ const [l1, l2] = [lum(over(top)), lum(over(bottom))].sort((a, b) => b - a);
75
+ return (l1 + 0.05) / (l2 + 0.05);
76
+ }
77
+
78
+ describe('ModifierKeys', () => {
79
+ function render(modifiers = NO_MODIFIERS, onTap = vi.fn()) {
80
+ let tree!: TestRenderer.ReactTestRenderer;
81
+ act(() => { tree = TestRenderer.create(<ModifierKeys modifiers={modifiers} onTap={onTap} />); });
82
+ const key = (name: string) => tree.root.find((node) => node.props.testID === `desklink-key-${name}`);
83
+ return { key, onTap };
84
+ }
85
+
86
+ it('draws off, latched and locked as three different keys and names each state', () => {
87
+ const { key } = render({ Control: 'off', Shift: 'once', Alt: 'lock', Meta: 'off' });
88
+ const look = (name: string) => {
89
+ const node = key(name);
90
+ const [, bar] = node.props.children;
91
+ return { key: flatten(node.props.style), bar: flatten(bar.props.style), said: node.props.accessibilityValue.text };
92
+ };
93
+ const off = look('Control');
94
+ const latched = look('Shift');
95
+ const locked = look('Alt');
96
+ expect([off.said, latched.said, locked.said]).toEqual(['off', 'latched', 'locked']);
97
+ expect(off.key.backgroundColor).not.toBe(latched.key.backgroundColor);
98
+ // Latched and locked share a fill and differ in shape: the rim and the bar.
99
+ expect(locked.key.borderColor).not.toBe(latched.key.borderColor);
100
+ expect(locked.bar.backgroundColor).not.toBe(latched.bar.backgroundColor);
101
+ expect(key('Control').props.accessibilityState).toEqual({ selected: false });
102
+ expect(key('Shift').props.accessibilityState).toEqual({ selected: true });
103
+ });
104
+
105
+ it('hands a tap to the session', () => {
106
+ const { key, onTap } = render();
107
+ key('Control').props.onPress();
108
+ expect(onTap).toHaveBeenCalledWith('Control');
109
+ });
110
+
111
+ it('keeps every label at 4.5:1 or better', () => {
112
+ expect(contrast(KEY_COLORS.offLabel, KEY_COLORS.off)).toBeGreaterThanOrEqual(4.5);
113
+ expect(contrast(KEY_COLORS.onLabel, KEY_COLORS.on)).toBeGreaterThanOrEqual(4.5);
114
+ });
115
+ });
116
+
117
+ describe('ClipboardConfirmation', () => {
118
+ beforeEach(() => { vi.useFakeTimers(); });
119
+ afterEach(() => { vi.useRealTimers(); });
120
+
121
+ const transfer = (id: number, text = 'hello world'): ClipboardTransfer => ({ id, direction: 'to-desktop', text, truncated: false });
122
+
123
+ it('shows each transfer, ignores touches, and goes away after about 1.5 s', () => {
124
+ let tree!: TestRenderer.ReactTestRenderer;
125
+ act(() => { tree = TestRenderer.create(<ClipboardConfirmation transfer={null} />); });
126
+ expect(tree.toJSON()).toBeNull();
127
+
128
+ act(() => { tree.update(<ClipboardConfirmation transfer={transfer(1)} />); });
129
+ const place = tree.toJSON() as TestRenderer.ReactTestRendererJSON;
130
+ expect(place.props.pointerEvents).toBe('none');
131
+ expect(flatten(place.props.style).position).toBeUndefined();
132
+ expect(JSON.stringify(place)).toContain('Sent to desktop · “hello world”');
133
+
134
+ act(() => { vi.advanceTimersByTime(CLIPBOARD_CONFIRMATION_MS - 1); });
135
+ expect(tree.toJSON()).not.toBeNull();
136
+ act(() => { vi.advanceTimersByTime(1); });
137
+ expect(tree.toJSON()).toBeNull();
138
+
139
+ // The same text again is a new transfer and is confirmed again.
140
+ act(() => { tree.update(<ClipboardConfirmation transfer={transfer(2)} />); });
141
+ expect(tree.toJSON()).not.toBeNull();
142
+ });
143
+
144
+ it('describes where the text went and how it starts, with no byte count', () => {
145
+ expect(describeTransfer({ id: 1, direction: 'to-phone', text: 'a\n b', truncated: false })).toBe('Copied to phone · “a b”');
146
+ expect(describeTransfer({ id: 1, direction: 'to-phone', text: 'x'.repeat(2048), truncated: true })).toBe(`Copied to phone · “${'x'.repeat(24)}…”`);
147
+ expect(describeTransfer({ id: 1, direction: 'to-desktop', text: ' ', truncated: false })).toBe('Sent to desktop');
148
+ });
149
+
150
+ it('reads at 4.5:1 or better over a white or a black desktop', () => {
151
+ expect(contrast(PILL_COLORS.text, PILL_COLORS.backing, '#ffffff')).toBeGreaterThanOrEqual(4.5);
152
+ expect(contrast(PILL_COLORS.text, PILL_COLORS.backing, '#000000')).toBeGreaterThanOrEqual(4.5);
153
+ });
154
+ });
155
+
156
+ describe('example Copy', () => {
157
+ it('writes only on a tap and retains a backed accessible failure until the next tap', async () => {
158
+ example.write.mockReset();
159
+ example.copy.mockReset();
160
+ example.copy.mockImplementation(async (write: (text: string) => Promise<void>) => { await write('desktop text'); });
161
+ let tree!: TestRenderer.ReactTestRenderer;
162
+ await act(async () => { tree = TestRenderer.create(<App />); });
163
+ expect(example.write).not.toHaveBeenCalled();
164
+ const tap = () => tree.root.findByProps({ testID: 'desklink-copy' }).props.onPress();
165
+
166
+ example.copy.mockRejectedValueOnce(new Error('clipboard is unavailable for this desktop source'));
167
+ await act(async () => { tap(); });
168
+ expect(tree.root.findByProps({ testID: 'desklink-copy-error' }).findByType('Text').props.children)
169
+ .toBe('Clipboard failed: clipboard is unavailable for this desktop source');
170
+ expect(example.write).not.toHaveBeenCalled();
171
+
172
+ example.write.mockResolvedValue(false);
173
+ await act(async () => { tap(); });
174
+ expect(example.write).toHaveBeenCalledWith('desktop text');
175
+ const error = tree.root.findByProps({ testID: 'desklink-copy-error' });
176
+ expect(error.props.accessible).toBe(true);
177
+ expect(error.props.accessibilityRole).toBe('alert');
178
+ expect(error.props.accessibilityLiveRegion).toBe('polite');
179
+ const style = flatten(error.props.style);
180
+ expect(style.paddingHorizontal).toBeGreaterThan(0);
181
+ expect(style.paddingVertical).toBeGreaterThan(0);
182
+ expect(contrast('#ffffff', style.backgroundColor as string)).toBeGreaterThanOrEqual(4.5);
183
+ expect(error.findByType('Text').props.children).toBe('Clipboard failed: The phone refused the clipboard write.');
184
+ expect(tree.root.findAllByProps({ testID: 'desklink-clipboard' })).toHaveLength(0);
185
+
186
+ vi.useFakeTimers();
187
+ try {
188
+ act(() => { vi.advanceTimersByTime(5000); });
189
+ expect(tree.root.findAllByProps({ testID: 'desklink-copy-error' })).toHaveLength(1);
190
+ } finally { vi.useRealTimers(); }
191
+
192
+ example.write.mockRejectedValue(new Error('native write refused'));
193
+ await act(async () => { tap(); });
194
+ expect(tree.root.findByProps({ testID: 'desklink-copy-error' }).findByType('Text').props.children).toBe('Clipboard failed: native write refused');
195
+
196
+ let finish!: (written: boolean) => void;
197
+ example.write.mockImplementation(() => new Promise<boolean>((resolve) => { finish = resolve; }));
198
+ await act(async () => { tap(); });
199
+ expect(tree.root.findAllByProps({ testID: 'desklink-copy-error' })).toHaveLength(0);
200
+ expect(tree.root.findAllByProps({ testID: 'desklink-clipboard' })).toHaveLength(0);
201
+ await act(async () => { finish(true); });
202
+ expect(tree.root.findAllByProps({ testID: 'desklink-copy-error' })).toHaveLength(0);
203
+ act(() => { tree.unmount(); });
204
+ });
205
+ });
package/src/index.ts CHANGED
@@ -13,9 +13,12 @@ export { desktopAvailable } from './availability';
13
13
  export { DesktopView, type DesktopViewProps, type EdgeInsets } from './DesktopView';
14
14
  export { observeWebKeyboardMotion } from './webKeyboardMotion';
15
15
  export type { StickyModifier, StickyModifiers, StickyState } from './stickyModifiers';
16
+ export { ModifierKeys, type ModifierKeysProps } from './ModifierKeys';
17
+ export { ClipboardConfirmation, type ClipboardConfirmationProps } from './ClipboardConfirmation';
16
18
  export {
17
19
  useDesktopSession,
18
20
  CONTROL_PERMISSIONS,
21
+ type ClipboardTransfer,
19
22
  type DesktopSession,
20
23
  type DesktopSessionOptions,
21
24
  } from './useDesktopSession';
package/src/protocol.ts CHANGED
@@ -59,7 +59,7 @@ export interface SessionOpenRequest {
59
59
  }
60
60
 
61
61
  export interface SessionOpenResult {
62
- cursor?: { mode: 'embedded' | 'hidden' | 'metadata' | 'unavailable'; positions: boolean };
62
+ cursor?: { mode: 'embedded' | 'hidden' | 'metadata' | 'unavailable'; positions: boolean; source?: 'ext-image-copy-capture-v1'; limitation?: string };
63
63
  sessionId: string;
64
64
  generation: number;
65
65
  source: { kind: string; width: number; height: number; origin: { x: number; y: number } };
@@ -83,6 +83,7 @@ export interface CursorSample {
83
83
  y: number;
84
84
  visible: boolean;
85
85
  timestamp_us: number;
86
+ hotspot?: { x: number; y: number };
86
87
  }
87
88
 
88
89
  /**
@@ -25,6 +25,14 @@ import {
25
25
  type StickyModifiers,
26
26
  } from './stickyModifiers';
27
27
 
28
+ /** One clipboard transfer that reached its destination; `id` tells two equal transfers apart. */
29
+ export interface ClipboardTransfer {
30
+ id: number;
31
+ direction: 'to-phone' | 'to-desktop';
32
+ text: string;
33
+ truncated: boolean;
34
+ }
35
+
28
36
  /** How long a clipboard round trip may take before it is reported as lost. */
29
37
  const CLIPBOARD_TIMEOUT_MS = 4000;
30
38
 
@@ -199,8 +207,8 @@ export interface DesktopSession {
199
207
  close: (reason?: string) => Promise<void>;
200
208
  showKeyboard: () => void;
201
209
  hideKeyboard: () => void;
202
- /** Copy the desktop's clipboard to the phone; the caller places it locally. */
203
- copyRemoteToLocal: () => Promise<{ text: string; truncated: boolean }>;
210
+ /** Copy using the caller's local writer, which must reject if the write fails. */
211
+ copyRemoteToLocal: (writeLocal: (text: string) => Promise<void>) => Promise<{ text: string; truncated: boolean }>;
204
212
  /** Send the phone's clipboard text to the desktop. */
205
213
  pasteLocalToRemote: (text: string) => Promise<void>;
206
214
  /** Release anything the desktop is holding, without ending the session. */
@@ -221,6 +229,8 @@ export interface DesktopSession {
221
229
  * because the engine lets go of exactly the modifiers the message names.
222
230
  */
223
231
  pressKey: (name: string) => () => void;
232
+ /** The last clipboard transfer that completed, for the app to confirm; null until one does. */
233
+ clipboard: ClipboardTransfer | null;
224
234
  }
225
235
 
226
236
  const IDLE: SessionSnapshot = {
@@ -245,6 +255,8 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
245
255
  const [modifiers, setModifiers] = useState<StickyModifiers>(NO_MODIFIERS);
246
256
  /** Read by input as it arrives, which can be faster than a render. */
247
257
  const modifiersRef = useRef<StickyModifiers>(NO_MODIFIERS);
258
+ const [clipboard, setClipboard] = useState<ClipboardTransfer | null>(null);
259
+ const clipboardCount = useRef(0);
248
260
 
249
261
  const opened = useRef<SessionOpenResult | null>(null);
250
262
  const signaling = useRef<Signaling | null>(null);
@@ -887,6 +899,14 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
887
899
  return;
888
900
  }
889
901
  if (reply.kind === 'rejected') {
902
+ // These session-wide clipboard refusals have no request ID.
903
+ // A generic permission rejection can belong to other input.
904
+ if (reply.seq === 0 && (reply.code === 'clipboard-unsupported' || reply.code === 'session')) {
905
+ for (const resolve of pendingClipboard.current.values()) {
906
+ resolve({ text: '', truncated: false, error: reply.message });
907
+ }
908
+ pendingClipboard.current.clear();
909
+ }
890
910
  diagnostics.current.lastRejection = `${reply.code}: ${reply.message}`;
891
911
  update({ diagnostics: { ...diagnostics.current } });
892
912
  optionsRef.current.onRejected?.({ code: reply.code, message: reply.message });
@@ -937,7 +957,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
937
957
  };
938
958
  }, [transportFailed, onTransportState, cancelRestart, spendRestartAttempt, notePing, teardown, update, pressKey, typeText]);
939
959
 
940
- const copyRemoteToLocal = useCallback(async () => {
960
+ const copyRemoteToLocal = useCallback(async (writeLocal: (text: string) => Promise<void>) => {
941
961
  if (!inputEnabled.current) throw new Error('Desktop control is off.');
942
962
  const id = nativeRef.current;
943
963
  if (id == null || nativeDesklink == null) throw new Error('No desktop session is open.');
@@ -951,6 +971,8 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
951
971
  nativeDesklink.sendControl(id, JSON.stringify({ kind: 'clipboard_read', request }));
952
972
  const reply = await answer;
953
973
  if (reply.error != null) throw new Error(reply.error);
974
+ await writeLocal(reply.text);
975
+ setClipboard({ id: ++clipboardCount.current, direction: 'to-phone', text: reply.text, truncated: reply.truncated });
954
976
  return { text: reply.text, truncated: reply.truncated };
955
977
  }, []);
956
978
 
@@ -968,6 +990,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
968
990
  nativeDesklink.sendControl(id, JSON.stringify({ kind: 'clipboard_write', request, text }));
969
991
  const reply = await answer;
970
992
  if (reply.error != null) throw new Error(reply.error);
993
+ setClipboard({ id: ++clipboardCount.current, direction: 'to-desktop', text, truncated: false });
971
994
  }, []);
972
995
 
973
996
  const close = useCallback(async (reason = 'closed by the user') => {
@@ -1086,6 +1109,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
1086
1109
  modifiers,
1087
1110
  tapModifier,
1088
1111
  pressKey,
1112
+ clipboard,
1089
1113
  }), [
1090
1114
  snapshot,
1091
1115
  nativeId,
@@ -1104,6 +1128,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
1104
1128
  modifiers,
1105
1129
  tapModifier,
1106
1130
  pressKey,
1131
+ clipboard,
1107
1132
  ]);
1108
1133
  }
1109
1134