@desklink/react-native 0.2.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.
@@ -1,12 +1,66 @@
1
1
  import * as React from 'react';
2
- import { Keyboard, StyleSheet, TextInput, View, type GestureResponderEvent, type LayoutChangeEvent } from 'react-native';
3
- import { RTCView } from 'react-native-webrtc';
2
+ import { Image, Keyboard, StyleSheet, TextInput, View, type GestureResponderEvent, type LayoutChangeEvent, type ViewProps } from 'react-native';
3
+ import pointerArrow from './pointer.png';
4
+ import { requireNativeViewManager, requireOptionalNativeModule } from 'expo-modules-core';
4
5
  import type { DesktopViewProps } from './DesktopView';
6
+ import { hardwareKey, type HardwareKey } from './hardwareKeys';
5
7
  import { keyboardDelta } from './keyboardDelta';
8
+ import { requireWebRTC } from './webrtc.ios';
6
9
  import { desktopInputEnabled, emitDesktopKeyboard, getDesktopSize, getDesktopStream, markDesktopPresented, nativeDesklink, observeDesktopSurface, registerDesktopView } from './native.ios';
7
10
 
8
11
  type Point = { x: number; y: number };
9
- type Gesture = { start: Point; last: Point; time: number; mode: 'pending' | 'hover' | 'pan' | 'armed' | 'drag' | 'two' | 'scroll' | 'pinch' | 'spent'; span: number; focus: Point };
12
+ type Gesture = { stamp: number; start: Point; last: Point; time: number; mode: 'pending' | 'letterbox' | 'hover' | 'pan' | 'armed' | 'drag' | 'two' | 'scroll' | 'pinch' | 'spent'; span: number; focus: Point };
13
+
14
+ /** Wheel detents accumulate in these steps and never send more than this at once. */
15
+ const MIN_WHEEL_STEP = 0.05;
16
+ const MAX_WHEEL_STEP = 10;
17
+ /** Desktop pixels one wheel detent stands for. */
18
+ const PIXELS_PER_DETENT = 120;
19
+ /** Where the cursor mark's tip sits inside the mark, in points. */
20
+ const CURSOR_HOTSPOT = 3;
21
+
22
+ type NativeEvent<T> = { nativeEvent: T };
23
+ type PointerEvent = { phase: 'hover' | 'down' | 'move' | 'up' | 'cancel'; x: number; y: number; buttons?: number; timestamp?: number };
24
+ type WheelEvent = { phase: 'begin' | 'move' | 'end'; dx: number; dy: number; x: number; y: number };
25
+ type InputViewProps = ViewProps & {
26
+ enabled: boolean;
27
+ onKey: (event: NativeEvent<HardwareKey>) => void;
28
+ onPointer: (event: NativeEvent<PointerEvent>) => void;
29
+ onWheel: (event: NativeEvent<WheelEvent>) => void;
30
+ };
31
+
32
+ /**
33
+ * The package's native surface (`ios/DesklinkInputView.swift`): it reports the
34
+ * hardware keyboard and an iPad pointer. A build without it — an app that has
35
+ * not rebuilt its native project since updating — keeps a plain view, with
36
+ * touch and the on-screen keyboard only.
37
+ */
38
+ function loadInputView(): React.ComponentType<InputViewProps> | null {
39
+ try {
40
+ return requireOptionalNativeModule('DesklinkInput') ? requireNativeViewManager<InputViewProps>('DesklinkInput') : null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /** A press's desktop button from UIKit's button mask: primary, secondary, then the middle. */
47
+ function pressedButton(mask: number): number {
48
+ if (mask & 2) return 3;
49
+ if (mask & 4) return 2;
50
+ return 1;
51
+ }
52
+
53
+ /** The native video view, loaded on first render rather than with this module. */
54
+ function loadRTCView(): React.ComponentType<Record<string, unknown>> | null {
55
+ try {
56
+ const webrtc = requireWebRTC() as {
57
+ RTCView?: React.ComponentType<Record<string, unknown>>;
58
+ };
59
+ return webrtc.RTCView ?? null;
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
10
64
 
11
65
  export function DesktopView({ sessionId, style, placeholder, accessibilityLabel, keyboardClearance = 0, gestures = 'desktop' }: DesktopViewProps) {
12
66
  const [revision, refresh] = React.useReducer((n: number) => n + 1, 0);
@@ -21,10 +75,17 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
21
75
  const input = React.useRef<TextInput>(null);
22
76
  const captured = React.useRef(false);
23
77
  const gesture = React.useRef<Gesture | null>(null);
78
+ const wheel = React.useRef({ x: 0, y: 0 });
24
79
  const longPress = React.useRef<ReturnType<typeof setTimeout> | null>(null);
25
80
  const lastTap = React.useRef<{ time: number; at: Point; point: Point } | null>(null);
81
+ const chordsDown = React.useRef(new Set<number>());
82
+ /** Touch timestamps of recent trackpad or mouse presses, which the native view reports and touch leaves alone. */
83
+ const pressStamps = React.useRef<number[]>([]);
84
+ const pressed = React.useRef<number | null>(null);
26
85
  const [enabled, setEnabled] = React.useState(() => desktopInputEnabled(sessionId));
27
86
  const stream = getDesktopStream(sessionId);
87
+ const RTCView = React.useMemo(loadRTCView, []);
88
+ const InputView = React.useMemo(loadInputView, []);
28
89
  void revision;
29
90
 
30
91
  React.useEffect(() => {
@@ -37,13 +98,18 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
37
98
  return () => { show.remove(); hide.remove(); };
38
99
  }, []);
39
100
  React.useEffect(() => {
40
- if (gestures !== 'device') return;
41
- setCursor(null);
101
+ // A held drag belongs to the old meaning: let go of the button rather
102
+ // than release it somewhere the finger never meant.
42
103
  const held = gesture.current;
43
- if (held && (held.mode === 'pending' || held.mode === 'armed')) {
104
+ if (held) {
44
105
  stopTimer();
106
+ if (held.mode === 'drag') send({ kind: 'release_all' });
45
107
  held.mode = 'spent';
46
108
  }
109
+ flushWheel(true);
110
+ if (gestures === 'device') setCursor(null);
111
+ // `send` and `flushWheel` are stable per render; this runs on profile change.
112
+ // eslint-disable-next-line react-hooks/exhaustive-deps
47
113
  }, [gestures]);
48
114
  React.useEffect(() => {
49
115
  if (!sessionId) return;
@@ -71,11 +137,26 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
71
137
  if (!clamp && (px < 0 || py < 0 || px >= size.width || py >= size.height)) return null;
72
138
  return { x: Math.max(0, Math.min(size.width - 1, px)), y: Math.max(0, Math.min(size.height - 1, py)) };
73
139
  };
140
+ /** Hold the offset to the picture's edges at a zoom level. */
141
+ const clampOffset = (x: number, y: number, atScale: number = scale): Point => {
142
+ const maxX = Math.max(0, (size.width * atScale - bounds.width) / 2);
143
+ const maxY = Math.max(0, (size.height * atScale - visibleHeight) / 2);
144
+ return { x: Math.max(-maxX, Math.min(maxX, x)), y: Math.max(-maxY, Math.min(maxY, y)) };
145
+ };
74
146
  const send = (control: Record<string, unknown>) => { if (sessionId) nativeDesklink.sendControl(sessionId, JSON.stringify(control)); };
75
- const pointer = (phase: string, at: Point, button?: number) => {
147
+ const flushWheel = (force: boolean) => {
148
+ const { x, y } = wheel.current;
149
+ if (!force && Math.abs(x) < MIN_WHEEL_STEP && Math.abs(y) < MIN_WHEEL_STEP) return;
150
+ if (!x && !y) return;
151
+ send({ kind: 'wheel', dx: Math.max(-MAX_WHEEL_STEP, Math.min(MAX_WHEEL_STEP, x)), dy: Math.max(-MAX_WHEEL_STEP, Math.min(MAX_WHEEL_STEP, y)) });
152
+ wheel.current = { x: 0, y: 0 };
153
+ };
154
+ const pointer = (phase: string, at: Point, button?: number, mark = true) => {
76
155
  send({ kind: 'pointer', phase, x: at.x, y: at.y, ...(button ? { button } : {}) });
77
156
  // A device screen shows the touch itself; a cursor would be a second finger that lies.
78
- if (gestures !== 'device') setCursor(at);
157
+ // A trackpad or mouse has the system's own pointer on screen.
158
+ if (!mark) setCursor(null);
159
+ else if (gestures !== 'device') setCursor(at);
79
160
  };
80
161
  const click = (at: Point, button = 1) => { pointer('down', at, button); pointer('up', at, button); };
81
162
  const stopTimer = () => { if (longPress.current) clearTimeout(longPress.current); longPress.current = null; };
@@ -88,17 +169,24 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
88
169
  if (!enabled) return;
89
170
  const touches = event.nativeEvent.touches;
90
171
  if (touches.length !== 1) return;
172
+ // A trackpad or mouse press the native view already reported is its own.
173
+ const stamp = touches[0].timestamp;
174
+ if (pressStamps.current.some((pressStamp) => Math.abs(pressStamp - stamp) < 1)) { gesture.current = null; return; }
91
175
  const at = { x: touches[0].locationX, y: touches[0].locationY };
92
176
  // A device screen is pressed, not pointed at: the finger lands with the
93
177
  // button down. A tap is the same press lifted without moving, and a
94
178
  // hold holds — there is no long press to arm.
95
179
  if (gestures === 'device') {
96
180
  const from = point(at.x, at.y);
97
- gesture.current = { start: at, last: at, time: Date.now(), mode: from ? 'drag' : 'spent', span: 0, focus: at };
181
+ gesture.current = { stamp, start: at, last: at, time: Date.now(), mode: from ? 'drag' : 'spent', span: 0, focus: at };
98
182
  if (from) pointer('down', from, 1);
99
183
  return;
100
184
  }
101
- gesture.current = { start: at, last: at, time: Date.now(), mode: 'pending', span: 0, focus: at };
185
+ // Off the picture there is no pointer to carry: the finger waits for a
186
+ // second one, and one finger alone is ignored. A zoomed picture pans
187
+ // from anywhere, clamped to its edges.
188
+ const onPicture = point(at.x, at.y) !== null;
189
+ gesture.current = { stamp, start: at, last: at, time: Date.now(), mode: onPicture || zoom > 1 ? 'pending' : 'letterbox', span: 0, focus: at };
102
190
  stopTimer();
103
191
  longPress.current = setTimeout(() => {
104
192
  if (gesture.current?.mode === 'pending' && point(at.x, at.y)) gesture.current.mode = 'armed';
@@ -107,7 +195,7 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
107
195
  const move = (event: GestureResponderEvent) => {
108
196
  if (!enabled) return;
109
197
  const g = gesture.current;
110
- if (!g) return;
198
+ if (!g || !scale) return;
111
199
  const touches = event.nativeEvent.touches;
112
200
  if (touches.length === 2) {
113
201
  stopTimer();
@@ -115,22 +203,52 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
115
203
  const distance = span(touches);
116
204
  if (g.mode !== 'two' && g.mode !== 'pinch' && g.mode !== 'scroll') {
117
205
  if (g.mode === 'drag') send({ kind: 'release_all' });
206
+ if (g.mode === 'letterbox' && !touches.some((touch) => point(touch.locationX, touch.locationY))) {
207
+ // The letterbox is not the desktop: only a second finger
208
+ // on the picture starts a gesture.
209
+ g.mode = 'spent';
210
+ return;
211
+ }
118
212
  g.mode = 'two'; g.span = distance; g.focus = center; g.start = center;
213
+ wheel.current = { x: 0, y: 0 };
119
214
  return;
120
215
  }
121
216
  if (g.mode === 'two') {
122
217
  if (Math.abs(distance - g.span) > 12) g.mode = 'pinch';
123
- else if (Math.hypot(center.x - g.start.x, center.y - g.start.y) > 8) g.mode = 'scroll';
218
+ else if (Math.hypot(center.x - g.start.x, center.y - g.start.y) > 8) {
219
+ g.mode = 'scroll';
220
+ // The desktop scrolls whatever is under its pointer.
221
+ const under = point(center.x, center.y);
222
+ if (under) pointer('move', under);
223
+ }
124
224
  }
125
225
  if (g.mode === 'pinch' && g.span) {
126
- setZoom((value) => Math.max(1, Math.min(2.5 / fit, value * distance / g.span)));
226
+ const maxZoom = fit > 0 ? 2.5 / fit : 2.5;
227
+ const next = Math.max(1, Math.min(maxZoom, zoom * distance / g.span));
228
+ const applied = zoom > 0 ? next / zoom : 1;
229
+ // The desktop point under the focus stays under it while the
230
+ // offset follows the fingers, then everything clamps back.
231
+ const nextScale = fit * next;
232
+ const ox = (bounds.width - size.width * scale) / 2 + offset.x;
233
+ const oy = (visibleHeight - size.height * scale) / 2 + offset.y;
234
+ const baseX = (bounds.width - size.width * nextScale) / 2;
235
+ const baseY = (visibleHeight - size.height * nextScale) / 2;
236
+ setZoom(next);
237
+ setOffset(clampOffset(
238
+ center.x - (center.x - ox) * applied - baseX + (center.x - g.focus.x),
239
+ center.y - (center.y - oy) * applied - baseY + (center.y - g.focus.y),
240
+ nextScale,
241
+ ));
127
242
  } else if (g.mode === 'scroll') {
128
- send({ kind: 'wheel', dx: -(center.x - g.focus.x) / scale / 120, dy: -(center.y - g.focus.y) / scale / 120 });
243
+ // Content follows the fingers: moving them up scrolls the page down.
244
+ wheel.current.x += -(center.x - g.focus.x) / scale / PIXELS_PER_DETENT;
245
+ wheel.current.y += -(center.y - g.focus.y) / scale / PIXELS_PER_DETENT;
246
+ flushWheel(false);
129
247
  }
130
248
  g.span = distance; g.focus = center; g.last = center;
131
249
  return;
132
250
  }
133
- if (touches.length !== 1 || g.mode === 'spent' || g.mode === 'two' || g.mode === 'pinch' || (g.mode === 'scroll' && gestures !== 'browser')) return;
251
+ if (touches.length !== 1 || g.mode === 'spent' || g.mode === 'letterbox' || g.mode === 'two' || g.mode === 'pinch' || (g.mode === 'scroll' && gestures !== 'browser')) return;
134
252
  const at = { x: touches[0].locationX, y: touches[0].locationY };
135
253
  if (g.mode === 'pending' && Math.hypot(at.x - g.start.x, at.y - g.start.y) > 8) {
136
254
  stopTimer();
@@ -144,17 +262,16 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
144
262
  }
145
263
  if (g.mode === 'scroll') {
146
264
  // Content follows the finger: moving it up scrolls the page down.
147
- send({ kind: 'wheel', dx: -(at.x - g.last.x) / scale / 120, dy: -(at.y - g.last.y) / scale / 120 });
265
+ wheel.current.x += -(at.x - g.last.x) / scale / PIXELS_PER_DETENT;
266
+ wheel.current.y += -(at.y - g.last.y) / scale / PIXELS_PER_DETENT;
267
+ flushWheel(false);
148
268
  g.last = at; g.focus = at;
149
269
  return;
150
270
  }
151
271
  if (g.mode === 'pan') {
152
272
  const dx = at.x - g.last.x;
153
273
  const dy = at.y - g.last.y;
154
- setOffset((current) => ({
155
- x: Math.max(-Math.max(0, (pictureWidth - bounds.width) / 2), Math.min(Math.max(0, (pictureWidth - bounds.width) / 2), current.x + dx)),
156
- y: Math.max(-Math.max(0, (pictureHeight - visibleHeight) / 2), Math.min(Math.max(0, (pictureHeight - visibleHeight) / 2), current.y + dy)),
157
- }));
274
+ setOffset((current) => clampOffset(current.x + dx, current.y + dy));
158
275
  }
159
276
  if (g.mode === 'armed' && Math.hypot(at.x - g.start.x, at.y - g.start.y) > 8) {
160
277
  const from = point(g.start.x, g.start.y, true);
@@ -171,6 +288,8 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
171
288
  stopTimer();
172
289
  const g = gesture.current;
173
290
  if (!g) return;
291
+ // Leftover wheel below the send threshold still belongs to this scroll.
292
+ if (g.mode === 'scroll') flushWheel(true);
174
293
  if (g.mode === 'two' && Date.now() - g.time < 350) {
175
294
  const at = point(g.focus.x, g.focus.y);
176
295
  if (at) click(at, 3);
@@ -206,22 +325,90 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
206
325
  }
207
326
  setKeyboardText(keyboardTextRef.current);
208
327
  };
328
+ const onKey = ({ nativeEvent }: NativeEvent<HardwareKey>) => {
329
+ if (!enabled || !sessionId) return;
330
+ const action = hardwareKey(nativeEvent, chordsDown.current, captured.current);
331
+ if (action === null) return;
332
+ if ('send' in action) send(action.send);
333
+ else emitDesktopKeyboard(sessionId, action.capture);
334
+ };
335
+ const onPointer = ({ nativeEvent: { phase, x, y, buttons = 0, timestamp = NaN } }: NativeEvent<PointerEvent>) => {
336
+ if (!enabled) return;
337
+ if (phase === 'hover') {
338
+ const at = point(x, y);
339
+ if (at) pointer('move', at, undefined, false);
340
+ return;
341
+ }
342
+ if (phase === 'down') {
343
+ pressStamps.current = [...pressStamps.current.slice(-7), timestamp];
344
+ const button = pressedButton(buttons);
345
+ // Touch may have seen this press first: it is the pointer's, not a finger's.
346
+ const g = gesture.current;
347
+ let held = false;
348
+ if (g && Math.abs(g.stamp - timestamp) < 1) {
349
+ stopTimer();
350
+ gesture.current = null;
351
+ if (g.mode === 'drag') {
352
+ const from = point(g.start.x, g.start.y, true);
353
+ held = button === 1;
354
+ if (!held && from) pointer('up', from, 1, false);
355
+ }
356
+ }
357
+ // The letterbox is not the desktop: a press there does nothing.
358
+ const at = point(x, y);
359
+ if (!at) return;
360
+ pressed.current = button;
361
+ if (!held) pointer('down', at, button, false);
362
+ return;
363
+ }
364
+ const button = pressed.current;
365
+ if (button === null) return;
366
+ const at = point(x, y, true);
367
+ if (phase === 'move') { if (at) pointer('move', at, button, false); return; }
368
+ pressed.current = null;
369
+ if (at) pointer('up', at, button, false); else send({ kind: 'release_all' });
370
+ };
371
+ const onWheel = ({ nativeEvent: { phase, dx, dy, x, y } }: NativeEvent<WheelEvent>) => {
372
+ if (!enabled || !scale) return;
373
+ if (phase === 'end') { flushWheel(true); return; }
374
+ if (phase === 'begin') {
375
+ // The desktop scrolls whatever is under its pointer.
376
+ const under = point(x, y);
377
+ if (under) pointer('move', under, undefined, false);
378
+ }
379
+ // Content follows the fingers, as with two fingers on the screen.
380
+ wheel.current.x += -dx / scale / PIXELS_PER_DETENT;
381
+ wheel.current.y += -dy / scale / PIXELS_PER_DETENT;
382
+ flushWheel(false);
383
+ };
209
384
  const onLayout = (event: LayoutChangeEvent) => setBounds(event.nativeEvent.layout);
210
- return <View style={[styles.surface, style]} onLayout={onLayout} accessibilityLabel={accessibilityLabel}
211
- onStartShouldSetResponder={() => enabled} onMoveShouldSetResponder={() => enabled}
212
- onResponderGrant={start} onResponderMove={move} onResponderRelease={end}
213
- onResponderTerminate={() => { stopTimer(); if (gesture.current?.mode === 'drag') send({ kind: 'release_all' }); gesture.current = null; }}>
214
- {sessionId && stream && size.width > 0 && size.height > 0 ?
385
+ const surface: ViewProps = {
386
+ style: [styles.surface, style], onLayout, accessible: accessibilityLabel !== undefined, accessibilityLabel,
387
+ onStartShouldSetResponder: () => enabled, onMoveShouldSetResponder: () => enabled,
388
+ onResponderGrant: start, onResponderMove: move, onResponderRelease: end,
389
+ onResponderTerminate: () => { stopTimer(); if (gesture.current?.mode === 'drag') send({ kind: 'release_all' }); gesture.current = null; },
390
+ };
391
+ const children = <>
392
+ {sessionId && stream && RTCView && size.width > 0 && size.height > 0 ?
215
393
  <RTCView pointerEvents="none" streamURL={stream.toURL()} objectFit="contain" style={{ position: 'absolute', left: originX, top: originY, width: pictureWidth, height: pictureHeight }}
216
394
  onDimensionsChange={(event) => { if (event.nativeEvent.width > 0 && event.nativeEvent.height > 0) markDesktopPresented(sessionId); }} /> : placeholder}
217
- {cursor && <View pointerEvents="none" style={[styles.cursor, { left: originX + (cursor.x + 0.5) * scale, top: originY + (cursor.y + 0.5) * scale }]} />}
218
- <TextInput ref={input} style={styles.input} value={keyboardText} onChangeText={changeText} autoCorrect={false} autoCapitalize="none" submitBehavior="submit"
219
- onSubmitEditing={() => { send({ kind: 'key', name: 'Enter', modifiers: [], down: true }); send({ kind: 'key', name: 'Enter', modifiers: [], down: false }); }} />
220
- </View>;
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 }]} />}
396
+ <TextInput ref={input} style={styles.input} value={keyboardText} onChangeText={changeText} autoCorrect={false} autoCapitalize="none" submitBehavior="submit" accessible={false}
397
+ onSubmitEditing={() => {
398
+ // While the app holds a sticky modifier, Enter is the app's to
399
+ // chord: it goes through capture like every other key, so
400
+ // Ctrl+Enter reaches the desktop instead of typing a newline.
401
+ if (captured.current && sessionId) emitDesktopKeyboard(sessionId, { key: 'Enter' });
402
+ else { send({ kind: 'key', name: 'Enter', modifiers: [], down: true }); send({ kind: 'key', name: 'Enter', modifiers: [], down: false }); }
403
+ }} />
404
+ </>;
405
+ return InputView
406
+ ? <InputView {...surface} enabled={enabled} onKey={onKey} onPointer={onPointer} onWheel={onWheel}>{children}</InputView>
407
+ : <View {...surface}>{children}</View>;
221
408
  }
222
409
 
223
410
  const styles = StyleSheet.create({
224
411
  surface: { backgroundColor: '#000', overflow: 'hidden' },
225
- cursor: { position: 'absolute', width: 12, height: 18, backgroundColor: '#fff', borderWidth: 2, borderColor: '#000', transform: [{ rotate: '-30deg' }] },
412
+ cursor: { position: 'absolute', width: 28, height: 28 },
226
413
  input: { position: 'absolute', width: 1, height: 1, opacity: 0, left: 0, bottom: 0 },
227
414
  });
@@ -0,0 +1,61 @@
1
+ import type { ControlMessage } from './protocol';
2
+
3
+ /** A hardware key as the iOS input view reports it: its HID usage, the characters it types, and what is held. */
4
+ export type HardwareKey = { usage: number; down: boolean; characters: string; modifiers: string[] };
5
+
6
+ /** What a key press does: a control message, or a key or text for the session's sticky modifiers. */
7
+ export type HardwareKeyAction = { send: ControlMessage } | { capture: { key: string } | { text: string } };
8
+
9
+ /** Keys with no character of their own, by HID keyboard usage, in the engine's names. */
10
+ const NAMED: Record<number, string> = {
11
+ 0x28: 'Enter', 0x29: 'Escape', 0x2a: 'Backspace', 0x2b: 'Tab', 0x58: 'Enter',
12
+ 0x3a: 'F1', 0x3b: 'F2', 0x3c: 'F3', 0x3d: 'F4', 0x3e: 'F5', 0x3f: 'F6',
13
+ 0x40: 'F7', 0x41: 'F8', 0x42: 'F9', 0x43: 'F10', 0x44: 'F11', 0x45: 'F12',
14
+ 0x4a: 'Home', 0x4b: 'PageUp', 0x4c: 'Delete', 0x4d: 'End', 0x4e: 'PageDown',
15
+ 0x4f: 'ArrowRight', 0x50: 'ArrowLeft', 0x51: 'ArrowDown', 0x52: 'ArrowUp',
16
+ 0xe0: 'Control', 0xe1: 'Shift', 0xe2: 'Alt', 0xe3: 'Meta',
17
+ 0xe4: 'ControlRight', 0xe5: 'ShiftRight', 0xe6: 'AltGraph', 0xe7: 'MetaRight',
18
+ };
19
+ const MODIFIERS = new Set(['Control', 'ControlRight', 'Shift', 'ShiftRight', 'Alt', 'AltGraph', 'Meta', 'MetaRight']);
20
+
21
+ /** The letter or digit a key stands for where it sits, whatever the layout types: a chord names a key. */
22
+ function chordCharacter(usage: number): string | undefined {
23
+ if (usage >= 0x04 && usage <= 0x1d) return String.fromCharCode(0x61 + usage - 0x04);
24
+ if (usage >= 0x1e && usage <= 0x26) return String(usage - 0x1d);
25
+ if (usage === 0x27) return '0';
26
+ return undefined;
27
+ }
28
+
29
+ /** Control characters and the private-use codes UIKit gives keys like F13 type nothing. */
30
+ const TYPES_NOTHING = /[\u0000-\u001f\u007f-]/;
31
+
32
+ /**
33
+ * Turn one hardware key into what the desktop receives, as the web and Android
34
+ * receivers do: named keys and modifiers travel as key events with what is
35
+ * held, Control and Command chords as the key's own letter or digit, and
36
+ * anything else as the text it types. `chordsDown` remembers the chord keys
37
+ * still down, so a chord's key-up is sent even when its modifier let go first.
38
+ */
39
+ export function hardwareKey(key: HardwareKey, chordsDown: Set<number>, captured: boolean): HardwareKeyAction | null {
40
+ const { usage, down, modifiers } = key;
41
+ const name = NAMED[usage];
42
+ if (name !== undefined) {
43
+ // A plain key pressed while a sticky modifier waits is the session's to
44
+ // chord. Only the press is held back: a release still goes, so a key
45
+ // that was down before the capture cannot stick.
46
+ if (captured && down && modifiers.length === 0 && !MODIFIERS.has(name)) return { capture: { key: name } };
47
+ return { send: { kind: 'key', name, modifiers, down } };
48
+ }
49
+ const character = chordCharacter(usage);
50
+ if (character !== undefined) {
51
+ // Alt alone is not a chord: Option composes a character with the layout.
52
+ if ((modifiers.includes('Control') || modifiers.includes('Meta')) && (down || chordsDown.has(usage))) {
53
+ if (down) chordsDown.add(usage);
54
+ else chordsDown.delete(usage);
55
+ return { send: { kind: 'key', character, modifiers, down } };
56
+ }
57
+ if (!down && chordsDown.delete(usage)) return { send: { kind: 'key', character, modifiers: [], down: false } };
58
+ }
59
+ if (!down || key.characters === '' || TYPES_NOTHING.test(key.characters)) return null;
60
+ return captured ? { capture: { text: key.characters } } : { send: { kind: 'text', text: key.characters } };
61
+ }
package/src/native.ios.ts CHANGED
@@ -1,5 +1,18 @@
1
- import { MediaStream, RTCPeerConnection } from 'react-native-webrtc';
1
+ import type { MediaStream, RTCPeerConnection } from 'react-native-webrtc';
2
2
  import type { NativeDesklinkModule, NativeSessionEvent } from './native';
3
+ import { requireWebRTC } from './webrtc.ios';
4
+
5
+ /** The WebRTC stack, loaded the first time a session opens rather than with this module. */
6
+ function loadWebRTC(): { RTCPeerConnection: new (config: unknown) => RTCPeerConnection; MediaStream: new () => MediaStream } | null {
7
+ try {
8
+ return requireWebRTC() as {
9
+ RTCPeerConnection: new (config: unknown) => RTCPeerConnection;
10
+ MediaStream: new () => MediaStream;
11
+ };
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
3
16
 
4
17
  type Session = {
5
18
  peer: RTCPeerConnection;
@@ -8,6 +21,7 @@ type Session = {
8
21
  input: boolean;
9
22
  seq: number;
10
23
  closed: boolean;
24
+ presented: boolean;
11
25
  candidates: Array<{ candidate: string; sdpMid: string | null; sdpMLineIndex: number }>;
12
26
  remoteSet: boolean;
13
27
  };
@@ -18,6 +32,13 @@ const surfaces = new Set<() => void>();
18
32
  const sizes = new Map<string, { width: number; height: number }>();
19
33
  let nextId = 0;
20
34
 
35
+ // RTCView's Metal renderer draws a frame only when its timestamp differs from
36
+ // the last one drawn, and a receiver honouring the engine's zero playout delay
37
+ // stamps every decoded frame with a render time of 0: the view would keep its
38
+ // first frame, or none, while frames go on decoding. Declining the extension
39
+ // keeps real timestamps, at the cost of the receiver's usual jitter buffer.
40
+ const PLAYOUT_DELAY = /^a=extmap:\d+(?:\/\w+)? http:\/\/www\.webrtc\.org\/experiments\/rtp-hdrext\/playout-delay\r?\n/gm;
41
+
21
42
  function emit(sessionId: string, name: NativeSessionEvent['name'], payload: Record<string, unknown> = {}) {
22
43
  if (!sessions.has(sessionId) && name !== 'closed') return;
23
44
  for (const listener of listeners) listener({ sessionId, name, payload });
@@ -41,6 +62,11 @@ export function observeDesktopSurface(listener: () => void): () => void {
41
62
  }
42
63
 
43
64
  export function markDesktopPresented(id: string) {
65
+ // A resize or a remount must not re-mark: readiness is the first frame,
66
+ // and a second mark during `reconnecting` would wrongly read as `live`.
67
+ const session = sessions.get(id);
68
+ if (!session || session.presented) return;
69
+ session.presented = true;
44
70
  emit(id, 'presented');
45
71
  }
46
72
 
@@ -48,6 +74,26 @@ export function desktopInputEnabled(id: string | null): boolean {
48
74
  return id != null && sessions.get(id)?.input === true;
49
75
  }
50
76
 
77
+ /**
78
+ * The offer with H.264 moved ahead of the other video codecs. For a captured
79
+ * desktop the engine offers VP9 first and H.264 beside it, and codes whichever
80
+ * the answer lists first; an answer keeps the offer's order. iOS's WebRTC
81
+ * decodes H.264 in hardware and VP9 in software, so this receiver asks for
82
+ * H.264 by reordering the offer it applies. An offer without H.264 is unchanged.
83
+ */
84
+ export function preferH264(sdp: string): string {
85
+ const lines = sdp.split('\r\n');
86
+ const start = lines.findIndex((line) => line.startsWith('m=video '));
87
+ if (start === -1) return sdp;
88
+ const next = lines.findIndex((line, index) => index > start && line.startsWith('m='));
89
+ const section = lines.slice(start + 1, next === -1 ? undefined : next);
90
+ const h264 = new Set(section.flatMap((line) => /^a=rtpmap:(\d+) H264\//i.exec(line)?.[1] ?? []));
91
+ const [media, port, proto, ...formats] = lines[start].split(' ');
92
+ const ordered = [...formats.filter((format) => h264.has(format)), ...formats.filter((format) => !h264.has(format))];
93
+ lines[start] = [media, port, proto, ...ordered].join(' ');
94
+ return lines.join('\r\n');
95
+ }
96
+
51
97
  function failure(id: string, error: unknown) {
52
98
  emit(id, 'failure', { code: 'transport', message: error instanceof Error ? error.message : String(error) });
53
99
  }
@@ -56,8 +102,10 @@ export const nativeDesklink: NativeDesklinkModule = {
56
102
  createSession(iceServersJson) {
57
103
  const id = `desklink-${++nextId}`;
58
104
  try {
59
- const peer = new RTCPeerConnection({ iceServers: JSON.parse(iceServersJson) });
60
- const session: Session = { peer, channel: null, stream: null, input: false, seq: 0, closed: false, candidates: [], remoteSet: false };
105
+ const webrtc = loadWebRTC();
106
+ if (!webrtc) return null;
107
+ const peer = new webrtc.RTCPeerConnection({ iceServers: JSON.parse(iceServersJson) });
108
+ const session: Session = { peer, channel: null, stream: null, input: false, seq: 0, closed: false, presented: false, candidates: [], remoteSet: false };
61
109
  sessions.set(id, session);
62
110
  peer.onicecandidate = (event) => {
63
111
  if (event.candidate) emit(id, 'candidate', {
@@ -72,7 +120,7 @@ export const nativeDesklink: NativeDesklinkModule = {
72
120
  };
73
121
  peer.ontrack = (event) => {
74
122
  if (session.closed || event.track.kind !== 'video') return;
75
- const stream = event.streams[0] ?? new MediaStream();
123
+ const stream = event.streams[0] ?? new webrtc.MediaStream();
76
124
  if (event.streams.length === 0) stream.addTrack(event.track);
77
125
  session.stream = stream;
78
126
  notifySurface();
@@ -98,7 +146,7 @@ export const nativeDesklink: NativeDesklinkModule = {
98
146
  if (type !== 'offer') { failure(id, 'the engine must send an offer'); return false; }
99
147
  void (async () => {
100
148
  try {
101
- await session.peer.setRemoteDescription({ type: 'offer', sdp });
149
+ await session.peer.setRemoteDescription({ type: 'offer', sdp: preferH264(sdp.replace(PLAYOUT_DELAY, '')) });
102
150
  if (session.closed) return;
103
151
  session.remoteSet = true;
104
152
  for (const candidate of session.candidates.splice(0)) await session.peer.addIceCandidate(candidate);
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,9 +91,14 @@ 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
- | { kind: 'revoked'; reason: string; sessionId?: string };
97
+ /**
98
+ * `code` is the engine's revocation code: `transport` means the path was
99
+ * lost and a new session may recover it; anything else, or none, is final.
100
+ */
101
+ | { kind: 'revoked'; reason: string; code?: string; sessionId?: string };
87
102
 
88
103
  /**
89
104
  * The application's own authenticated channel to the host engine.
@@ -155,7 +170,7 @@ export type ControlReply =
155
170
  | { kind: 'ack'; seq: number }
156
171
  | { kind: 'rejected'; seq: number; code: string; message: string }
157
172
  | { kind: 'clipboard'; request: string; text: string; truncated?: boolean; error?: string }
158
- | { kind: 'revoked'; reason: string }
173
+ | { kind: 'revoked'; reason: string; code?: string }
159
174
  /**
160
175
  * The engine's heartbeat, twice a second while the control channel is
161
176
  * open. A client that has seen one and then sees none for a while reads