@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 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 as a native module, plus the browser, which brings its own WebRTC.
20
- `expo-module.config.json` declares the native side honestly rather than
21
- compiling an untested stub for other platforms; `ios/DEFERRED.md` lists exactly
22
- what an iOS backend has to implement.
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: hold the screen on its side; 'auto' to follow the phone
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
- - **Hardware-first decoding.** The session builds its own peer connection with
65
- a hardware-first decoder factory for VP9, falling back to software on phones
66
- without a VP9 hardware decoder. No other WebRTC user in the app is affected.
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. A mouse keeps its own.
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`), moving with the keyboard as it slides. A picture too
79
- tall for what is left keeps the pointer, where a tap just put the caret, in
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,** decided natively with a
86
- slop threshold: tap to click, and a second tap close by is a double click on
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. Under congestion, only the latest unsent pointer position is kept;
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.** `status: 'live'` is set by the first frame
101
- actually presented, not by a track arriving or ICE connecting.
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
@@ -2,7 +2,7 @@ apply plugin: 'com.android.library'
2
2
  apply plugin: 'kotlin-android'
3
3
 
4
4
  group = 'expo.modules.desklink'
5
- version = '0.1.0'
5
+ version = '0.1.1'
6
6
 
7
7
  android {
8
8
  namespace 'expo.modules.desklink'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desklink/react-native",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Render and drive a desktop session from a React Native app",
6
6
  "main": "index.ts",
@@ -0,0 +1,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
- * The native session module.
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
@@ -14,7 +14,7 @@
14
14
  * only describe the subset a client needs.
15
15
  */
16
16
 
17
- export const PROTOCOL_VERSION = 2;
17
+ export const PROTOCOL_VERSION = 3;
18
18
 
19
19
  export type Permission = 'view' | 'control' | 'clipboard';
20
20
 
@@ -1,18 +1,18 @@
1
1
  import type { ControlMessage } from './protocol';
2
2
 
3
3
  /**
4
- * Ctrl and Shift for a touch keyboard, which has neither.
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
 
@@ -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
- /** Ctrl and Shift as sticky keys: off, armed for the next key, or locked. */
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