@desklink/react-native 0.1.0 → 0.2.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.
@@ -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/native.web.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { NativeDesklinkModule, NativeEventName, NativeSessionEvent } from './native';
2
+ import type { GestureProfile } from './protocol';
2
3
  import { observeWebKeyboardMotion } from './webKeyboardMotion';
3
4
 
4
5
  /**
@@ -114,6 +115,8 @@ interface WebSession {
114
115
  longPress: ReturnType<typeof setTimeout> | null;
115
116
  /** While a sticky modifier waits for its key, typing is the session's to chord. */
116
117
  captured: boolean;
118
+ /** Which touch meaning this surface uses; a browser page and a device screen answer one finger differently. */
119
+ gestures: GestureProfile;
117
120
  inputEnabled: boolean;
118
121
  /** The word the keyboard is composing, and how much of it has already gone. */
119
122
  composition: string;
@@ -378,8 +381,9 @@ function cancelLongPress(session: WebSession): void {
378
381
  function pointer(session: WebSession, phase: 'move' | 'down' | 'up' | 'cancel', at: { x: number; y: number }, button?: number): void {
379
382
  control(session, { kind: 'pointer', phase, x: at.x, y: at.y, ...(button === undefined ? {} : { button }), seq: seq(session) });
380
383
  // A mouse brings its own cursor; a finger leaves nothing on the glass to
381
- // show where the desktop's pointer went, so the mark does.
382
- if (session.mouse || phase === 'cancel') return;
384
+ // show where the desktop's pointer went, so the mark does. A device screen
385
+ // shows the touch itself, so a mark would be a second finger that lies.
386
+ if (session.mouse || phase === 'cancel' || session.gestures === 'device') return;
383
387
  session.pointerAt = { x: at.x, y: at.y };
384
388
  placePointer(session);
385
389
  }
@@ -391,13 +395,15 @@ function click(session: WebSession, at: { x: number; y: number }, button: number
391
395
 
392
396
  /**
393
397
  * Touch gestures, the ones mature remote-desktop viewers settled on — the same
394
- * as the native view's:
398
+ * as the native view's. The profile reshapes one finger only:
395
399
  *
396
400
  * - tap: click where the finger lands; two taps: a double click on one spot;
397
401
  * - press and hold: a right click on release, or drag after it to hold the
398
402
  * left button (select text, move a window);
399
403
  * - one finger: move around a zoomed-in desktop; on the whole desktop,
400
- * move its pointer, which follows the finger without pressing a button;
404
+ * move its pointer, which follows the finger without pressing a button —
405
+ * unless the profile is `browser`, where it scrolls the page, or `device`,
406
+ * where it lands with the button down and drags with it held;
401
407
  * - two fingers: scroll the desktop under them, or pinch to zoom (and move)
402
408
  * the picture; a quick two-finger tap is a right click.
403
409
  *
@@ -534,6 +540,27 @@ function attachGestures(session: WebSession): () => void {
534
540
  }
535
541
  session.touches.set(event.pointerId, { x, y });
536
542
  if (session.touches.size === 1) {
543
+ // A device screen is pressed, not pointed at: the finger lands with
544
+ // the button down, drags with it held, and lifts to release. A tap
545
+ // is the same down and up with no move, so it clicks for free, and
546
+ // a hold holds for free — there is no long press to arm.
547
+ if (session.gestures === 'device') {
548
+ session.downX = x;
549
+ session.downY = y;
550
+ session.lastX = x;
551
+ session.lastY = y;
552
+ const at = desktopPoint(session, x, y);
553
+ if (at === null) {
554
+ session.gesture = 'letterbox';
555
+ return;
556
+ }
557
+ session.gesture = 'drag';
558
+ session.lastTap = null;
559
+ pointer(session, 'down', at, 1);
560
+ session.dragX = at.x;
561
+ session.dragY = at.y;
562
+ return;
563
+ }
537
564
  session.downOnPicture = desktopPoint(session, x, y) !== null;
538
565
  session.gesture = session.view.fitted && !session.downOnPicture ? 'letterbox' : 'pending';
539
566
  session.downX = x;
@@ -586,6 +613,16 @@ function attachGestures(session: WebSession): () => void {
586
613
  if (Math.hypot(x - session.downX, y - session.downY) > TOUCH_SLOP) {
587
614
  cancelLongPress(session);
588
615
  session.lastTap = null;
616
+ // A page scrolls under one finger; the desktop's pointer
617
+ // rides along so the scroll lands where the finger is.
618
+ if (session.gestures === 'browser') {
619
+ session.gesture = 'scroll';
620
+ session.lastX = x;
621
+ session.lastY = y;
622
+ const at = desktopPoint(session, x, y);
623
+ if (at !== null) pointer(session, 'move', at);
624
+ return;
625
+ }
589
626
  // The whole desktop has nowhere to move to, so the finger
590
627
  // moves the desktop's pointer instead, without a button.
591
628
  session.gesture = session.view.fitted
@@ -620,6 +657,15 @@ function attachGestures(session: WebSession): () => void {
620
657
  case 'pinch':
621
658
  case 'scroll':
622
659
  if (session.touches.size >= 2) twoFingers();
660
+ else if (session.gesture === 'scroll' && session.gestures === 'browser') {
661
+ // One finger dragging a page: content follows it, in pixel
662
+ // deltas turned to detents like the two-finger scroll.
663
+ session.wheelX -= (x - session.lastX) / session.view.scale / PIXELS_PER_DETENT;
664
+ session.wheelY -= (y - session.lastY) / session.view.scale / PIXELS_PER_DETENT;
665
+ session.lastX = x;
666
+ session.lastY = y;
667
+ flushWheel(false);
668
+ }
623
669
  return;
624
670
  default:
625
671
  return;
@@ -662,6 +708,7 @@ function attachGestures(session: WebSession): () => void {
662
708
  return;
663
709
  }
664
710
  if (gesture === 'pending') tap(x, y);
711
+ else if (gesture === 'scroll') flushWheel(true);
665
712
  else if (gesture === 'hover') hoverTo(x, y);
666
713
  else if (gesture === 'armed') {
667
714
  const at = desktopPoint(session, session.downX, session.downY);
@@ -902,6 +949,7 @@ export const nativeDesklink: NativeDesklinkModule = {
902
949
  lastTap: null,
903
950
  longPress: null,
904
951
  captured: false,
952
+ gestures: 'desktop',
905
953
  inputEnabled: false,
906
954
  composition: '',
907
955
  compositionSent: '',
@@ -959,6 +1007,13 @@ export const nativeDesklink: NativeDesklinkModule = {
959
1007
  emit(id, 'failure', { code: 'transport', message: 'the connection to the desktop was lost' });
960
1008
  }
961
1009
  };
1010
+ peer.oniceconnectionstatechange = () => {
1011
+ if (sessions.get(id) !== session) return;
1012
+ // Surfaced as it happens — upstream of any failure timeout — so a
1013
+ // short drop reads as reconnecting within about a second, not as
1014
+ // a frozen picture that still says live.
1015
+ emit(id, 'ice', { state: peer.iceConnectionState.toUpperCase() });
1016
+ };
962
1017
  return id;
963
1018
  },
964
1019
 
@@ -1130,9 +1185,35 @@ export const nativeDesklink: NativeDesklinkModule = {
1130
1185
  * surface, and removing the old one is what keeps a re-render from leaving two
1131
1186
  * live videos behind.
1132
1187
  */
1133
- export function attachSurface(id: string, container: HTMLElement | null): void {
1188
+ /** Which touch meaning a surface uses; changing it mid-session is safe. */
1189
+ export function setGestures(id: string, gestures: GestureProfile): void {
1190
+ const session = sessions.get(id);
1191
+ if (session === undefined) return;
1192
+ if (session.gestures === gestures) return;
1193
+ // A held drag belongs to the old meaning: let go rather than release the
1194
+ // button somewhere the finger never meant.
1195
+ if (session.gesture === 'drag' || session.gesture === 'mouse') endGestureDrag(session);
1196
+ cancelLongPress(session);
1197
+ session.gesture = 'none';
1198
+ session.touches.clear();
1199
+ session.gestures = gestures;
1200
+ // A device screen shows the touch itself: a mark left over from another
1201
+ // profile would be a finger that is not there.
1202
+ if (gestures === 'device' && session.pointerAt !== null) {
1203
+ session.pointerAt = null;
1204
+ placePointer(session);
1205
+ }
1206
+ }
1207
+
1208
+ /** Release a held button at the last desktop point it was sent to. */
1209
+ function endGestureDrag(session: WebSession): void {
1210
+ pointer(session, 'up', { x: session.dragX, y: session.dragY }, 1);
1211
+ }
1212
+
1213
+ export function attachSurface(id: string, container: HTMLElement | null, gestures: GestureProfile = 'desktop'): void {
1134
1214
  const session = sessions.get(id);
1135
1215
  if (session === undefined) return;
1216
+ session.gestures = gestures;
1136
1217
  session.detach?.();
1137
1218
  session.detach = null;
1138
1219
  if (session.surface !== null) session.surface.remove();
package/src/protocol.ts CHANGED
@@ -14,10 +14,18 @@
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
 
21
+ /**
22
+ * Which touch meaning a surface uses. `desktop` is the trackpad-shaped
23
+ * default; `browser` scrolls a page with one finger; `device` presses the
24
+ * screen with one finger, like the finger itself was there. The wire messages
25
+ * are the same under all three — only which gesture sends which changes.
26
+ */
27
+ export type GestureProfile = 'desktop' | 'browser' | 'device';
28
+
21
29
  export interface IceServerConfig {
22
30
  urls: string[];
23
31
  username?: string;
@@ -61,13 +69,21 @@ export interface RtcCandidate {
61
69
  sdpMLineIndex?: number | null;
62
70
  }
63
71
 
64
- /** Engine → client lifecycle events, already unwrapped from the local protocol. */
72
+ /**
73
+ * Engine → client lifecycle events, already unwrapped from the local protocol.
74
+ *
75
+ * `sessionId` names the engine session the event belongs to, when the
76
+ * carrier preserves it. The hook ignores an event whose id does not match
77
+ * the session it holds: after an outage, a previous session's queued offer
78
+ * or revocation can arrive beside the new session's, and applying those to
79
+ * the new session corrupts or kills a healthy recovery.
80
+ */
65
81
  export type SessionEvent =
66
- | { kind: 'description'; description: RtcDescription }
67
- | { kind: 'candidate'; candidate: RtcCandidate }
68
- | { kind: 'state'; capture: string; transport: string; firstFrame: boolean }
69
- | { kind: 'restoreToken'; token: string }
70
- | { kind: 'revoked'; reason: string };
82
+ | { kind: 'description'; description: RtcDescription; sessionId?: string }
83
+ | { kind: 'candidate'; candidate: RtcCandidate; sessionId?: string }
84
+ | { kind: 'state'; capture: string; transport: string; firstFrame: boolean; sessionId?: string }
85
+ | { kind: 'restoreToken'; token: string; sessionId?: string }
86
+ | { kind: 'revoked'; reason: string; sessionId?: string };
71
87
 
72
88
  /**
73
89
  * The application's own authenticated channel to the host engine.
@@ -139,7 +155,14 @@ export type ControlReply =
139
155
  | { kind: 'ack'; seq: number }
140
156
  | { kind: 'rejected'; seq: number; code: string; message: string }
141
157
  | { kind: 'clipboard'; request: string; text: string; truncated?: boolean; error?: string }
142
- | { kind: 'revoked'; reason: string };
158
+ | { kind: 'revoked'; reason: string }
159
+ /**
160
+ * The engine's heartbeat, twice a second while the control channel is
161
+ * open. A client that has seen one and then sees none for a while reads
162
+ * the path as stalled, long before ICE consent timers notice. Clients that
163
+ * predate it simply ignore it.
164
+ */
165
+ | { kind: 'ping' };
143
166
 
144
167
  export function parseControlReply(raw: string): ControlReply | null {
145
168
  try {
@@ -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