@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
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,30 @@ 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, and the `device` profile draws no mark
|
|
74
|
+
on any platform — the screen being touched shows the finger itself.
|
|
76
75
|
- **The picture above the keyboard.** While the phone's keyboard is up, the
|
|
77
76
|
picture sits above it and above the room the app keeps for its own controls
|
|
78
|
-
(`keyboardClearance`)
|
|
79
|
-
|
|
80
|
-
sight.
|
|
77
|
+
(`keyboardClearance`). Android follows the keyboard animation and keeps the
|
|
78
|
+
pointer in sight; iOS follows the keyboard show/hide bounds.
|
|
81
79
|
- **Contained geometry.** Touch maps through the picture's actual placement.
|
|
82
80
|
A one-finger drag starting in the letterbox does not move the pointer; a
|
|
83
81
|
second finger on the picture can still start a pinch or scroll. A drag that
|
|
84
82
|
leaves the picture is held to its edge rather than released somewhere unseen.
|
|
85
|
-
- **The gestures remote-desktop viewers settled on,**
|
|
86
|
-
|
|
83
|
+
- **The gestures remote-desktop viewers settled on,** with a slop threshold:
|
|
84
|
+
tap to click, and a second tap close by is a double click on
|
|
87
85
|
the same point; hold and release for a right click where the finger rested
|
|
88
86
|
(every desktop app's context menu); hold then drag for the left button
|
|
89
87
|
(select text, move a window); on the whole desktop a finger that moves
|
|
@@ -91,14 +89,48 @@ import { desktopAvailable } from '@desklink/react-native/availability';
|
|
|
91
89
|
under the finger while the picture catches up; two fingers scroll the desktop
|
|
92
90
|
under them, or pinch; a quick two-finger tap is a right click too. Scrolling
|
|
93
91
|
is fractional wheel steps, smooth where the desktop supports high-resolution
|
|
94
|
-
wheels.
|
|
95
|
-
|
|
92
|
+
wheels. Android coalesces pending pointer moves under congestion. The
|
|
93
|
+
`gestures` prop reshapes one finger for what the picture shows, and can be
|
|
94
|
+
changed mid-session: `browser` scrolls the page under one finger (a tap
|
|
95
|
+
still clicks and a hold still right-clicks), `device` presses the screen —
|
|
96
|
+
the finger lands with the button down, drags with it held and lifts to
|
|
97
|
+
release, so a tap clicks and a hold holds with no long-press right click,
|
|
98
|
+
and no drawn pointer — while the default `desktop` is the trackpad above.
|
|
99
|
+
Two fingers scroll and pinch the same under every profile.
|
|
96
100
|
- **The keys a phone lacks.** `modifiers`, `tapModifier` and `pressKey` give
|
|
97
101
|
sticky Ctrl and Shift: tap arms one for the next key, tap again locks it.
|
|
98
102
|
While one is armed, the next key or character the phone's keyboard types is
|
|
99
103
|
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
|
-
|
|
104
|
+
- **Readiness is a rendered frame.** Android marks the first draw; iOS uses
|
|
105
|
+
`RTCView`'s native video-dimensions callback, which fires after a decoded
|
|
106
|
+
picture arrives. Neither marks a track or ICE connection as live.
|
|
107
|
+
- **Connection states, as they happen.** The session status is `idle`,
|
|
108
|
+
`opening`, `connecting`, `live`, `reconnecting`, `ended` or `failed`. The
|
|
109
|
+
peer's ICE state and the engine's `session.state` both feed it: a
|
|
110
|
+
`disconnected` path reads as `reconnecting` within about a second of the
|
|
111
|
+
drop — never a frozen picture that still says live — and a recovered path
|
|
112
|
+
reads as `live` again once ICE is `connected`. `failed` stays terminal.
|
|
113
|
+
Every event carries its engine session id where the carrier preserves it,
|
|
114
|
+
and the session ignores anything naming a session it no longer holds, so a
|
|
115
|
+
previous generation's queued offer or revocation cannot corrupt or kill a
|
|
116
|
+
healthy recovery.
|
|
117
|
+
Detection is two-layered: ICE and engine `session.state` events, plus the
|
|
118
|
+
engine's twice-a-second control-channel heartbeat (`{"kind":"ping"}`) — a
|
|
119
|
+
path that stops heartbeating reads as stalled within ~1.5 s even while ICE
|
|
120
|
+
consent timers are still making up their minds, and resumed heartbeats read
|
|
121
|
+
as recovered. Clients on engines that predate the heartbeat simply never
|
|
122
|
+
arm that watchdog.
|
|
123
|
+
- **ICE restarts before reopens.** When the path drops after frames were
|
|
124
|
+
shown, the session asks the engine (`session.restart_ice`) to re-offer on
|
|
125
|
+
the same peer connection: the fresh offer arrives as the usual
|
|
126
|
+
`session.description` event and is answered as usual, all without reopening
|
|
127
|
+
the session. The first restart waits ~2 s for blips that heal alone;
|
|
128
|
+
retries back off across ~60 s, so a 30–60 s outage still recovers when the
|
|
129
|
+
network returns. Only a terminal `failed` reopens the session, and those
|
|
130
|
+
reopens back off across ~60 s too instead of spending one attempt while the
|
|
131
|
+
network is still down. Renegotiation transients from a restart the session
|
|
132
|
+
asked for itself are expected, not a new outage: they neither flip the
|
|
133
|
+
status nor trigger another restart for a short grace window.
|
|
102
134
|
- **Control starts off.** `setInputEnabled(true)` enables pointer, keyboard and
|
|
103
135
|
clipboard input; `setInputEnabled(false)` blocks new input, releases held keys
|
|
104
136
|
and buttons, and resets pending gestures. The app decides when to enable it
|
|
@@ -122,9 +154,9 @@ interface Signaling {
|
|
|
122
154
|
```
|
|
123
155
|
|
|
124
156
|
`method` is one of `session.open`, `session.description`, `session.candidate`,
|
|
125
|
-
`session.close`; `SessionEvent` is the engine's offer,
|
|
126
|
-
revocation. Pixels and input do **not** use this
|
|
127
|
-
desktop's own WebRTC session.
|
|
157
|
+
`session.restart_ice`, `session.close`; `SessionEvent` is the engine's offer,
|
|
158
|
+
candidates, state and revocation. Pixels and input do **not** use this
|
|
159
|
+
channel — they are the desktop's own WebRTC session.
|
|
128
160
|
|
|
129
161
|
## Licence
|
|
130
162
|
|
package/android/build.gradle
CHANGED
|
@@ -126,6 +126,10 @@ class DesklinkModule : Module() {
|
|
|
126
126
|
view.setKeyboardClearance(clearance ?: 0f)
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
Prop("gestures") { view: DesktopView, gestures: String? ->
|
|
130
|
+
view.setGestures(gestures ?: "desktop")
|
|
131
|
+
}
|
|
132
|
+
|
|
129
133
|
OnViewDestroys { view: DesktopView -> view.release() }
|
|
130
134
|
|
|
131
135
|
OnViewDidUpdateProps { view: DesktopView ->
|
|
@@ -66,7 +66,9 @@ private const val MIN_WHEEL_STEP = 0.05f
|
|
|
66
66
|
* - press and hold: a right click on release, or drag after it to hold the left
|
|
67
67
|
* button (select text, move a window);
|
|
68
68
|
* - one finger: move around a zoomed-in desktop; on the whole desktop,
|
|
69
|
-
* move its pointer, which follows the finger without pressing a button
|
|
69
|
+
* move its pointer, which follows the finger without pressing a button —
|
|
70
|
+
* unless `gestures` is `browser`, where it scrolls the page, or `device`,
|
|
71
|
+
* where it lands with the button down and drags with it held;
|
|
70
72
|
* - two fingers: scroll the desktop under them, or pinch to zoom (and move) the
|
|
71
73
|
* picture; a quick two-finger tap is a right click.
|
|
72
74
|
*/
|
|
@@ -81,6 +83,23 @@ class DesktopView(context: Context, appContext: AppContext) : ExpoView(context,
|
|
|
81
83
|
private var surfaceWidth = 0
|
|
82
84
|
private var surfaceHeight = 0
|
|
83
85
|
|
|
86
|
+
/** Which touch meaning this surface uses; a browser page and a device screen answer one finger differently. */
|
|
87
|
+
var gestures = "desktop"
|
|
88
|
+
private set
|
|
89
|
+
|
|
90
|
+
fun setGestures(value: String) {
|
|
91
|
+
if (value == gestures) return
|
|
92
|
+
// A held drag belongs to the old meaning: let go rather than release the
|
|
93
|
+
// button somewhere the finger never meant.
|
|
94
|
+
if (gesture == Gesture.DRAG) session?.sendCancel()
|
|
95
|
+
cancelGesture()
|
|
96
|
+
gestures = value
|
|
97
|
+
if (value == "device" && pointerAt != null) {
|
|
98
|
+
pointerAt = null
|
|
99
|
+
placePointer()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
84
103
|
// The picture's placement: surface pixels per desktop pixel, and where the
|
|
85
104
|
// desktop's top-left corner sits in this view. `fitted` keeps a picture that
|
|
86
105
|
// was showing the whole desktop showing it when the view changes size.
|
|
@@ -449,7 +468,8 @@ class DesktopView(context: Context, appContext: AppContext) : ExpoView(context,
|
|
|
449
468
|
/** Send the desktop's pointer somewhere, and show it there. */
|
|
450
469
|
private fun pointerTo(active: DesktopSession, phase: String, at: Pair<Int, Int>, withButton: Boolean = false) {
|
|
451
470
|
active.sendPointer(phase, at.first, at.second, withButton)
|
|
452
|
-
|
|
471
|
+
// A device screen shows the touch itself; a mark would be a second finger that lies.
|
|
472
|
+
if (!mouseInput && gestures != "device") showPointer(at)
|
|
453
473
|
}
|
|
454
474
|
|
|
455
475
|
private fun showPointer(at: Pair<Int, Int>) {
|
|
@@ -519,6 +539,25 @@ class DesktopView(context: Context, appContext: AppContext) : ExpoView(context,
|
|
|
519
539
|
}
|
|
520
540
|
when (event.actionMasked) {
|
|
521
541
|
MotionEvent.ACTION_DOWN -> {
|
|
542
|
+
// A device screen is pressed, not pointed at: the finger lands with the
|
|
543
|
+
// button down, drags with it held, and lifts to release. A tap is the
|
|
544
|
+
// same down and up with no move, so it clicks for free, and a hold
|
|
545
|
+
// holds for free — there is no long press to arm.
|
|
546
|
+
if (gestures == "device") {
|
|
547
|
+
downX = event.x
|
|
548
|
+
downY = event.y
|
|
549
|
+
lastX = event.x
|
|
550
|
+
lastY = event.y
|
|
551
|
+
val at = point(event.x, event.y)
|
|
552
|
+
if (at == null) {
|
|
553
|
+
gesture = Gesture.LETTERBOX
|
|
554
|
+
} else {
|
|
555
|
+
gesture = Gesture.DRAG
|
|
556
|
+
lastTapPoint = null
|
|
557
|
+
pointerTo(active, "down", at)
|
|
558
|
+
}
|
|
559
|
+
return true
|
|
560
|
+
}
|
|
522
561
|
downOnPicture = point(event.x, event.y) != null
|
|
523
562
|
gesture = if (fitted && !downOnPicture) Gesture.LETTERBOX else Gesture.PENDING
|
|
524
563
|
downX = event.x
|
|
@@ -573,6 +612,15 @@ class DesktopView(context: Context, appContext: AppContext) : ExpoView(context,
|
|
|
573
612
|
Gesture.PENDING -> if (hypot(event.x - downX, event.y - downY) > touchSlop) {
|
|
574
613
|
removeCallbacks(longPress)
|
|
575
614
|
lastTapPoint = null
|
|
615
|
+
// A page scrolls under one finger; the desktop's pointer rides along
|
|
616
|
+
// so the scroll lands where the finger is.
|
|
617
|
+
if (gestures == "browser") {
|
|
618
|
+
gesture = Gesture.SCROLL
|
|
619
|
+
lastX = event.x
|
|
620
|
+
lastY = event.y
|
|
621
|
+
point(event.x, event.y)?.let { pointerTo(active, "move", it) }
|
|
622
|
+
return true
|
|
623
|
+
}
|
|
576
624
|
// The whole desktop has nowhere to move to, so the finger moves the
|
|
577
625
|
// desktop's pointer instead, without a button.
|
|
578
626
|
gesture = if (!fitted) Gesture.PAN else if (downOnPicture) Gesture.HOVER else Gesture.LETTERBOX
|
|
@@ -599,6 +647,15 @@ class DesktopView(context: Context, appContext: AppContext) : ExpoView(context,
|
|
|
599
647
|
Gesture.DRAG -> dragTo(active, event.x, event.y)
|
|
600
648
|
|
|
601
649
|
Gesture.TWO, Gesture.PINCH, Gesture.SCROLL -> if (event.pointerCount >= 2) twoFingers(active, event)
|
|
650
|
+
else if (gesture == Gesture.SCROLL && gestures == "browser") {
|
|
651
|
+
// One finger dragging a page: content follows it, in pixel deltas
|
|
652
|
+
// turned to detents like the two-finger scroll.
|
|
653
|
+
wheelX -= (event.x - lastX) / scale / PIXELS_PER_DETENT
|
|
654
|
+
wheelY -= (event.y - lastY) / scale / PIXELS_PER_DETENT
|
|
655
|
+
lastX = event.x
|
|
656
|
+
lastY = event.y
|
|
657
|
+
flushWheel(active, force = false)
|
|
658
|
+
}
|
|
602
659
|
|
|
603
660
|
else -> {}
|
|
604
661
|
}
|
|
@@ -618,6 +675,7 @@ class DesktopView(context: Context, appContext: AppContext) : ExpoView(context,
|
|
|
618
675
|
removeCallbacks(longPress)
|
|
619
676
|
when (gesture) {
|
|
620
677
|
Gesture.PENDING -> tap(active, event.x, event.y)
|
|
678
|
+
Gesture.SCROLL -> flushWheel(active, force = true)
|
|
621
679
|
Gesture.HOVER -> hoverTo(active, event.x, event.y)
|
|
622
680
|
Gesture.ARMED -> point(downX, downY)?.let { rightClick(active, it) }
|
|
623
681
|
Gesture.DRAG -> endDrag(active, event.x, event.y)
|
|
@@ -669,7 +727,7 @@ class DesktopView(context: Context, appContext: AppContext) : ExpoView(context,
|
|
|
669
727
|
|
|
670
728
|
private fun rightClick(active: DesktopSession, at: Pair<Int, Int>) {
|
|
671
729
|
active.sendRightClick(at.first, at.second)
|
|
672
|
-
if (!mouseInput) showPointer(at)
|
|
730
|
+
if (!mouseInput && gestures != "device") showPointer(at)
|
|
673
731
|
}
|
|
674
732
|
|
|
675
733
|
private fun twoFingers(active: DesktopSession, event: MotionEvent) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@desklink/react-native",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|
|
@@ -29,5 +29,8 @@
|
|
|
29
29
|
"react": ">=19",
|
|
30
30
|
"react-native": ">=0.83",
|
|
31
31
|
"react-native-webrtc": ">=124"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test:reconnect": "node test/reconnect-flow.mjs"
|
|
32
35
|
}
|
|
33
36
|
}
|
|
@@ -0,0 +1,227 @@
|
|
|
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, gestures = 'desktop' }: 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 (gestures !== 'device') return;
|
|
41
|
+
setCursor(null);
|
|
42
|
+
const held = gesture.current;
|
|
43
|
+
if (held && (held.mode === 'pending' || held.mode === 'armed')) {
|
|
44
|
+
stopTimer();
|
|
45
|
+
held.mode = 'spent';
|
|
46
|
+
}
|
|
47
|
+
}, [gestures]);
|
|
48
|
+
React.useEffect(() => {
|
|
49
|
+
if (!sessionId) return;
|
|
50
|
+
return registerDesktopView(sessionId, {
|
|
51
|
+
show: () => { if (desktopInputEnabled(sessionId)) input.current?.focus(); },
|
|
52
|
+
hide: () => { input.current?.blur(); Keyboard.dismiss(); },
|
|
53
|
+
capture: (value) => { captured.current = value; },
|
|
54
|
+
}, {
|
|
55
|
+
size: (width, height) => setSize({ width, height }),
|
|
56
|
+
fit: () => { setZoom(1); setOffset({ x: 0, y: 0 }); },
|
|
57
|
+
});
|
|
58
|
+
}, [sessionId]);
|
|
59
|
+
|
|
60
|
+
const visibleHeight = Math.max(1, bounds.height - (keyboardHeight ? keyboardHeight + keyboardClearance : 0));
|
|
61
|
+
const fit = size.width && size.height ? Math.min(bounds.width / size.width, visibleHeight / size.height) : 1;
|
|
62
|
+
const scale = fit * zoom;
|
|
63
|
+
const pictureWidth = size.width * scale;
|
|
64
|
+
const pictureHeight = size.height * scale;
|
|
65
|
+
const originX = (bounds.width - pictureWidth) / 2 + offset.x;
|
|
66
|
+
const originY = (visibleHeight - pictureHeight) / 2 + offset.y;
|
|
67
|
+
const point = (x: number, y: number, clamp = false): Point | null => {
|
|
68
|
+
if (!size.width || !size.height || !scale) return null;
|
|
69
|
+
const px = Math.floor((x - originX) / scale);
|
|
70
|
+
const py = Math.floor((y - originY) / scale);
|
|
71
|
+
if (!clamp && (px < 0 || py < 0 || px >= size.width || py >= size.height)) return null;
|
|
72
|
+
return { x: Math.max(0, Math.min(size.width - 1, px)), y: Math.max(0, Math.min(size.height - 1, py)) };
|
|
73
|
+
};
|
|
74
|
+
const send = (control: Record<string, unknown>) => { if (sessionId) nativeDesklink.sendControl(sessionId, JSON.stringify(control)); };
|
|
75
|
+
const pointer = (phase: string, at: Point, button?: number) => {
|
|
76
|
+
send({ kind: 'pointer', phase, x: at.x, y: at.y, ...(button ? { button } : {}) });
|
|
77
|
+
// A device screen shows the touch itself; a cursor would be a second finger that lies.
|
|
78
|
+
if (gestures !== 'device') setCursor(at);
|
|
79
|
+
};
|
|
80
|
+
const click = (at: Point, button = 1) => { pointer('down', at, button); pointer('up', at, button); };
|
|
81
|
+
const stopTimer = () => { if (longPress.current) clearTimeout(longPress.current); longPress.current = null; };
|
|
82
|
+
const focus = (touches: readonly { locationX: number; locationY: number }[]): Point => ({
|
|
83
|
+
x: (touches[0].locationX + touches[1].locationX) / 2,
|
|
84
|
+
y: (touches[0].locationY + touches[1].locationY) / 2,
|
|
85
|
+
});
|
|
86
|
+
const span = (touches: readonly { locationX: number; locationY: number }[]) => Math.hypot(touches[0].locationX - touches[1].locationX, touches[0].locationY - touches[1].locationY);
|
|
87
|
+
const start = (event: GestureResponderEvent) => {
|
|
88
|
+
if (!enabled) return;
|
|
89
|
+
const touches = event.nativeEvent.touches;
|
|
90
|
+
if (touches.length !== 1) return;
|
|
91
|
+
const at = { x: touches[0].locationX, y: touches[0].locationY };
|
|
92
|
+
// A device screen is pressed, not pointed at: the finger lands with the
|
|
93
|
+
// button down. A tap is the same press lifted without moving, and a
|
|
94
|
+
// hold holds — there is no long press to arm.
|
|
95
|
+
if (gestures === 'device') {
|
|
96
|
+
const from = point(at.x, at.y);
|
|
97
|
+
gesture.current = { start: at, last: at, time: Date.now(), mode: from ? 'drag' : 'spent', span: 0, focus: at };
|
|
98
|
+
if (from) pointer('down', from, 1);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
gesture.current = { start: at, last: at, time: Date.now(), mode: 'pending', span: 0, focus: at };
|
|
102
|
+
stopTimer();
|
|
103
|
+
longPress.current = setTimeout(() => {
|
|
104
|
+
if (gesture.current?.mode === 'pending' && point(at.x, at.y)) gesture.current.mode = 'armed';
|
|
105
|
+
}, 500);
|
|
106
|
+
};
|
|
107
|
+
const move = (event: GestureResponderEvent) => {
|
|
108
|
+
if (!enabled) return;
|
|
109
|
+
const g = gesture.current;
|
|
110
|
+
if (!g) return;
|
|
111
|
+
const touches = event.nativeEvent.touches;
|
|
112
|
+
if (touches.length === 2) {
|
|
113
|
+
stopTimer();
|
|
114
|
+
const center = focus(touches);
|
|
115
|
+
const distance = span(touches);
|
|
116
|
+
if (g.mode !== 'two' && g.mode !== 'pinch' && g.mode !== 'scroll') {
|
|
117
|
+
if (g.mode === 'drag') send({ kind: 'release_all' });
|
|
118
|
+
g.mode = 'two'; g.span = distance; g.focus = center; g.start = center;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (g.mode === 'two') {
|
|
122
|
+
if (Math.abs(distance - g.span) > 12) g.mode = 'pinch';
|
|
123
|
+
else if (Math.hypot(center.x - g.start.x, center.y - g.start.y) > 8) g.mode = 'scroll';
|
|
124
|
+
}
|
|
125
|
+
if (g.mode === 'pinch' && g.span) {
|
|
126
|
+
setZoom((value) => Math.max(1, Math.min(2.5 / fit, value * distance / g.span)));
|
|
127
|
+
} else if (g.mode === 'scroll') {
|
|
128
|
+
send({ kind: 'wheel', dx: -(center.x - g.focus.x) / scale / 120, dy: -(center.y - g.focus.y) / scale / 120 });
|
|
129
|
+
}
|
|
130
|
+
g.span = distance; g.focus = center; g.last = center;
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (touches.length !== 1 || g.mode === 'spent' || g.mode === 'two' || g.mode === 'pinch' || (g.mode === 'scroll' && gestures !== 'browser')) return;
|
|
134
|
+
const at = { x: touches[0].locationX, y: touches[0].locationY };
|
|
135
|
+
if (g.mode === 'pending' && Math.hypot(at.x - g.start.x, at.y - g.start.y) > 8) {
|
|
136
|
+
stopTimer();
|
|
137
|
+
// A page scrolls under one finger; the desktop's pointer rides along
|
|
138
|
+
// so the scroll lands where the finger is.
|
|
139
|
+
if (gestures === 'browser') {
|
|
140
|
+
g.mode = 'scroll';
|
|
141
|
+
const under = point(at.x, at.y);
|
|
142
|
+
if (under) pointer('move', under);
|
|
143
|
+
} else g.mode = zoom > 1 ? 'pan' : 'hover';
|
|
144
|
+
}
|
|
145
|
+
if (g.mode === 'scroll') {
|
|
146
|
+
// Content follows the finger: moving it up scrolls the page down.
|
|
147
|
+
send({ kind: 'wheel', dx: -(at.x - g.last.x) / scale / 120, dy: -(at.y - g.last.y) / scale / 120 });
|
|
148
|
+
g.last = at; g.focus = at;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (g.mode === 'pan') {
|
|
152
|
+
const dx = at.x - g.last.x;
|
|
153
|
+
const dy = at.y - g.last.y;
|
|
154
|
+
setOffset((current) => ({
|
|
155
|
+
x: Math.max(-Math.max(0, (pictureWidth - bounds.width) / 2), Math.min(Math.max(0, (pictureWidth - bounds.width) / 2), current.x + dx)),
|
|
156
|
+
y: Math.max(-Math.max(0, (pictureHeight - visibleHeight) / 2), Math.min(Math.max(0, (pictureHeight - visibleHeight) / 2), current.y + dy)),
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
159
|
+
if (g.mode === 'armed' && Math.hypot(at.x - g.start.x, at.y - g.start.y) > 8) {
|
|
160
|
+
const from = point(g.start.x, g.start.y, true);
|
|
161
|
+
if (from) pointer('down', from, 1);
|
|
162
|
+
g.mode = 'drag';
|
|
163
|
+
}
|
|
164
|
+
if (g.mode === 'hover' || g.mode === 'drag') {
|
|
165
|
+
const to = point(at.x, at.y, true);
|
|
166
|
+
if (to) pointer('move', to, g.mode === 'drag' ? 1 : undefined);
|
|
167
|
+
}
|
|
168
|
+
g.last = at;
|
|
169
|
+
};
|
|
170
|
+
const end = (event: GestureResponderEvent) => {
|
|
171
|
+
stopTimer();
|
|
172
|
+
const g = gesture.current;
|
|
173
|
+
if (!g) return;
|
|
174
|
+
if (g.mode === 'two' && Date.now() - g.time < 350) {
|
|
175
|
+
const at = point(g.focus.x, g.focus.y);
|
|
176
|
+
if (at) click(at, 3);
|
|
177
|
+
g.mode = 'spent';
|
|
178
|
+
}
|
|
179
|
+
// A remaining finger after a two-finger gesture must not begin a click.
|
|
180
|
+
if (event.nativeEvent.touches.length > 0) { g.mode = 'spent'; return; }
|
|
181
|
+
gesture.current = null;
|
|
182
|
+
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' }); }
|
|
183
|
+
else if (g.mode === 'armed') { const at = point(g.start.x, g.start.y); if (at) click(at, 3); }
|
|
184
|
+
else if (g.mode === 'pending') {
|
|
185
|
+
const at = point(g.start.x, g.start.y);
|
|
186
|
+
if (at) {
|
|
187
|
+
const prev = lastTap.current;
|
|
188
|
+
const repeated = prev && Date.now() - prev.time < 350 && Math.hypot(prev.at.x - g.start.x, prev.at.y - g.start.y) < 40;
|
|
189
|
+
click(repeated ? prev.point : at);
|
|
190
|
+
lastTap.current = { time: Date.now(), at: g.start, point: repeated ? prev.point : at };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
const changeText = (text: string) => {
|
|
195
|
+
// Retain only a short suffix so iOS can generate deletion events while
|
|
196
|
+
// autocorrect and multi-stage input still arrive as committed text.
|
|
197
|
+
const { removed, added } = keyboardDelta(keyboardTextRef.current, text);
|
|
198
|
+
keyboardTextRef.current = text.length > 64 ? text.slice(-32) : text;
|
|
199
|
+
for (let i = 0; i < removed; i++) {
|
|
200
|
+
if (captured.current && sessionId) emitDesktopKeyboard(sessionId, { key: 'Backspace' });
|
|
201
|
+
else { send({ kind: 'key', name: 'Backspace', modifiers: [], down: true }); send({ kind: 'key', name: 'Backspace', modifiers: [], down: false }); }
|
|
202
|
+
}
|
|
203
|
+
if (added) {
|
|
204
|
+
if (captured.current && sessionId) emitDesktopKeyboard(sessionId, { text: added });
|
|
205
|
+
else send({ kind: 'text', text: added });
|
|
206
|
+
}
|
|
207
|
+
setKeyboardText(keyboardTextRef.current);
|
|
208
|
+
};
|
|
209
|
+
const onLayout = (event: LayoutChangeEvent) => setBounds(event.nativeEvent.layout);
|
|
210
|
+
return <View style={[styles.surface, style]} onLayout={onLayout} accessibilityLabel={accessibilityLabel}
|
|
211
|
+
onStartShouldSetResponder={() => enabled} onMoveShouldSetResponder={() => enabled}
|
|
212
|
+
onResponderGrant={start} onResponderMove={move} onResponderRelease={end}
|
|
213
|
+
onResponderTerminate={() => { stopTimer(); if (gesture.current?.mode === 'drag') send({ kind: 'release_all' }); gesture.current = null; }}>
|
|
214
|
+
{sessionId && stream && size.width > 0 && size.height > 0 ?
|
|
215
|
+
<RTCView pointerEvents="none" streamURL={stream.toURL()} objectFit="contain" style={{ position: 'absolute', left: originX, top: originY, width: pictureWidth, height: pictureHeight }}
|
|
216
|
+
onDimensionsChange={(event) => { if (event.nativeEvent.width > 0 && event.nativeEvent.height > 0) markDesktopPresented(sessionId); }} /> : placeholder}
|
|
217
|
+
{cursor && <View pointerEvents="none" style={[styles.cursor, { left: originX + (cursor.x + 0.5) * scale, top: originY + (cursor.y + 0.5) * scale }]} />}
|
|
218
|
+
<TextInput ref={input} style={styles.input} value={keyboardText} onChangeText={changeText} autoCorrect={false} autoCapitalize="none" submitBehavior="submit"
|
|
219
|
+
onSubmitEditing={() => { send({ kind: 'key', name: 'Enter', modifiers: [], down: true }); send({ kind: 'key', name: 'Enter', modifiers: [], down: false }); }} />
|
|
220
|
+
</View>;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const styles = StyleSheet.create({
|
|
224
|
+
surface: { backgroundColor: '#000', overflow: 'hidden' },
|
|
225
|
+
cursor: { position: 'absolute', width: 12, height: 18, backgroundColor: '#fff', borderWidth: 2, borderColor: '#000', transform: [{ rotate: '-30deg' }] },
|
|
226
|
+
input: { position: 'absolute', width: 1, height: 1, opacity: 0, left: 0, bottom: 0 },
|
|
227
|
+
});
|
package/src/DesktopView.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import * as React from 'react';
|
|
|
3
3
|
import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
|
|
4
4
|
|
|
5
5
|
import { desktopAvailable } from './native';
|
|
6
|
+
import type { GestureProfile } from './protocol';
|
|
6
7
|
|
|
7
8
|
export interface DesktopViewProps {
|
|
8
9
|
/** The session handle from `useDesktopSession`. */
|
|
@@ -18,6 +19,11 @@ export interface DesktopViewProps {
|
|
|
18
19
|
* following the keyboard as it moves.
|
|
19
20
|
*/
|
|
20
21
|
keyboardClearance?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Which touch meaning the surface uses: a desktop pointer, a browser page
|
|
24
|
+
* (one finger scrolls), or a device screen (one finger presses and drags).
|
|
25
|
+
*/
|
|
26
|
+
gestures?: GestureProfile;
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
interface NativeSurfaceProps {
|
|
@@ -26,6 +32,7 @@ interface NativeSurfaceProps {
|
|
|
26
32
|
accessible?: boolean;
|
|
27
33
|
accessibilityLabel?: string;
|
|
28
34
|
keyboardClearance?: number;
|
|
35
|
+
gestures?: GestureProfile;
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
// The Expo view is resolved at module load; on a platform without it the
|
|
@@ -42,7 +49,7 @@ const NativeSurface: React.ComponentType<NativeSurfaceProps> | null = desktopAva
|
|
|
42
49
|
* belong to the application, which mounts this wherever it wants the desktop
|
|
43
50
|
* to appear.
|
|
44
51
|
*/
|
|
45
|
-
export function DesktopView({ sessionId, style, placeholder, accessibilityLabel, keyboardClearance = 0 }: DesktopViewProps) {
|
|
52
|
+
export function DesktopView({ sessionId, style, placeholder, accessibilityLabel, keyboardClearance = 0, gestures = 'desktop' }: DesktopViewProps) {
|
|
46
53
|
if (NativeSurface == null || sessionId == null) {
|
|
47
54
|
return (
|
|
48
55
|
<View style={[styles.surface, style]}>
|
|
@@ -50,7 +57,7 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
|
|
|
50
57
|
</View>
|
|
51
58
|
);
|
|
52
59
|
}
|
|
53
|
-
return <NativeSurface style={[styles.surface, style]} sessionId={sessionId} accessible={accessibilityLabel !== undefined} accessibilityLabel={accessibilityLabel} keyboardClearance={keyboardClearance} />;
|
|
60
|
+
return <NativeSurface style={[styles.surface, style]} sessionId={sessionId} accessible={accessibilityLabel !== undefined} accessibilityLabel={accessibilityLabel} keyboardClearance={keyboardClearance} gestures={gestures} />;
|
|
54
61
|
}
|
|
55
62
|
|
|
56
63
|
const styles = StyleSheet.create({
|
package/src/DesktopView.web.tsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
3
|
|
|
4
|
+
import type { GestureProfile } from './protocol';
|
|
5
|
+
|
|
4
6
|
export interface DesktopViewProps {
|
|
5
7
|
/** The session handle from `useDesktopSession`. */
|
|
6
8
|
sessionId: string | null;
|
|
@@ -14,6 +16,11 @@ export interface DesktopViewProps {
|
|
|
14
16
|
* controls, in points. While the keyboard is up the picture sits above both.
|
|
15
17
|
*/
|
|
16
18
|
keyboardClearance?: number;
|
|
19
|
+
/**
|
|
20
|
+
* Which touch meaning the surface uses: a desktop pointer, a browser page
|
|
21
|
+
* (one finger scrolls), or a device screen (one finger presses and drags).
|
|
22
|
+
*/
|
|
23
|
+
gestures?: GestureProfile;
|
|
17
24
|
}
|
|
18
25
|
|
|
19
26
|
/**
|
|
@@ -25,19 +32,24 @@ export interface DesktopViewProps {
|
|
|
25
32
|
* actually mounted rather than at module load, so an application that never
|
|
26
33
|
* opens a desktop does not carry one in its first paint.
|
|
27
34
|
*/
|
|
28
|
-
export function DesktopView({ sessionId, style, placeholder, accessibilityLabel, keyboardClearance = 0 }: DesktopViewProps) {
|
|
35
|
+
export function DesktopView({ sessionId, style, placeholder, accessibilityLabel, keyboardClearance = 0, gestures = 'desktop' }: DesktopViewProps) {
|
|
29
36
|
const mounted = React.useCallback(
|
|
30
37
|
(node: unknown) => {
|
|
31
38
|
const element = (node as HTMLElement | null) ?? null;
|
|
32
39
|
if (sessionId === null) return;
|
|
33
40
|
void import('./native').then((platform) => {
|
|
34
41
|
if (element === null || !platform.desktopAvailable) return;
|
|
35
|
-
platform.attachSurface(sessionId, element);
|
|
42
|
+
platform.attachSurface(sessionId, element, gestures);
|
|
36
43
|
});
|
|
37
44
|
},
|
|
38
45
|
[sessionId],
|
|
39
46
|
);
|
|
40
47
|
|
|
48
|
+
React.useEffect(() => {
|
|
49
|
+
if (sessionId === null) return;
|
|
50
|
+
void import('./native').then((platform) => platform.setGestures(sessionId, gestures));
|
|
51
|
+
}, [sessionId, gestures]);
|
|
52
|
+
|
|
41
53
|
React.useEffect(() => {
|
|
42
54
|
if (sessionId === null) return;
|
|
43
55
|
void import('./native').then((platform) => platform.setKeyboardClearance(sessionId, keyboardClearance));
|
|
@@ -0,0 +1,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
|
+
}
|