@desklink/react-native 0.1.1 → 0.3.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 +198 -17
- package/android/src/main/java/expo/modules/desklink/DesklinkModule.kt +4 -0
- package/android/src/main/java/expo/modules/desklink/DesktopView.kt +61 -3
- package/app.plugin.js +16 -0
- package/expo-module.config.json +4 -1
- package/ios/DesklinkInput.podspec +22 -0
- package/ios/DesklinkInputModule.swift +20 -0
- package/ios/DesklinkInputView.swift +209 -0
- package/package.json +11 -2
- package/src/DesktopView.ios.spec.tsx +354 -0
- package/src/DesktopView.ios.tsx +244 -27
- package/src/DesktopView.tsx +9 -2
- package/src/DesktopView.web.tsx +14 -2
- package/src/hardwareKeys.ts +61 -0
- package/src/native.ios.ts +53 -5
- package/src/native.web.ts +86 -5
- package/src/protocol.ts +34 -7
- package/src/useDesktopSession.ts +496 -63
- package/src/webrtc.ios.ts +10 -0
package/src/DesktopView.ios.tsx
CHANGED
|
@@ -1,14 +1,67 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
-
import { Keyboard, StyleSheet, TextInput, View, type GestureResponderEvent, type LayoutChangeEvent } from 'react-native';
|
|
3
|
-
import {
|
|
2
|
+
import { Keyboard, StyleSheet, TextInput, View, type GestureResponderEvent, type LayoutChangeEvent, type ViewProps } from 'react-native';
|
|
3
|
+
import { requireNativeViewManager, requireOptionalNativeModule } from 'expo-modules-core';
|
|
4
4
|
import type { DesktopViewProps } from './DesktopView';
|
|
5
|
+
import { hardwareKey, type HardwareKey } from './hardwareKeys';
|
|
5
6
|
import { keyboardDelta } from './keyboardDelta';
|
|
7
|
+
import { requireWebRTC } from './webrtc.ios';
|
|
6
8
|
import { desktopInputEnabled, emitDesktopKeyboard, getDesktopSize, getDesktopStream, markDesktopPresented, nativeDesklink, observeDesktopSurface, registerDesktopView } from './native.ios';
|
|
7
9
|
|
|
8
10
|
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 };
|
|
11
|
+
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 };
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
/** Wheel detents accumulate in these steps and never send more than this at once. */
|
|
14
|
+
const MIN_WHEEL_STEP = 0.05;
|
|
15
|
+
const MAX_WHEEL_STEP = 10;
|
|
16
|
+
/** Desktop pixels one wheel detent stands for. */
|
|
17
|
+
const PIXELS_PER_DETENT = 120;
|
|
18
|
+
/** Where the cursor mark's tip sits inside the mark, in points. */
|
|
19
|
+
const CURSOR_HOTSPOT = 3;
|
|
20
|
+
|
|
21
|
+
type NativeEvent<T> = { nativeEvent: T };
|
|
22
|
+
type PointerEvent = { phase: 'hover' | 'down' | 'move' | 'up' | 'cancel'; x: number; y: number; buttons?: number; timestamp?: number };
|
|
23
|
+
type WheelEvent = { phase: 'begin' | 'move' | 'end'; dx: number; dy: number; x: number; y: number };
|
|
24
|
+
type InputViewProps = ViewProps & {
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
onKey: (event: NativeEvent<HardwareKey>) => void;
|
|
27
|
+
onPointer: (event: NativeEvent<PointerEvent>) => void;
|
|
28
|
+
onWheel: (event: NativeEvent<WheelEvent>) => void;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The package's native surface (`ios/DesklinkInputView.swift`): it reports the
|
|
33
|
+
* hardware keyboard and an iPad pointer. A build without it — an app that has
|
|
34
|
+
* not rebuilt its native project since updating — keeps a plain view, with
|
|
35
|
+
* touch and the on-screen keyboard only.
|
|
36
|
+
*/
|
|
37
|
+
function loadInputView(): React.ComponentType<InputViewProps> | null {
|
|
38
|
+
try {
|
|
39
|
+
return requireOptionalNativeModule('DesklinkInput') ? requireNativeViewManager<InputViewProps>('DesklinkInput') : null;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A press's desktop button from UIKit's button mask: primary, secondary, then the middle. */
|
|
46
|
+
function pressedButton(mask: number): number {
|
|
47
|
+
if (mask & 2) return 3;
|
|
48
|
+
if (mask & 4) return 2;
|
|
49
|
+
return 1;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The native video view, loaded on first render rather than with this module. */
|
|
53
|
+
function loadRTCView(): React.ComponentType<Record<string, unknown>> | null {
|
|
54
|
+
try {
|
|
55
|
+
const webrtc = requireWebRTC() as {
|
|
56
|
+
RTCView?: React.ComponentType<Record<string, unknown>>;
|
|
57
|
+
};
|
|
58
|
+
return webrtc.RTCView ?? null;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function DesktopView({ sessionId, style, placeholder, accessibilityLabel, keyboardClearance = 0, gestures = 'desktop' }: DesktopViewProps) {
|
|
12
65
|
const [revision, refresh] = React.useReducer((n: number) => n + 1, 0);
|
|
13
66
|
const [bounds, setBounds] = React.useState({ width: 0, height: 0 });
|
|
14
67
|
const [size, setSize] = React.useState(() => getDesktopSize(sessionId) ?? { width: 0, height: 0 });
|
|
@@ -21,10 +74,17 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
|
|
|
21
74
|
const input = React.useRef<TextInput>(null);
|
|
22
75
|
const captured = React.useRef(false);
|
|
23
76
|
const gesture = React.useRef<Gesture | null>(null);
|
|
77
|
+
const wheel = React.useRef({ x: 0, y: 0 });
|
|
24
78
|
const longPress = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
25
79
|
const lastTap = React.useRef<{ time: number; at: Point; point: Point } | null>(null);
|
|
80
|
+
const chordsDown = React.useRef(new Set<number>());
|
|
81
|
+
/** Touch timestamps of recent trackpad or mouse presses, which the native view reports and touch leaves alone. */
|
|
82
|
+
const pressStamps = React.useRef<number[]>([]);
|
|
83
|
+
const pressed = React.useRef<number | null>(null);
|
|
26
84
|
const [enabled, setEnabled] = React.useState(() => desktopInputEnabled(sessionId));
|
|
27
85
|
const stream = getDesktopStream(sessionId);
|
|
86
|
+
const RTCView = React.useMemo(loadRTCView, []);
|
|
87
|
+
const InputView = React.useMemo(loadInputView, []);
|
|
28
88
|
void revision;
|
|
29
89
|
|
|
30
90
|
React.useEffect(() => {
|
|
@@ -36,6 +96,20 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
|
|
|
36
96
|
const hide = Keyboard.addListener('keyboardWillHide', () => setKeyboardHeight(0));
|
|
37
97
|
return () => { show.remove(); hide.remove(); };
|
|
38
98
|
}, []);
|
|
99
|
+
React.useEffect(() => {
|
|
100
|
+
// A held drag belongs to the old meaning: let go of the button rather
|
|
101
|
+
// than release it somewhere the finger never meant.
|
|
102
|
+
const held = gesture.current;
|
|
103
|
+
if (held) {
|
|
104
|
+
stopTimer();
|
|
105
|
+
if (held.mode === 'drag') send({ kind: 'release_all' });
|
|
106
|
+
held.mode = 'spent';
|
|
107
|
+
}
|
|
108
|
+
flushWheel(true);
|
|
109
|
+
if (gestures === 'device') setCursor(null);
|
|
110
|
+
// `send` and `flushWheel` are stable per render; this runs on profile change.
|
|
111
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
112
|
+
}, [gestures]);
|
|
39
113
|
React.useEffect(() => {
|
|
40
114
|
if (!sessionId) return;
|
|
41
115
|
return registerDesktopView(sessionId, {
|
|
@@ -62,10 +136,26 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
|
|
|
62
136
|
if (!clamp && (px < 0 || py < 0 || px >= size.width || py >= size.height)) return null;
|
|
63
137
|
return { x: Math.max(0, Math.min(size.width - 1, px)), y: Math.max(0, Math.min(size.height - 1, py)) };
|
|
64
138
|
};
|
|
139
|
+
/** Hold the offset to the picture's edges at a zoom level. */
|
|
140
|
+
const clampOffset = (x: number, y: number, atScale: number = scale): Point => {
|
|
141
|
+
const maxX = Math.max(0, (size.width * atScale - bounds.width) / 2);
|
|
142
|
+
const maxY = Math.max(0, (size.height * atScale - visibleHeight) / 2);
|
|
143
|
+
return { x: Math.max(-maxX, Math.min(maxX, x)), y: Math.max(-maxY, Math.min(maxY, y)) };
|
|
144
|
+
};
|
|
65
145
|
const send = (control: Record<string, unknown>) => { if (sessionId) nativeDesklink.sendControl(sessionId, JSON.stringify(control)); };
|
|
66
|
-
const
|
|
146
|
+
const flushWheel = (force: boolean) => {
|
|
147
|
+
const { x, y } = wheel.current;
|
|
148
|
+
if (!force && Math.abs(x) < MIN_WHEEL_STEP && Math.abs(y) < MIN_WHEEL_STEP) return;
|
|
149
|
+
if (!x && !y) return;
|
|
150
|
+
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)) });
|
|
151
|
+
wheel.current = { x: 0, y: 0 };
|
|
152
|
+
};
|
|
153
|
+
const pointer = (phase: string, at: Point, button?: number, mark = true) => {
|
|
67
154
|
send({ kind: 'pointer', phase, x: at.x, y: at.y, ...(button ? { button } : {}) });
|
|
68
|
-
|
|
155
|
+
// A device screen shows the touch itself; a cursor would be a second finger that lies.
|
|
156
|
+
// A trackpad or mouse has the system's own pointer on screen.
|
|
157
|
+
if (!mark) setCursor(null);
|
|
158
|
+
else if (gestures !== 'device') setCursor(at);
|
|
69
159
|
};
|
|
70
160
|
const click = (at: Point, button = 1) => { pointer('down', at, button); pointer('up', at, button); };
|
|
71
161
|
const stopTimer = () => { if (longPress.current) clearTimeout(longPress.current); longPress.current = null; };
|
|
@@ -78,8 +168,24 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
|
|
|
78
168
|
if (!enabled) return;
|
|
79
169
|
const touches = event.nativeEvent.touches;
|
|
80
170
|
if (touches.length !== 1) return;
|
|
171
|
+
// A trackpad or mouse press the native view already reported is its own.
|
|
172
|
+
const stamp = touches[0].timestamp;
|
|
173
|
+
if (pressStamps.current.some((pressStamp) => Math.abs(pressStamp - stamp) < 1)) { gesture.current = null; return; }
|
|
81
174
|
const at = { x: touches[0].locationX, y: touches[0].locationY };
|
|
82
|
-
|
|
175
|
+
// A device screen is pressed, not pointed at: the finger lands with the
|
|
176
|
+
// button down. A tap is the same press lifted without moving, and a
|
|
177
|
+
// hold holds — there is no long press to arm.
|
|
178
|
+
if (gestures === 'device') {
|
|
179
|
+
const from = point(at.x, at.y);
|
|
180
|
+
gesture.current = { stamp, start: at, last: at, time: Date.now(), mode: from ? 'drag' : 'spent', span: 0, focus: at };
|
|
181
|
+
if (from) pointer('down', from, 1);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
// Off the picture there is no pointer to carry: the finger waits for a
|
|
185
|
+
// second one, and one finger alone is ignored. A zoomed picture pans
|
|
186
|
+
// from anywhere, clamped to its edges.
|
|
187
|
+
const onPicture = point(at.x, at.y) !== null;
|
|
188
|
+
gesture.current = { stamp, start: at, last: at, time: Date.now(), mode: onPicture || zoom > 1 ? 'pending' : 'letterbox', span: 0, focus: at };
|
|
83
189
|
stopTimer();
|
|
84
190
|
longPress.current = setTimeout(() => {
|
|
85
191
|
if (gesture.current?.mode === 'pending' && point(at.x, at.y)) gesture.current.mode = 'armed';
|
|
@@ -88,7 +194,7 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
|
|
|
88
194
|
const move = (event: GestureResponderEvent) => {
|
|
89
195
|
if (!enabled) return;
|
|
90
196
|
const g = gesture.current;
|
|
91
|
-
if (!g) return;
|
|
197
|
+
if (!g || !scale) return;
|
|
92
198
|
const touches = event.nativeEvent.touches;
|
|
93
199
|
if (touches.length === 2) {
|
|
94
200
|
stopTimer();
|
|
@@ -96,34 +202,75 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
|
|
|
96
202
|
const distance = span(touches);
|
|
97
203
|
if (g.mode !== 'two' && g.mode !== 'pinch' && g.mode !== 'scroll') {
|
|
98
204
|
if (g.mode === 'drag') send({ kind: 'release_all' });
|
|
205
|
+
if (g.mode === 'letterbox' && !touches.some((touch) => point(touch.locationX, touch.locationY))) {
|
|
206
|
+
// The letterbox is not the desktop: only a second finger
|
|
207
|
+
// on the picture starts a gesture.
|
|
208
|
+
g.mode = 'spent';
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
99
211
|
g.mode = 'two'; g.span = distance; g.focus = center; g.start = center;
|
|
212
|
+
wheel.current = { x: 0, y: 0 };
|
|
100
213
|
return;
|
|
101
214
|
}
|
|
102
215
|
if (g.mode === 'two') {
|
|
103
216
|
if (Math.abs(distance - g.span) > 12) g.mode = 'pinch';
|
|
104
|
-
else if (Math.hypot(center.x - g.start.x, center.y - g.start.y) > 8)
|
|
217
|
+
else if (Math.hypot(center.x - g.start.x, center.y - g.start.y) > 8) {
|
|
218
|
+
g.mode = 'scroll';
|
|
219
|
+
// The desktop scrolls whatever is under its pointer.
|
|
220
|
+
const under = point(center.x, center.y);
|
|
221
|
+
if (under) pointer('move', under);
|
|
222
|
+
}
|
|
105
223
|
}
|
|
106
224
|
if (g.mode === 'pinch' && g.span) {
|
|
107
|
-
|
|
225
|
+
const maxZoom = fit > 0 ? 2.5 / fit : 2.5;
|
|
226
|
+
const next = Math.max(1, Math.min(maxZoom, zoom * distance / g.span));
|
|
227
|
+
const applied = zoom > 0 ? next / zoom : 1;
|
|
228
|
+
// The desktop point under the focus stays under it while the
|
|
229
|
+
// offset follows the fingers, then everything clamps back.
|
|
230
|
+
const nextScale = fit * next;
|
|
231
|
+
const ox = (bounds.width - size.width * scale) / 2 + offset.x;
|
|
232
|
+
const oy = (visibleHeight - size.height * scale) / 2 + offset.y;
|
|
233
|
+
const baseX = (bounds.width - size.width * nextScale) / 2;
|
|
234
|
+
const baseY = (visibleHeight - size.height * nextScale) / 2;
|
|
235
|
+
setZoom(next);
|
|
236
|
+
setOffset(clampOffset(
|
|
237
|
+
center.x - (center.x - ox) * applied - baseX + (center.x - g.focus.x),
|
|
238
|
+
center.y - (center.y - oy) * applied - baseY + (center.y - g.focus.y),
|
|
239
|
+
nextScale,
|
|
240
|
+
));
|
|
108
241
|
} else if (g.mode === 'scroll') {
|
|
109
|
-
|
|
242
|
+
// Content follows the fingers: moving them up scrolls the page down.
|
|
243
|
+
wheel.current.x += -(center.x - g.focus.x) / scale / PIXELS_PER_DETENT;
|
|
244
|
+
wheel.current.y += -(center.y - g.focus.y) / scale / PIXELS_PER_DETENT;
|
|
245
|
+
flushWheel(false);
|
|
110
246
|
}
|
|
111
|
-
g.span = distance; g.focus = center;
|
|
247
|
+
g.span = distance; g.focus = center; g.last = center;
|
|
112
248
|
return;
|
|
113
249
|
}
|
|
114
|
-
if (touches.length !== 1 || g.mode === 'spent' || g.mode === 'two' || g.mode === 'pinch' || g.mode === 'scroll') return;
|
|
250
|
+
if (touches.length !== 1 || g.mode === 'spent' || g.mode === 'letterbox' || g.mode === 'two' || g.mode === 'pinch' || (g.mode === 'scroll' && gestures !== 'browser')) return;
|
|
115
251
|
const at = { x: touches[0].locationX, y: touches[0].locationY };
|
|
116
252
|
if (g.mode === 'pending' && Math.hypot(at.x - g.start.x, at.y - g.start.y) > 8) {
|
|
117
253
|
stopTimer();
|
|
118
|
-
|
|
254
|
+
// A page scrolls under one finger; the desktop's pointer rides along
|
|
255
|
+
// so the scroll lands where the finger is.
|
|
256
|
+
if (gestures === 'browser') {
|
|
257
|
+
g.mode = 'scroll';
|
|
258
|
+
const under = point(at.x, at.y);
|
|
259
|
+
if (under) pointer('move', under);
|
|
260
|
+
} else g.mode = zoom > 1 ? 'pan' : 'hover';
|
|
261
|
+
}
|
|
262
|
+
if (g.mode === 'scroll') {
|
|
263
|
+
// Content follows the finger: moving it up scrolls the page down.
|
|
264
|
+
wheel.current.x += -(at.x - g.last.x) / scale / PIXELS_PER_DETENT;
|
|
265
|
+
wheel.current.y += -(at.y - g.last.y) / scale / PIXELS_PER_DETENT;
|
|
266
|
+
flushWheel(false);
|
|
267
|
+
g.last = at; g.focus = at;
|
|
268
|
+
return;
|
|
119
269
|
}
|
|
120
270
|
if (g.mode === 'pan') {
|
|
121
271
|
const dx = at.x - g.last.x;
|
|
122
272
|
const dy = at.y - g.last.y;
|
|
123
|
-
setOffset((current) => (
|
|
124
|
-
x: Math.max(-Math.max(0, (pictureWidth - bounds.width) / 2), Math.min(Math.max(0, (pictureWidth - bounds.width) / 2), current.x + dx)),
|
|
125
|
-
y: Math.max(-Math.max(0, (pictureHeight - visibleHeight) / 2), Math.min(Math.max(0, (pictureHeight - visibleHeight) / 2), current.y + dy)),
|
|
126
|
-
}));
|
|
273
|
+
setOffset((current) => clampOffset(current.x + dx, current.y + dy));
|
|
127
274
|
}
|
|
128
275
|
if (g.mode === 'armed' && Math.hypot(at.x - g.start.x, at.y - g.start.y) > 8) {
|
|
129
276
|
const from = point(g.start.x, g.start.y, true);
|
|
@@ -140,6 +287,8 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
|
|
|
140
287
|
stopTimer();
|
|
141
288
|
const g = gesture.current;
|
|
142
289
|
if (!g) return;
|
|
290
|
+
// Leftover wheel below the send threshold still belongs to this scroll.
|
|
291
|
+
if (g.mode === 'scroll') flushWheel(true);
|
|
143
292
|
if (g.mode === 'two' && Date.now() - g.time < 350) {
|
|
144
293
|
const at = point(g.focus.x, g.focus.y);
|
|
145
294
|
if (at) click(at, 3);
|
|
@@ -175,18 +324,86 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
|
|
|
175
324
|
}
|
|
176
325
|
setKeyboardText(keyboardTextRef.current);
|
|
177
326
|
};
|
|
327
|
+
const onKey = ({ nativeEvent }: NativeEvent<HardwareKey>) => {
|
|
328
|
+
if (!enabled || !sessionId) return;
|
|
329
|
+
const action = hardwareKey(nativeEvent, chordsDown.current, captured.current);
|
|
330
|
+
if (action === null) return;
|
|
331
|
+
if ('send' in action) send(action.send);
|
|
332
|
+
else emitDesktopKeyboard(sessionId, action.capture);
|
|
333
|
+
};
|
|
334
|
+
const onPointer = ({ nativeEvent: { phase, x, y, buttons = 0, timestamp = NaN } }: NativeEvent<PointerEvent>) => {
|
|
335
|
+
if (!enabled) return;
|
|
336
|
+
if (phase === 'hover') {
|
|
337
|
+
const at = point(x, y);
|
|
338
|
+
if (at) pointer('move', at, undefined, false);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (phase === 'down') {
|
|
342
|
+
pressStamps.current = [...pressStamps.current.slice(-7), timestamp];
|
|
343
|
+
const button = pressedButton(buttons);
|
|
344
|
+
// Touch may have seen this press first: it is the pointer's, not a finger's.
|
|
345
|
+
const g = gesture.current;
|
|
346
|
+
let held = false;
|
|
347
|
+
if (g && Math.abs(g.stamp - timestamp) < 1) {
|
|
348
|
+
stopTimer();
|
|
349
|
+
gesture.current = null;
|
|
350
|
+
if (g.mode === 'drag') {
|
|
351
|
+
const from = point(g.start.x, g.start.y, true);
|
|
352
|
+
held = button === 1;
|
|
353
|
+
if (!held && from) pointer('up', from, 1, false);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
// The letterbox is not the desktop: a press there does nothing.
|
|
357
|
+
const at = point(x, y);
|
|
358
|
+
if (!at) return;
|
|
359
|
+
pressed.current = button;
|
|
360
|
+
if (!held) pointer('down', at, button, false);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const button = pressed.current;
|
|
364
|
+
if (button === null) return;
|
|
365
|
+
const at = point(x, y, true);
|
|
366
|
+
if (phase === 'move') { if (at) pointer('move', at, button, false); return; }
|
|
367
|
+
pressed.current = null;
|
|
368
|
+
if (at) pointer('up', at, button, false); else send({ kind: 'release_all' });
|
|
369
|
+
};
|
|
370
|
+
const onWheel = ({ nativeEvent: { phase, dx, dy, x, y } }: NativeEvent<WheelEvent>) => {
|
|
371
|
+
if (!enabled || !scale) return;
|
|
372
|
+
if (phase === 'end') { flushWheel(true); return; }
|
|
373
|
+
if (phase === 'begin') {
|
|
374
|
+
// The desktop scrolls whatever is under its pointer.
|
|
375
|
+
const under = point(x, y);
|
|
376
|
+
if (under) pointer('move', under, undefined, false);
|
|
377
|
+
}
|
|
378
|
+
// Content follows the fingers, as with two fingers on the screen.
|
|
379
|
+
wheel.current.x += -dx / scale / PIXELS_PER_DETENT;
|
|
380
|
+
wheel.current.y += -dy / scale / PIXELS_PER_DETENT;
|
|
381
|
+
flushWheel(false);
|
|
382
|
+
};
|
|
178
383
|
const onLayout = (event: LayoutChangeEvent) => setBounds(event.nativeEvent.layout);
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
{
|
|
384
|
+
const surface: ViewProps = {
|
|
385
|
+
style: [styles.surface, style], onLayout, accessible: accessibilityLabel !== undefined, accessibilityLabel,
|
|
386
|
+
onStartShouldSetResponder: () => enabled, onMoveShouldSetResponder: () => enabled,
|
|
387
|
+
onResponderGrant: start, onResponderMove: move, onResponderRelease: end,
|
|
388
|
+
onResponderTerminate: () => { stopTimer(); if (gesture.current?.mode === 'drag') send({ kind: 'release_all' }); gesture.current = null; },
|
|
389
|
+
};
|
|
390
|
+
const children = <>
|
|
391
|
+
{sessionId && stream && RTCView && size.width > 0 && size.height > 0 ?
|
|
184
392
|
<RTCView pointerEvents="none" streamURL={stream.toURL()} objectFit="contain" style={{ position: 'absolute', left: originX, top: originY, width: pictureWidth, height: pictureHeight }}
|
|
185
393
|
onDimensionsChange={(event) => { if (event.nativeEvent.width > 0 && event.nativeEvent.height > 0) markDesktopPresented(sessionId); }} /> : placeholder}
|
|
186
|
-
{cursor && <View pointerEvents="none" style={[styles.cursor, { left: originX + (cursor.x + 0.5) * scale, top: originY + (cursor.y + 0.5) * scale }]} />}
|
|
187
|
-
<TextInput ref={input} style={styles.input} value={keyboardText} onChangeText={changeText} autoCorrect={false} autoCapitalize="none" submitBehavior="submit"
|
|
188
|
-
onSubmitEditing={() => {
|
|
189
|
-
|
|
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
|
+
<TextInput ref={input} style={styles.input} value={keyboardText} onChangeText={changeText} autoCorrect={false} autoCapitalize="none" submitBehavior="submit" accessible={false}
|
|
396
|
+
onSubmitEditing={() => {
|
|
397
|
+
// While the app holds a sticky modifier, Enter is the app's to
|
|
398
|
+
// chord: it goes through capture like every other key, so
|
|
399
|
+
// Ctrl+Enter reaches the desktop instead of typing a newline.
|
|
400
|
+
if (captured.current && sessionId) emitDesktopKeyboard(sessionId, { key: 'Enter' });
|
|
401
|
+
else { send({ kind: 'key', name: 'Enter', modifiers: [], down: true }); send({ kind: 'key', name: 'Enter', modifiers: [], down: false }); }
|
|
402
|
+
}} />
|
|
403
|
+
</>;
|
|
404
|
+
return InputView
|
|
405
|
+
? <InputView {...surface} enabled={enabled} onKey={onKey} onPointer={onPointer} onWheel={onWheel}>{children}</InputView>
|
|
406
|
+
: <View {...surface}>{children}</View>;
|
|
190
407
|
}
|
|
191
408
|
|
|
192
409
|
const styles = StyleSheet.create({
|
package/src/DesktopView.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import * as React from 'react';
|
|
|
3
3
|
import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
|
|
4
4
|
|
|
5
5
|
import { desktopAvailable } from './native';
|
|
6
|
+
import type { GestureProfile } from './protocol';
|
|
6
7
|
|
|
7
8
|
export interface DesktopViewProps {
|
|
8
9
|
/** The session handle from `useDesktopSession`. */
|
|
@@ -18,6 +19,11 @@ export interface DesktopViewProps {
|
|
|
18
19
|
* following the keyboard as it moves.
|
|
19
20
|
*/
|
|
20
21
|
keyboardClearance?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Which touch meaning the surface uses: a desktop pointer, a browser page
|
|
24
|
+
* (one finger scrolls), or a device screen (one finger presses and drags).
|
|
25
|
+
*/
|
|
26
|
+
gestures?: GestureProfile;
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
interface NativeSurfaceProps {
|
|
@@ -26,6 +32,7 @@ interface NativeSurfaceProps {
|
|
|
26
32
|
accessible?: boolean;
|
|
27
33
|
accessibilityLabel?: string;
|
|
28
34
|
keyboardClearance?: number;
|
|
35
|
+
gestures?: GestureProfile;
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
// The Expo view is resolved at module load; on a platform without it the
|
|
@@ -42,7 +49,7 @@ const NativeSurface: React.ComponentType<NativeSurfaceProps> | null = desktopAva
|
|
|
42
49
|
* belong to the application, which mounts this wherever it wants the desktop
|
|
43
50
|
* to appear.
|
|
44
51
|
*/
|
|
45
|
-
export function DesktopView({ sessionId, style, placeholder, accessibilityLabel, keyboardClearance = 0 }: DesktopViewProps) {
|
|
52
|
+
export function DesktopView({ sessionId, style, placeholder, accessibilityLabel, keyboardClearance = 0, gestures = 'desktop' }: DesktopViewProps) {
|
|
46
53
|
if (NativeSurface == null || sessionId == null) {
|
|
47
54
|
return (
|
|
48
55
|
<View style={[styles.surface, style]}>
|
|
@@ -50,7 +57,7 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
|
|
|
50
57
|
</View>
|
|
51
58
|
);
|
|
52
59
|
}
|
|
53
|
-
return <NativeSurface style={[styles.surface, style]} sessionId={sessionId} accessible={accessibilityLabel !== undefined} accessibilityLabel={accessibilityLabel} keyboardClearance={keyboardClearance} />;
|
|
60
|
+
return <NativeSurface style={[styles.surface, style]} sessionId={sessionId} accessible={accessibilityLabel !== undefined} accessibilityLabel={accessibilityLabel} keyboardClearance={keyboardClearance} gestures={gestures} />;
|
|
54
61
|
}
|
|
55
62
|
|
|
56
63
|
const styles = StyleSheet.create({
|
package/src/DesktopView.web.tsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
3
|
|
|
4
|
+
import type { GestureProfile } from './protocol';
|
|
5
|
+
|
|
4
6
|
export interface DesktopViewProps {
|
|
5
7
|
/** The session handle from `useDesktopSession`. */
|
|
6
8
|
sessionId: string | null;
|
|
@@ -14,6 +16,11 @@ export interface DesktopViewProps {
|
|
|
14
16
|
* controls, in points. While the keyboard is up the picture sits above both.
|
|
15
17
|
*/
|
|
16
18
|
keyboardClearance?: number;
|
|
19
|
+
/**
|
|
20
|
+
* Which touch meaning the surface uses: a desktop pointer, a browser page
|
|
21
|
+
* (one finger scrolls), or a device screen (one finger presses and drags).
|
|
22
|
+
*/
|
|
23
|
+
gestures?: GestureProfile;
|
|
17
24
|
}
|
|
18
25
|
|
|
19
26
|
/**
|
|
@@ -25,19 +32,24 @@ export interface DesktopViewProps {
|
|
|
25
32
|
* actually mounted rather than at module load, so an application that never
|
|
26
33
|
* opens a desktop does not carry one in its first paint.
|
|
27
34
|
*/
|
|
28
|
-
export function DesktopView({ sessionId, style, placeholder, accessibilityLabel, keyboardClearance = 0 }: DesktopViewProps) {
|
|
35
|
+
export function DesktopView({ sessionId, style, placeholder, accessibilityLabel, keyboardClearance = 0, gestures = 'desktop' }: DesktopViewProps) {
|
|
29
36
|
const mounted = React.useCallback(
|
|
30
37
|
(node: unknown) => {
|
|
31
38
|
const element = (node as HTMLElement | null) ?? null;
|
|
32
39
|
if (sessionId === null) return;
|
|
33
40
|
void import('./native').then((platform) => {
|
|
34
41
|
if (element === null || !platform.desktopAvailable) return;
|
|
35
|
-
platform.attachSurface(sessionId, element);
|
|
42
|
+
platform.attachSurface(sessionId, element, gestures);
|
|
36
43
|
});
|
|
37
44
|
},
|
|
38
45
|
[sessionId],
|
|
39
46
|
);
|
|
40
47
|
|
|
48
|
+
React.useEffect(() => {
|
|
49
|
+
if (sessionId === null) return;
|
|
50
|
+
void import('./native').then((platform) => platform.setGestures(sessionId, gestures));
|
|
51
|
+
}, [sessionId, gestures]);
|
|
52
|
+
|
|
41
53
|
React.useEffect(() => {
|
|
42
54
|
if (sessionId === null) return;
|
|
43
55
|
void import('./native').then((platform) => platform.setKeyboardClearance(sessionId, keyboardClearance));
|
|
@@ -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
|
|
60
|
-
|
|
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);
|