@desklink/react-native 0.1.0 → 0.1.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 +20 -23
- package/android/build.gradle +1 -1
- package/package.json +1 -1
- package/src/DesktopView.ios.tsx +196 -0
- package/src/keyboardDelta.ts +7 -0
- package/src/native.ios.ts +186 -0
- package/src/native.ts +2 -7
- package/src/protocol.ts +1 -1
- package/src/stickyModifiers.ts +6 -4
- package/src/useDesktopSession.ts +2 -2
package/README.md
CHANGED
|
@@ -16,14 +16,11 @@ repository it resolves through the workspace; anywhere else, install it from npm
|
|
|
16
16
|
npx expo install @desklink/react-native # or yarn add, then prebuild/rebuild
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
Android
|
|
20
|
-
`
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
It compiles against the WebRTC binding the app already ships
|
|
25
|
-
(`react-native-webrtc`) so there is one `org.webrtc` copy in the binary, and it
|
|
26
|
-
never touches that binding's process-global decoder factory.
|
|
19
|
+
Android uses an Expo native module; iOS uses the app's existing
|
|
20
|
+
`react-native-webrtc` peer connection and `RTCView` from JavaScript. Both need a
|
|
21
|
+
dev client or native build containing that binding. Web uses the browser's WebRTC.
|
|
22
|
+
No second WebRTC binary is linked. The Android module has its own hardware-first
|
|
23
|
+
decoder factory; iOS uses the decoder shipped by `react-native-webrtc`.
|
|
27
24
|
|
|
28
25
|
## Use
|
|
29
26
|
|
|
@@ -44,7 +41,7 @@ await desktop.connect();
|
|
|
44
41
|
// On a deliberate control action, once the picture is live:
|
|
45
42
|
desktop.setInputEnabled(true);
|
|
46
43
|
desktop.showKeyboard();
|
|
47
|
-
desktop.setOrientation('landscape'); // Android
|
|
44
|
+
desktop.setOrientation('landscape'); // Android only; iOS apps configure supported orientations themselves
|
|
48
45
|
await desktop.pasteLocalToRemote(await Clipboard.getStringAsync());
|
|
49
46
|
```
|
|
50
47
|
|
|
@@ -61,29 +58,29 @@ import { desktopAvailable } from '@desklink/react-native/availability';
|
|
|
61
58
|
|
|
62
59
|
### What the package guarantees
|
|
63
60
|
|
|
64
|
-
- **
|
|
65
|
-
|
|
66
|
-
|
|
61
|
+
- **VP9 decoding.** Android builds a session-scoped hardware-first decoder
|
|
62
|
+
factory with software fallback. iOS uses the app's `react-native-webrtc`
|
|
63
|
+
decoder; neither platform links a second WebRTC stack.
|
|
67
64
|
- **A sharp, zoomable picture.** The desktop fits the view by default; a pinch
|
|
68
65
|
zooms up to 2.5 view pixels per desktop pixel and one finger moves around the
|
|
69
66
|
zoomed desktop. On Android the decoded frame is copied once into the view's
|
|
70
67
|
own texture and drawn with a multi-tap filter when it is shown smaller than
|
|
71
68
|
its size, so a fitted 4K desktop does not alias and a pinch redraws at once.
|
|
72
|
-
`fitToView()` shows the whole desktop again.
|
|
69
|
+
iOS uses `RTCView`'s native renderer. `fitToView()` shows the whole desktop again.
|
|
73
70
|
- **A pointer a phone can see.** Once a touch has sent the desktop's pointer
|
|
74
71
|
somewhere, the view draws it there at a readable size, over a picture whose
|
|
75
|
-
own cursor is a few pixels tall or not captured at all.
|
|
72
|
+
own cursor is a few pixels tall or not captured at all. Android hides the
|
|
73
|
+
extra mark when a mouse is attached.
|
|
76
74
|
- **The picture above the keyboard.** While the phone's keyboard is up, the
|
|
77
75
|
picture sits above it and above the room the app keeps for its own controls
|
|
78
|
-
(`keyboardClearance`)
|
|
79
|
-
|
|
80
|
-
sight.
|
|
76
|
+
(`keyboardClearance`). Android follows the keyboard animation and keeps the
|
|
77
|
+
pointer in sight; iOS follows the keyboard show/hide bounds.
|
|
81
78
|
- **Contained geometry.** Touch maps through the picture's actual placement.
|
|
82
79
|
A one-finger drag starting in the letterbox does not move the pointer; a
|
|
83
80
|
second finger on the picture can still start a pinch or scroll. A drag that
|
|
84
81
|
leaves the picture is held to its edge rather than released somewhere unseen.
|
|
85
|
-
- **The gestures remote-desktop viewers settled on,**
|
|
86
|
-
|
|
82
|
+
- **The gestures remote-desktop viewers settled on,** with a slop threshold:
|
|
83
|
+
tap to click, and a second tap close by is a double click on
|
|
87
84
|
the same point; hold and release for a right click where the finger rested
|
|
88
85
|
(every desktop app's context menu); hold then drag for the left button
|
|
89
86
|
(select text, move a window); on the whole desktop a finger that moves
|
|
@@ -91,14 +88,14 @@ import { desktopAvailable } from '@desklink/react-native/availability';
|
|
|
91
88
|
under the finger while the picture catches up; two fingers scroll the desktop
|
|
92
89
|
under them, or pinch; a quick two-finger tap is a right click too. Scrolling
|
|
93
90
|
is fractional wheel steps, smooth where the desktop supports high-resolution
|
|
94
|
-
wheels.
|
|
95
|
-
other input follows that position in order.
|
|
91
|
+
wheels. Android coalesces pending pointer moves under congestion.
|
|
96
92
|
- **The keys a phone lacks.** `modifiers`, `tapModifier` and `pressKey` give
|
|
97
93
|
sticky Ctrl and Shift: tap arms one for the next key, tap again locks it.
|
|
98
94
|
While one is armed, the next key or character the phone's keyboard types is
|
|
99
95
|
sent as that key's chord, so Ctrl then v is Ctrl+V. The app draws the keys.
|
|
100
|
-
- **Readiness is a rendered frame.**
|
|
101
|
-
|
|
96
|
+
- **Readiness is a rendered frame.** Android marks the first draw; iOS uses
|
|
97
|
+
`RTCView`'s native video-dimensions callback, which fires after a decoded
|
|
98
|
+
picture arrives. Neither marks a track or ICE connection as live.
|
|
102
99
|
- **Control starts off.** `setInputEnabled(true)` enables pointer, keyboard and
|
|
103
100
|
clipboard input; `setInputEnabled(false)` blocks new input, releases held keys
|
|
104
101
|
and buttons, and resets pending gestures. The app decides when to enable it
|
package/android/build.gradle
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,196 @@
|
|
|
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';
|
|
4
|
+
import type { DesktopViewProps } from './DesktopView';
|
|
5
|
+
import { keyboardDelta } from './keyboardDelta';
|
|
6
|
+
import { desktopInputEnabled, emitDesktopKeyboard, getDesktopSize, getDesktopStream, markDesktopPresented, nativeDesklink, observeDesktopSurface, registerDesktopView } from './native.ios';
|
|
7
|
+
|
|
8
|
+
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 };
|
|
10
|
+
|
|
11
|
+
export function DesktopView({ sessionId, style, placeholder, accessibilityLabel, keyboardClearance = 0 }: DesktopViewProps) {
|
|
12
|
+
const [revision, refresh] = React.useReducer((n: number) => n + 1, 0);
|
|
13
|
+
const [bounds, setBounds] = React.useState({ width: 0, height: 0 });
|
|
14
|
+
const [size, setSize] = React.useState(() => getDesktopSize(sessionId) ?? { width: 0, height: 0 });
|
|
15
|
+
const [keyboardHeight, setKeyboardHeight] = React.useState(0);
|
|
16
|
+
const [zoom, setZoom] = React.useState(1);
|
|
17
|
+
const [offset, setOffset] = React.useState<Point>({ x: 0, y: 0 });
|
|
18
|
+
const [cursor, setCursor] = React.useState<Point | null>(null);
|
|
19
|
+
const [keyboardText, setKeyboardText] = React.useState('');
|
|
20
|
+
const keyboardTextRef = React.useRef('');
|
|
21
|
+
const input = React.useRef<TextInput>(null);
|
|
22
|
+
const captured = React.useRef(false);
|
|
23
|
+
const gesture = React.useRef<Gesture | null>(null);
|
|
24
|
+
const longPress = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
25
|
+
const lastTap = React.useRef<{ time: number; at: Point; point: Point } | null>(null);
|
|
26
|
+
const [enabled, setEnabled] = React.useState(() => desktopInputEnabled(sessionId));
|
|
27
|
+
const stream = getDesktopStream(sessionId);
|
|
28
|
+
void revision;
|
|
29
|
+
|
|
30
|
+
React.useEffect(() => {
|
|
31
|
+
setEnabled(desktopInputEnabled(sessionId));
|
|
32
|
+
return observeDesktopSurface(() => { refresh(); setEnabled(desktopInputEnabled(sessionId)); });
|
|
33
|
+
}, [sessionId]);
|
|
34
|
+
React.useEffect(() => {
|
|
35
|
+
const show = Keyboard.addListener('keyboardWillShow', (event) => setKeyboardHeight(event.endCoordinates.height));
|
|
36
|
+
const hide = Keyboard.addListener('keyboardWillHide', () => setKeyboardHeight(0));
|
|
37
|
+
return () => { show.remove(); hide.remove(); };
|
|
38
|
+
}, []);
|
|
39
|
+
React.useEffect(() => {
|
|
40
|
+
if (!sessionId) return;
|
|
41
|
+
return registerDesktopView(sessionId, {
|
|
42
|
+
show: () => { if (desktopInputEnabled(sessionId)) input.current?.focus(); },
|
|
43
|
+
hide: () => { input.current?.blur(); Keyboard.dismiss(); },
|
|
44
|
+
capture: (value) => { captured.current = value; },
|
|
45
|
+
}, {
|
|
46
|
+
size: (width, height) => setSize({ width, height }),
|
|
47
|
+
fit: () => { setZoom(1); setOffset({ x: 0, y: 0 }); },
|
|
48
|
+
});
|
|
49
|
+
}, [sessionId]);
|
|
50
|
+
|
|
51
|
+
const visibleHeight = Math.max(1, bounds.height - (keyboardHeight ? keyboardHeight + keyboardClearance : 0));
|
|
52
|
+
const fit = size.width && size.height ? Math.min(bounds.width / size.width, visibleHeight / size.height) : 1;
|
|
53
|
+
const scale = fit * zoom;
|
|
54
|
+
const pictureWidth = size.width * scale;
|
|
55
|
+
const pictureHeight = size.height * scale;
|
|
56
|
+
const originX = (bounds.width - pictureWidth) / 2 + offset.x;
|
|
57
|
+
const originY = (visibleHeight - pictureHeight) / 2 + offset.y;
|
|
58
|
+
const point = (x: number, y: number, clamp = false): Point | null => {
|
|
59
|
+
if (!size.width || !size.height || !scale) return null;
|
|
60
|
+
const px = Math.floor((x - originX) / scale);
|
|
61
|
+
const py = Math.floor((y - originY) / scale);
|
|
62
|
+
if (!clamp && (px < 0 || py < 0 || px >= size.width || py >= size.height)) return null;
|
|
63
|
+
return { x: Math.max(0, Math.min(size.width - 1, px)), y: Math.max(0, Math.min(size.height - 1, py)) };
|
|
64
|
+
};
|
|
65
|
+
const send = (control: Record<string, unknown>) => { if (sessionId) nativeDesklink.sendControl(sessionId, JSON.stringify(control)); };
|
|
66
|
+
const pointer = (phase: string, at: Point, button?: number) => {
|
|
67
|
+
send({ kind: 'pointer', phase, x: at.x, y: at.y, ...(button ? { button } : {}) });
|
|
68
|
+
setCursor(at);
|
|
69
|
+
};
|
|
70
|
+
const click = (at: Point, button = 1) => { pointer('down', at, button); pointer('up', at, button); };
|
|
71
|
+
const stopTimer = () => { if (longPress.current) clearTimeout(longPress.current); longPress.current = null; };
|
|
72
|
+
const focus = (touches: readonly { locationX: number; locationY: number }[]): Point => ({
|
|
73
|
+
x: (touches[0].locationX + touches[1].locationX) / 2,
|
|
74
|
+
y: (touches[0].locationY + touches[1].locationY) / 2,
|
|
75
|
+
});
|
|
76
|
+
const span = (touches: readonly { locationX: number; locationY: number }[]) => Math.hypot(touches[0].locationX - touches[1].locationX, touches[0].locationY - touches[1].locationY);
|
|
77
|
+
const start = (event: GestureResponderEvent) => {
|
|
78
|
+
if (!enabled) return;
|
|
79
|
+
const touches = event.nativeEvent.touches;
|
|
80
|
+
if (touches.length !== 1) return;
|
|
81
|
+
const at = { x: touches[0].locationX, y: touches[0].locationY };
|
|
82
|
+
gesture.current = { start: at, last: at, time: Date.now(), mode: 'pending', span: 0, focus: at };
|
|
83
|
+
stopTimer();
|
|
84
|
+
longPress.current = setTimeout(() => {
|
|
85
|
+
if (gesture.current?.mode === 'pending' && point(at.x, at.y)) gesture.current.mode = 'armed';
|
|
86
|
+
}, 500);
|
|
87
|
+
};
|
|
88
|
+
const move = (event: GestureResponderEvent) => {
|
|
89
|
+
if (!enabled) return;
|
|
90
|
+
const g = gesture.current;
|
|
91
|
+
if (!g) return;
|
|
92
|
+
const touches = event.nativeEvent.touches;
|
|
93
|
+
if (touches.length === 2) {
|
|
94
|
+
stopTimer();
|
|
95
|
+
const center = focus(touches);
|
|
96
|
+
const distance = span(touches);
|
|
97
|
+
if (g.mode !== 'two' && g.mode !== 'pinch' && g.mode !== 'scroll') {
|
|
98
|
+
if (g.mode === 'drag') send({ kind: 'release_all' });
|
|
99
|
+
g.mode = 'two'; g.span = distance; g.focus = center; g.start = center;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (g.mode === 'two') {
|
|
103
|
+
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) g.mode = 'scroll';
|
|
105
|
+
}
|
|
106
|
+
if (g.mode === 'pinch' && g.span) {
|
|
107
|
+
setZoom((value) => Math.max(1, Math.min(2.5 / fit, value * distance / g.span)));
|
|
108
|
+
} else if (g.mode === 'scroll') {
|
|
109
|
+
send({ kind: 'wheel', dx: -(center.x - g.focus.x) / scale / 120, dy: -(center.y - g.focus.y) / scale / 120 });
|
|
110
|
+
}
|
|
111
|
+
g.span = distance; g.focus = center;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (touches.length !== 1 || g.mode === 'spent' || g.mode === 'two' || g.mode === 'pinch' || g.mode === 'scroll') return;
|
|
115
|
+
const at = { x: touches[0].locationX, y: touches[0].locationY };
|
|
116
|
+
if (g.mode === 'pending' && Math.hypot(at.x - g.start.x, at.y - g.start.y) > 8) {
|
|
117
|
+
stopTimer();
|
|
118
|
+
g.mode = zoom > 1 ? 'pan' : 'hover';
|
|
119
|
+
}
|
|
120
|
+
if (g.mode === 'pan') {
|
|
121
|
+
const dx = at.x - g.last.x;
|
|
122
|
+
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
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
if (g.mode === 'armed' && Math.hypot(at.x - g.start.x, at.y - g.start.y) > 8) {
|
|
129
|
+
const from = point(g.start.x, g.start.y, true);
|
|
130
|
+
if (from) pointer('down', from, 1);
|
|
131
|
+
g.mode = 'drag';
|
|
132
|
+
}
|
|
133
|
+
if (g.mode === 'hover' || g.mode === 'drag') {
|
|
134
|
+
const to = point(at.x, at.y, true);
|
|
135
|
+
if (to) pointer('move', to, g.mode === 'drag' ? 1 : undefined);
|
|
136
|
+
}
|
|
137
|
+
g.last = at;
|
|
138
|
+
};
|
|
139
|
+
const end = (event: GestureResponderEvent) => {
|
|
140
|
+
stopTimer();
|
|
141
|
+
const g = gesture.current;
|
|
142
|
+
if (!g) return;
|
|
143
|
+
if (g.mode === 'two' && Date.now() - g.time < 350) {
|
|
144
|
+
const at = point(g.focus.x, g.focus.y);
|
|
145
|
+
if (at) click(at, 3);
|
|
146
|
+
g.mode = 'spent';
|
|
147
|
+
}
|
|
148
|
+
// A remaining finger after a two-finger gesture must not begin a click.
|
|
149
|
+
if (event.nativeEvent.touches.length > 0) { g.mode = 'spent'; return; }
|
|
150
|
+
gesture.current = null;
|
|
151
|
+
if (g.mode === 'drag') { const at = point(g.last.x, g.last.y, true); if (at) pointer('up', at, 1); else send({ kind: 'release_all' }); }
|
|
152
|
+
else if (g.mode === 'armed') { const at = point(g.start.x, g.start.y); if (at) click(at, 3); }
|
|
153
|
+
else if (g.mode === 'pending') {
|
|
154
|
+
const at = point(g.start.x, g.start.y);
|
|
155
|
+
if (at) {
|
|
156
|
+
const prev = lastTap.current;
|
|
157
|
+
const repeated = prev && Date.now() - prev.time < 350 && Math.hypot(prev.at.x - g.start.x, prev.at.y - g.start.y) < 40;
|
|
158
|
+
click(repeated ? prev.point : at);
|
|
159
|
+
lastTap.current = { time: Date.now(), at: g.start, point: repeated ? prev.point : at };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
const changeText = (text: string) => {
|
|
164
|
+
// Retain only a short suffix so iOS can generate deletion events while
|
|
165
|
+
// autocorrect and multi-stage input still arrive as committed text.
|
|
166
|
+
const { removed, added } = keyboardDelta(keyboardTextRef.current, text);
|
|
167
|
+
keyboardTextRef.current = text.length > 64 ? text.slice(-32) : text;
|
|
168
|
+
for (let i = 0; i < removed; i++) {
|
|
169
|
+
if (captured.current && sessionId) emitDesktopKeyboard(sessionId, { key: 'Backspace' });
|
|
170
|
+
else { send({ kind: 'key', name: 'Backspace', modifiers: [], down: true }); send({ kind: 'key', name: 'Backspace', modifiers: [], down: false }); }
|
|
171
|
+
}
|
|
172
|
+
if (added) {
|
|
173
|
+
if (captured.current && sessionId) emitDesktopKeyboard(sessionId, { text: added });
|
|
174
|
+
else send({ kind: 'text', text: added });
|
|
175
|
+
}
|
|
176
|
+
setKeyboardText(keyboardTextRef.current);
|
|
177
|
+
};
|
|
178
|
+
const onLayout = (event: LayoutChangeEvent) => setBounds(event.nativeEvent.layout);
|
|
179
|
+
return <View style={[styles.surface, style]} onLayout={onLayout} accessibilityLabel={accessibilityLabel}
|
|
180
|
+
onStartShouldSetResponder={() => enabled} onMoveShouldSetResponder={() => enabled}
|
|
181
|
+
onResponderGrant={start} onResponderMove={move} onResponderRelease={end}
|
|
182
|
+
onResponderTerminate={() => { stopTimer(); if (gesture.current?.mode === 'drag') send({ kind: 'release_all' }); gesture.current = null; }}>
|
|
183
|
+
{sessionId && stream && size.width > 0 && size.height > 0 ?
|
|
184
|
+
<RTCView pointerEvents="none" streamURL={stream.toURL()} objectFit="contain" style={{ position: 'absolute', left: originX, top: originY, width: pictureWidth, height: pictureHeight }}
|
|
185
|
+
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={() => { send({ kind: 'key', name: 'Enter', modifiers: [], down: true }); send({ kind: 'key', name: 'Enter', modifiers: [], down: false }); }} />
|
|
189
|
+
</View>;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const styles = StyleSheet.create({
|
|
193
|
+
surface: { backgroundColor: '#000', overflow: 'hidden' },
|
|
194
|
+
cursor: { position: 'absolute', width: 12, height: 18, backgroundColor: '#fff', borderWidth: 2, borderColor: '#000', transform: [{ rotate: '-30deg' }] },
|
|
195
|
+
input: { position: 'absolute', width: 1, height: 1, opacity: 0, left: 0, bottom: 0 },
|
|
196
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export function keyboardDelta(previous: string, next: string) {
|
|
2
|
+
const before = [...previous];
|
|
3
|
+
const after = [...next];
|
|
4
|
+
let common = 0;
|
|
5
|
+
while (common < before.length && common < after.length && before[common] === after[common]) common++;
|
|
6
|
+
return { removed: before.length - common, added: after.slice(common).join('') };
|
|
7
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { MediaStream, RTCPeerConnection } from 'react-native-webrtc';
|
|
2
|
+
import type { NativeDesklinkModule, NativeSessionEvent } from './native';
|
|
3
|
+
|
|
4
|
+
type Session = {
|
|
5
|
+
peer: RTCPeerConnection;
|
|
6
|
+
channel: ReturnType<RTCPeerConnection['createDataChannel']> | null;
|
|
7
|
+
stream: MediaStream | null;
|
|
8
|
+
input: boolean;
|
|
9
|
+
seq: number;
|
|
10
|
+
closed: boolean;
|
|
11
|
+
candidates: Array<{ candidate: string; sdpMid: string | null; sdpMLineIndex: number }>;
|
|
12
|
+
remoteSet: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const sessions = new Map<string, Session>();
|
|
16
|
+
const listeners = new Set<(event: NativeSessionEvent) => void>();
|
|
17
|
+
const surfaces = new Set<() => void>();
|
|
18
|
+
const sizes = new Map<string, { width: number; height: number }>();
|
|
19
|
+
let nextId = 0;
|
|
20
|
+
|
|
21
|
+
function emit(sessionId: string, name: NativeSessionEvent['name'], payload: Record<string, unknown> = {}) {
|
|
22
|
+
if (!sessions.has(sessionId) && name !== 'closed') return;
|
|
23
|
+
for (const listener of listeners) listener({ sessionId, name, payload });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function notifySurface() {
|
|
27
|
+
for (const listener of surfaces) listener();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function getDesktopStream(id: string | null): MediaStream | null {
|
|
31
|
+
return id == null ? null : sessions.get(id)?.stream ?? null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getDesktopSize(id: string | null) {
|
|
35
|
+
return id == null ? null : sizes.get(id) ?? null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function observeDesktopSurface(listener: () => void): () => void {
|
|
39
|
+
surfaces.add(listener);
|
|
40
|
+
return () => surfaces.delete(listener);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function markDesktopPresented(id: string) {
|
|
44
|
+
emit(id, 'presented');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function desktopInputEnabled(id: string | null): boolean {
|
|
48
|
+
return id != null && sessions.get(id)?.input === true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function failure(id: string, error: unknown) {
|
|
52
|
+
emit(id, 'failure', { code: 'transport', message: error instanceof Error ? error.message : String(error) });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const nativeDesklink: NativeDesklinkModule = {
|
|
56
|
+
createSession(iceServersJson) {
|
|
57
|
+
const id = `desklink-${++nextId}`;
|
|
58
|
+
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 };
|
|
61
|
+
sessions.set(id, session);
|
|
62
|
+
peer.onicecandidate = (event) => {
|
|
63
|
+
if (event.candidate) emit(id, 'candidate', {
|
|
64
|
+
candidate: event.candidate.candidate,
|
|
65
|
+
sdpMid: event.candidate.sdpMid,
|
|
66
|
+
sdpMLineIndex: event.candidate.sdpMLineIndex,
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
peer.oniceconnectionstatechange = () => {
|
|
70
|
+
emit(id, 'ice', { state: peer.iceConnectionState.toUpperCase() });
|
|
71
|
+
if (peer.iceConnectionState === 'failed') failure(id, 'the connection to the desktop was lost');
|
|
72
|
+
};
|
|
73
|
+
peer.ontrack = (event) => {
|
|
74
|
+
if (session.closed || event.track.kind !== 'video') return;
|
|
75
|
+
const stream = event.streams[0] ?? new MediaStream();
|
|
76
|
+
if (event.streams.length === 0) stream.addTrack(event.track);
|
|
77
|
+
session.stream = stream;
|
|
78
|
+
notifySurface();
|
|
79
|
+
emit(id, 'track');
|
|
80
|
+
};
|
|
81
|
+
peer.ondatachannel = (event) => {
|
|
82
|
+
if (session.closed) return;
|
|
83
|
+
const channel = event.channel;
|
|
84
|
+
session.channel = channel;
|
|
85
|
+
channel.onopen = () => emit(id, 'channel', { state: 'OPEN' });
|
|
86
|
+
channel.onclose = () => emit(id, 'channel', { state: 'CLOSED' });
|
|
87
|
+
channel.onmessage = (message) => emit(id, 'control', { message: message.data });
|
|
88
|
+
};
|
|
89
|
+
queueMicrotask(() => emit(id, 'ready'));
|
|
90
|
+
return id;
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
setRemoteDescription(id, type, sdp) {
|
|
96
|
+
const session = sessions.get(id);
|
|
97
|
+
if (!session) return false;
|
|
98
|
+
if (type !== 'offer') { failure(id, 'the engine must send an offer'); return false; }
|
|
99
|
+
void (async () => {
|
|
100
|
+
try {
|
|
101
|
+
await session.peer.setRemoteDescription({ type: 'offer', sdp });
|
|
102
|
+
if (session.closed) return;
|
|
103
|
+
session.remoteSet = true;
|
|
104
|
+
for (const candidate of session.candidates.splice(0)) await session.peer.addIceCandidate(candidate);
|
|
105
|
+
const answer = await session.peer.createAnswer();
|
|
106
|
+
await session.peer.setLocalDescription(answer);
|
|
107
|
+
if (!session.closed) emit(id, 'answer', { sdp: session.peer.localDescription?.sdp ?? answer.sdp });
|
|
108
|
+
} catch (error) { if (!session.closed) failure(id, error); }
|
|
109
|
+
})();
|
|
110
|
+
return true;
|
|
111
|
+
},
|
|
112
|
+
addRemoteCandidate(id, candidate, sdpMid, sdpMLineIndex) {
|
|
113
|
+
const session = sessions.get(id);
|
|
114
|
+
if (!session) return false;
|
|
115
|
+
const ice = { candidate, sdpMid, sdpMLineIndex: sdpMLineIndex ?? 0 };
|
|
116
|
+
if (!session.remoteSet) session.candidates.push(ice);
|
|
117
|
+
else void session.peer.addIceCandidate(ice).catch((error) => failure(id, error));
|
|
118
|
+
return true;
|
|
119
|
+
},
|
|
120
|
+
sendControl(id, message) {
|
|
121
|
+
const session = sessions.get(id);
|
|
122
|
+
if (!session || session.closed || session.channel?.readyState !== 'open') return false;
|
|
123
|
+
try {
|
|
124
|
+
const control = JSON.parse(message) as Record<string, unknown>;
|
|
125
|
+
if (!session.input && ['pointer', 'wheel', 'key', 'text', 'clipboard_read', 'clipboard_write'].includes(String(control.kind))) return false;
|
|
126
|
+
session.channel.send(JSON.stringify({ ...control, seq: ++session.seq }));
|
|
127
|
+
return true;
|
|
128
|
+
} catch (error) { failure(id, error); return false; }
|
|
129
|
+
},
|
|
130
|
+
setInputEnabled(id, enabled) {
|
|
131
|
+
const session = sessions.get(id);
|
|
132
|
+
if (!session) return false;
|
|
133
|
+
session.input = enabled;
|
|
134
|
+
if (!enabled) nativeDesklink.sendControl(id, '{"kind":"release_all"}');
|
|
135
|
+
notifySurface();
|
|
136
|
+
return true;
|
|
137
|
+
},
|
|
138
|
+
showKeyboard(id) { keyboardActions.get(id)?.show(); return true; },
|
|
139
|
+
hideKeyboard(id) { keyboardActions.get(id)?.hide(); return true; },
|
|
140
|
+
captureKeyboard(id, captured) { keyboardActions.get(id)?.capture(captured); return true; },
|
|
141
|
+
// iOS rotation belongs to the application's supported orientations; this
|
|
142
|
+
// receiver cannot override its Info.plist at runtime.
|
|
143
|
+
setOrientation() { return false; },
|
|
144
|
+
setSurfaceSize(id, width, height) {
|
|
145
|
+
if (!sessions.has(id) || width <= 0 || height <= 0) return false;
|
|
146
|
+
sizes.set(id, { width, height });
|
|
147
|
+
surfaceActions.get(id)?.size(width, height);
|
|
148
|
+
return true;
|
|
149
|
+
},
|
|
150
|
+
fitToView(id) { surfaceActions.get(id)?.fit(); return true; },
|
|
151
|
+
closeSession(id) {
|
|
152
|
+
const session = sessions.get(id);
|
|
153
|
+
if (!session) return true;
|
|
154
|
+
session.closed = true;
|
|
155
|
+
session.channel?.close();
|
|
156
|
+
session.peer.close();
|
|
157
|
+
sessions.delete(id);
|
|
158
|
+
sizes.delete(id);
|
|
159
|
+
keyboardActions.delete(id);
|
|
160
|
+
surfaceActions.delete(id);
|
|
161
|
+
notifySurface();
|
|
162
|
+
for (const listener of listeners) listener({ sessionId: id, name: 'closed', payload: {} });
|
|
163
|
+
return true;
|
|
164
|
+
},
|
|
165
|
+
isAvailable() { return true; },
|
|
166
|
+
addListener(_name, handler) { listeners.add(handler); return { remove: () => { listeners.delete(handler); } }; },
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
export const desktopAvailable = true;
|
|
170
|
+
|
|
171
|
+
type KeyboardActions = { show: () => void; hide: () => void; capture: (value: boolean) => void };
|
|
172
|
+
type SurfaceActions = { size: (width: number, height: number) => void; fit: () => void };
|
|
173
|
+
const keyboardActions = new Map<string, KeyboardActions>();
|
|
174
|
+
const surfaceActions = new Map<string, SurfaceActions>();
|
|
175
|
+
export function registerDesktopView(id: string, keyboard: KeyboardActions, surface: SurfaceActions): () => void {
|
|
176
|
+
keyboardActions.set(id, keyboard);
|
|
177
|
+
surfaceActions.set(id, surface);
|
|
178
|
+
const size = sizes.get(id);
|
|
179
|
+
if (size) surface.size(size.width, size.height);
|
|
180
|
+
return () => {
|
|
181
|
+
if (keyboardActions.get(id) === keyboard) keyboardActions.delete(id);
|
|
182
|
+
if (surfaceActions.get(id) === surface) surfaceActions.delete(id);
|
|
183
|
+
nativeDesklink.sendControl(id, '{"kind":"release_all"}');
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
export function emitDesktopKeyboard(id: string, payload: Record<string, unknown>) { emit(id, 'keyboard', payload); }
|
package/src/native.ts
CHANGED
|
@@ -2,14 +2,9 @@ import { Platform } from 'react-native';
|
|
|
2
2
|
import { requireOptionalNativeModule } from 'expo-modules-core';
|
|
3
3
|
|
|
4
4
|
|
|
5
|
-
|
|
6
5
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* Everywhere but Android the module is absent, `desktopAvailable` is false, and
|
|
10
|
-
* the package still exports its types plus the web surface — an application
|
|
11
|
-
* renders one screen on every platform and branches on capability, not on
|
|
12
|
-
* platform name.
|
|
6
|
+
* Android's native session module. Metro resolves `native.ios.ts` instead on
|
|
7
|
+
* iOS, where the receiver uses the app's react-native-webrtc binding.
|
|
13
8
|
*/
|
|
14
9
|
export type NativeEventName =
|
|
15
10
|
| 'ready'
|
package/src/protocol.ts
CHANGED
package/src/stickyModifiers.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import type { ControlMessage } from './protocol';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* Desktop modifiers for a touch keyboard.
|
|
5
5
|
*
|
|
6
6
|
* The cycle is the terminal key row's: one tap arms a modifier for the next
|
|
7
7
|
* key, a second tap locks it, a third lets it go. An armed modifier travels
|
|
8
8
|
* with the key it modifies, as that key's own chord, so the desktop never holds
|
|
9
9
|
* a modifier on its own and nothing can be left pressed between keys.
|
|
10
10
|
*/
|
|
11
|
-
export type StickyModifier = 'Control' | 'Shift';
|
|
11
|
+
export type StickyModifier = 'Control' | 'Shift' | 'Alt' | 'Meta';
|
|
12
12
|
export type StickyState = 'off' | 'once' | 'lock';
|
|
13
13
|
export type StickyModifiers = Readonly<Record<StickyModifier, StickyState>>;
|
|
14
14
|
|
|
15
|
-
export const NO_MODIFIERS: StickyModifiers = { Control: 'off', Shift: 'off' };
|
|
15
|
+
export const NO_MODIFIERS: StickyModifiers = { Control: 'off', Shift: 'off', Alt: 'off', Meta: 'off' };
|
|
16
16
|
|
|
17
17
|
const AFTER_TAP: Record<StickyState, StickyState> = { off: 'once', once: 'lock', lock: 'off' };
|
|
18
18
|
|
|
@@ -22,7 +22,7 @@ export function tapModifier(current: StickyModifiers, name: StickyModifier): Sti
|
|
|
22
22
|
|
|
23
23
|
/** The modifiers the next key carries, in the engine's names. */
|
|
24
24
|
export function armedModifiers(current: StickyModifiers): StickyModifier[] {
|
|
25
|
-
return (['Control', 'Shift'] as const).filter((name) => current[name] !== 'off');
|
|
25
|
+
return (['Control', 'Shift', 'Alt', 'Meta'] as const).filter((name) => current[name] !== 'off');
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/** What stays armed once a key has used the modifiers: a lock stays, the rest let go. */
|
|
@@ -30,6 +30,8 @@ export function afterKey(current: StickyModifiers): StickyModifiers {
|
|
|
30
30
|
return {
|
|
31
31
|
Control: current.Control === 'lock' ? 'lock' : 'off',
|
|
32
32
|
Shift: current.Shift === 'lock' ? 'lock' : 'off',
|
|
33
|
+
Alt: current.Alt === 'lock' ? 'lock' : 'off',
|
|
34
|
+
Meta: current.Meta === 'lock' ? 'lock' : 'off',
|
|
33
35
|
};
|
|
34
36
|
}
|
|
35
37
|
|
package/src/useDesktopSession.ts
CHANGED
|
@@ -125,7 +125,7 @@ export interface DesktopSession {
|
|
|
125
125
|
/** Hold the screen in landscape while the desktop is shown, or follow the phone again. */
|
|
126
126
|
setOrientation: (mode: 'landscape' | 'auto') => void;
|
|
127
127
|
send: (message: ControlMessage) => void;
|
|
128
|
-
/**
|
|
128
|
+
/** Desktop modifiers as sticky keys: off, armed for the next key, or locked. */
|
|
129
129
|
modifiers: StickyModifiers;
|
|
130
130
|
/** Tap a sticky modifier: off, then armed for the next key, then locked, then off. */
|
|
131
131
|
tapModifier: (name: StickyModifier) => void;
|
|
@@ -218,7 +218,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
218
218
|
|
|
219
219
|
const applyModifiers = useCallback((next: StickyModifiers) => {
|
|
220
220
|
const current = modifiersRef.current;
|
|
221
|
-
if (next.Control === current.Control && next.Shift === current.Shift) return;
|
|
221
|
+
if (next.Control === current.Control && next.Shift === current.Shift && next.Alt === current.Alt && next.Meta === current.Meta) return;
|
|
222
222
|
modifiersRef.current = next;
|
|
223
223
|
setModifiers(next);
|
|
224
224
|
// The phone's keyboard is the session's own only while a modifier waits
|