@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.
- package/README.md +58 -26
- package/android/build.gradle +1 -1
- 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/package.json +4 -1
- package/src/DesktopView.ios.tsx +227 -0
- package/src/DesktopView.tsx +9 -2
- package/src/DesktopView.web.tsx +14 -2
- package/src/keyboardDelta.ts +7 -0
- package/src/native.ios.ts +186 -0
- package/src/native.ts +2 -7
- package/src/native.web.ts +86 -5
- package/src/protocol.ts +31 -8
- package/src/stickyModifiers.ts +6 -4
- package/src/useDesktopSession.ts +422 -57
|
@@ -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/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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
/**
|
|
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 {
|
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
|
|