@desklink/react-native 0.5.0 → 0.5.2
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 +84 -15
- package/android/build.gradle +13 -1
- package/android/src/main/java/expo/modules/desklink/DesktopView.kt +4 -2
- package/package.json +1 -1
- package/src/ClipboardConfirmation.tsx +57 -0
- package/src/DesktopView.ios.spec.tsx +17 -0
- package/src/DesktopView.ios.tsx +58 -7
- package/src/DesktopView.tsx +7 -0
- package/src/ModifierKeys.tsx +77 -0
- package/src/controls.spec.tsx +205 -0
- package/src/index.ts +3 -0
- package/src/protocol.ts +9 -1
- package/src/useDesktopSession.ts +210 -20
package/README.md
CHANGED
|
@@ -30,6 +30,9 @@ repository it resolves through the workspace; anywhere else, install it from npm
|
|
|
30
30
|
npx expo install @desklink/react-native # or yarn add, then prebuild/rebuild
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
+
Expo SDK 55 and later are supported, including the SDK a fresh
|
|
34
|
+
`npx create-expo-app` resolves today: no SDK pin is needed.
|
|
35
|
+
|
|
33
36
|
Android uses an Expo native module; iOS uses the app's existing
|
|
34
37
|
`react-native-webrtc` peer connection and `RTCView` from JavaScript, plus a small
|
|
35
38
|
Expo module of its own for the hardware keyboard and an iPad pointer. Both need a
|
|
@@ -59,6 +62,7 @@ changes, and trackpad presses reach the desktop as touches.
|
|
|
59
62
|
|
|
60
63
|
```tsx
|
|
61
64
|
import { DesktopView, useDesktopSession } from '@desklink/react-native';
|
|
65
|
+
import * as Clipboard from 'expo-clipboard';
|
|
62
66
|
|
|
63
67
|
const desktop = useDesktopSession({
|
|
64
68
|
authorize: async () => ({
|
|
@@ -75,6 +79,9 @@ await desktop.connect();
|
|
|
75
79
|
desktop.setInputEnabled(true);
|
|
76
80
|
desktop.showKeyboard();
|
|
77
81
|
desktop.setOrientation('landscape'); // Android only; iOS apps configure supported orientations themselves
|
|
82
|
+
await desktop.copyRemoteToLocal(async (text) => {
|
|
83
|
+
if (!await Clipboard.setStringAsync(text)) throw new Error('Clipboard write refused');
|
|
84
|
+
});
|
|
78
85
|
await desktop.pasteLocalToRemote(await Clipboard.getStringAsync());
|
|
79
86
|
// Optional receiver evidence: codec, decoder and video counters; no ICE addresses.
|
|
80
87
|
const stats = await desktop.getStats();
|
|
@@ -120,6 +127,24 @@ the iOS receiver asks the engine for H.264, which iOS decodes in hardware
|
|
|
120
127
|
iOS decodes in software — so a 1440p or 4K picture on such a host costs CPU,
|
|
121
128
|
heat and battery.
|
|
122
129
|
|
|
130
|
+
### Paths with no UDP route
|
|
131
|
+
|
|
132
|
+
`session.loopbackTcp` asks the engine to also offer ICE over TCP on its own
|
|
133
|
+
`127.0.0.1`, for a client whose only way to the computer is a forward of that
|
|
134
|
+
loopback — an SSH tunnel, `adb reverse`:
|
|
135
|
+
|
|
136
|
+
```tsx
|
|
137
|
+
session: { loopbackTcp: true },
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
It is off by default, like the wire field it sets. A client with a UDP path
|
|
141
|
+
keeps it: ICE prefers UDP whenever it has one. The engine picks that port per
|
|
142
|
+
session, so the forward has to follow it (`adb reverse tcp:<port> tcp:<port>`,
|
|
143
|
+
`ssh -L <port>:127.0.0.1:<port> <computer>`) with `<port>` from the
|
|
144
|
+
`session.candidate` the engine offers. Without it a connection that never
|
|
145
|
+
forms fails after 20 seconds with that reason, rather than sitting on an empty
|
|
146
|
+
view.
|
|
147
|
+
|
|
123
148
|
### What the package guarantees
|
|
124
149
|
|
|
125
150
|
- **Hardware decoding where the platform has it.** Android and web decode the
|
|
@@ -135,7 +160,9 @@ heat and battery.
|
|
|
135
160
|
content; pinch to zoom before panning when the whole desktop already fits.
|
|
136
161
|
Pass the screen's safe-area `insets` (for example from
|
|
137
162
|
`react-native-safe-area-context`) so the default starts below the status bar
|
|
138
|
-
and beside a camera cutout.
|
|
163
|
+
and beside a camera cutout. `onPictureFrame` reports where the picture
|
|
164
|
+
ended whenever it moves, so a tall screen's band below it can carry the
|
|
165
|
+
application's own controls instead of empty space. Panning brings each desktop edge inside the safe
|
|
139
166
|
area, while landscape coverage extends beneath the cutout and home indicator
|
|
140
167
|
to limit black bars. A pinch zooms up to 2.5 view
|
|
141
168
|
pixels per desktop pixel, or out to the whole desktop. On Android the decoded frame is copied once into the view's
|
|
@@ -155,8 +182,10 @@ heat and battery.
|
|
|
155
182
|
touched shows the finger itself.
|
|
156
183
|
- **The picture above the keyboard.** While the phone's keyboard is up, the
|
|
157
184
|
picture sits above it and above the room the app keeps for its own controls
|
|
158
|
-
(`keyboardClearance`).
|
|
159
|
-
|
|
185
|
+
(`keyboardClearance`). Only the part of the view the keyboard really covers
|
|
186
|
+
counts, and a picture taller than what is left moves so the pointer, where
|
|
187
|
+
a tap just put the caret, stays in sight. Android also follows the keyboard
|
|
188
|
+
animation frame by frame; iOS moves once, as the keyboard shows or hides.
|
|
160
189
|
- **Contained geometry.** Touch maps through the picture's actual placement.
|
|
161
190
|
On the whole desktop, a one-finger drag starting in the letterbox does not move the pointer; a
|
|
162
191
|
second finger on the picture can still start a pinch or scroll. A drag that
|
|
@@ -191,7 +220,15 @@ heat and battery.
|
|
|
191
220
|
- **The keys a phone lacks.** `modifiers`, `tapModifier` and `pressKey` give
|
|
192
221
|
sticky Ctrl and Shift: tap arms one for the next key, tap again locks it.
|
|
193
222
|
While one is armed, the next key or character the phone's keyboard types is
|
|
194
|
-
sent as that key's chord, so Ctrl then v is Ctrl+V.
|
|
223
|
+
sent as that key's chord, so Ctrl then v is Ctrl+V. `modifiers` holds each
|
|
224
|
+
key's state (`off`, `once` for latched, `lock`) for the app to read.
|
|
225
|
+
`<ModifierKeys modifiers={desktop.modifiers} onTap={desktop.tapModifier} />`
|
|
226
|
+
draws them as a backed key row: off is an outlined key, latched a filled
|
|
227
|
+
one, locked a filled one with a white rim and a bar under its label, and a
|
|
228
|
+
screen reader hears off, latched or locked. Its `grow` prop makes it a
|
|
229
|
+
tablet's row: the keys take the width and the height the row is given
|
|
230
|
+
instead of sitting at 44 pt. An app may draw its own keys
|
|
231
|
+
from `modifiers` instead.
|
|
195
232
|
- **Readiness is a rendered frame.** Android marks the first draw; iOS marks
|
|
196
233
|
the first `RTCView` video-dimensions callback and ignores later ones, so a
|
|
197
234
|
resize or remount during a reconnect does not read as live. Neither marks
|
|
@@ -201,25 +238,30 @@ heat and battery.
|
|
|
201
238
|
peer's ICE state and the engine's `session.state` both feed it: a
|
|
202
239
|
`disconnected` path reads as `reconnecting` within about a second of the
|
|
203
240
|
drop — never a frozen picture that still says live — and a recovered path
|
|
204
|
-
reads as `live` again once
|
|
241
|
+
reads as `live` again once the picture is advancing again (ICE `connected`
|
|
242
|
+
alone does not restore `live` while the picture is frozen). A path that reports
|
|
205
243
|
`failed` reopens the session with fresh authority instead of staying stuck.
|
|
206
244
|
Every event carries its engine session id where the carrier preserves it,
|
|
207
245
|
and the session ignores anything naming a session it no longer holds, so a
|
|
208
246
|
previous generation's queued offer or revocation cannot corrupt or kill a
|
|
209
247
|
healthy recovery.
|
|
210
|
-
Detection is
|
|
248
|
+
Detection is three-layered: ICE and engine `session.state` events, the
|
|
211
249
|
engine's twice-a-second control-channel heartbeat (`{"kind":"ping"}`) — a
|
|
212
250
|
path that stops heartbeating reads as stalled within ~1.5 s even while ICE
|
|
213
251
|
consent timers are still making up their minds, and resumed heartbeats read
|
|
214
|
-
as recovered
|
|
215
|
-
|
|
252
|
+
as recovered — and a half-second poll of the receiver's own video counters
|
|
253
|
+
while a picture is shown: a picture stopped for ~2 s reads as not `live`
|
|
254
|
+
even while signalling, ICE and control all answer. Clients on engines that
|
|
255
|
+
predate the heartbeat simply never arm that watchdog.
|
|
216
256
|
- **ICE restarts before reopens.** When the path drops after frames were
|
|
217
257
|
shown, the session asks the engine (`session.restart_ice`) to re-offer on
|
|
218
258
|
the same peer connection: the fresh offer arrives as the usual
|
|
219
259
|
`session.description` event and is answered as usual, all without reopening
|
|
220
260
|
the session. The first restart waits ~2 s for blips that heal alone;
|
|
221
261
|
retries back off across ~60 s, so a 30–60 s outage still recovers when the
|
|
222
|
-
network returns
|
|
262
|
+
network returns — except while the picture is frozen, when the short ~2 s
|
|
263
|
+
retry keeps being spent without consuming the reopen budget until frames
|
|
264
|
+
move again. When the restart schedule is spent without the peer
|
|
223
265
|
reporting `failed`, the session reopens instead of sitting in
|
|
224
266
|
`reconnecting`. An engine revocation with code `transport` (the path was
|
|
225
267
|
lost) also reopens the session; any other revocation code, or none, ends
|
|
@@ -240,6 +282,15 @@ heat and battery.
|
|
|
240
282
|
session close and a lost control channel release what the desktop was holding.
|
|
241
283
|
- **Explicit clipboard.** Two methods, called on a user action while control is
|
|
242
284
|
enabled. The package never polls the clipboard or uses it as a way to type.
|
|
285
|
+
`copyRemoteToLocal(writeLocal)` awaits the caller-owned local writer, which
|
|
286
|
+
must reject on failure or refusal, before recording completion. The returned
|
|
287
|
+
text and truncation flag describe what was written. `pasteLocalToRemote(text)`
|
|
288
|
+
records completion only after the desktop acknowledges the write.
|
|
289
|
+
`clipboard` is the last transfer that completed, and
|
|
290
|
+
`<ClipboardConfirmation transfer={desktop.clipboard} />` confirms each one
|
|
291
|
+
with a backed pill — where it went and the start of the text —
|
|
292
|
+
that ignores touches and leaves after 1.5 s. Place it in a reserved area
|
|
293
|
+
outside `DesktopView`, keeping that area present when the pill is dismissed.
|
|
243
294
|
|
|
244
295
|
### `Signaling`
|
|
245
296
|
|
|
@@ -308,28 +359,44 @@ bridge, and checks that the first frame is presented, that the answer puts
|
|
|
308
359
|
H.264 first with NACK when the host offers it (VP9 otherwise; both descriptions
|
|
309
360
|
are written out), that a tap clicks the host, that
|
|
310
361
|
the picture keeps following the desktop afterwards, and that hardware keys
|
|
311
|
-
pressed on the simulator reach the desktop.
|
|
362
|
+
pressed on the simulator reach the desktop. It then drives the whole
|
|
363
|
+
**phone-only control** journey from the phone's own screen, with no input sent
|
|
364
|
+
to the desktop from this machine: a click, a drag that holds the button and
|
|
365
|
+
selects, a two-finger scroll, typed text, a sticky modifier that chords the next
|
|
366
|
+
key and clears, the clipboard in both directions, and the drawn pointer mark's
|
|
367
|
+
shape and tip. Every step has its own capture, the journey is recorded as one
|
|
368
|
+
screen recording on the simulator, and the phone's own screen is read to prove it
|
|
369
|
+
carries no diagnostic text.
|
|
370
|
+
With `DESKLINK_IOS_IPAD=1` it runs on
|
|
312
371
|
an iPad simulator and also drives a trackpad through XCUITest
|
|
313
372
|
(`test/ios-pointer/`): hover, a click-drag that selects text, a right click and
|
|
314
373
|
a scroll:
|
|
315
374
|
|
|
316
375
|
```sh
|
|
317
376
|
npm run build && cargo build --manifest-path packages/desktop-host/engine/Cargo.toml
|
|
318
|
-
env -u DISPLAY -u WAYLAND_DISPLAY DESKLINK_IOS_MAC=user@mac
|
|
377
|
+
env -u DISPLAY -u WAYLAND_DISPLAY DESKLINK_IOS_MAC=user@mac \
|
|
378
|
+
DESKLINK_IOS_DEVICE='fm-iPhone 17 Pro' npm run test:ios --workspace @desklink/react-native
|
|
319
379
|
```
|
|
320
380
|
|
|
321
381
|
The Mac needs Xcode with an iOS simulator runtime, CocoaPods, node and
|
|
322
382
|
[`axe`](https://github.com/cameroncooke/AXe) on its login `PATH`; everything
|
|
323
383
|
the run builds or caches stays in `~/desklink-ios` there. The run creates a
|
|
324
|
-
uniquely named simulator and deletes it during cleanup.
|
|
384
|
+
uniquely named simulator and deletes it during cleanup. Set
|
|
385
|
+
`DESKLINK_IOS_DEVICE` to the name or udid of one of the Mac's own simulators to
|
|
386
|
+
run on that instead and leave it in place. For host desktop isolation, see the
|
|
325
387
|
[private-lab guidance](../../README.md#develop). When the selected
|
|
326
388
|
Xcode has no simulator platform, point `DESKLINK_IOS_XCODE` at one that does;
|
|
327
389
|
`DESKLINK_IOS_RUNTIME` picks the iOS version (for example `18.6`); the
|
|
328
390
|
script's header lists the other settings. To capture the cursor tip proof, set
|
|
329
391
|
`DESKLINK_IOS_POINTER_PROOF=before` or `after`; the run writes a 4x crop, device
|
|
330
392
|
frame, proof metadata, and host click log under `DESKLINK_IOS_OUT`. Set
|
|
331
|
-
`DESKLINK_IOS_APP` to reuse an app bundle under
|
|
332
|
-
with `DESKLINK_IOS_SKIP_BUILD=1`.
|
|
393
|
+
`DESKLINK_IOS_APP` to reuse an app bundle under the lane's directory when
|
|
394
|
+
running with `DESKLINK_IOS_SKIP_BUILD=1`. `DESKLINK_IOS_DIR` is the shared Mac
|
|
395
|
+
lab root (`fm-desklink-ios`) and `DESKLINK_IOS_LANE` names this lane's own
|
|
396
|
+
subdirectory within it; the run deletes that subdirectory and nothing else when
|
|
397
|
+
it finishes, so two lanes can build at once without one cleanup deleting the
|
|
398
|
+
other's build. Set `DESKLINK_IOS_KEEP=1` to keep it. Run once with `before`
|
|
399
|
+
before the change and
|
|
333
400
|
once with `after` after it, on both iPhone and iPad simulators, to compare the
|
|
334
401
|
pointer crop and verify the host click is within two desktop pixels of the tip.
|
|
335
402
|
To try the example by hand against any bridge:
|
|
@@ -344,7 +411,9 @@ link whose `url` query parameter is the URL-encoded bridge WebSocket address:
|
|
|
344
411
|
The example allows the bridge's plaintext private-network connection; consumer
|
|
345
412
|
apps choose their own transport policy. A new link replaces the open session.
|
|
346
413
|
The live picture has no diagnostic overlay. Its accessibility status remains
|
|
347
|
-
available as `desklink-status`.
|
|
414
|
+
available as `desklink-status`. The key row (`Ctrl`, `Shift`, `Alt`, `Meta`) and
|
|
415
|
+
the `Copy` and `Paste` buttons are the app's own chrome over the picture, and a
|
|
416
|
+
clipboard transfer is confirmed by a pill above them.
|
|
348
417
|
|
|
349
418
|
For device evidence, an optional `report` query parameter accepts a URL-encoded
|
|
350
419
|
HTTP endpoint. While live, the example posts receiver statistics and connection
|
package/android/build.gradle
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
apply plugin: 'com.android.library'
|
|
2
|
-
|
|
2
|
+
// expo-modules-core's own Kotlin API is reified through the `pika` compiler
|
|
3
|
+
// plugin, and this plugin is what applies it. Building with a bare
|
|
4
|
+
// `kotlin-android` leaves those reified calls un-inlined, and the app then
|
|
5
|
+
// throws "This function has a reified type parameter ..." out of
|
|
6
|
+
// DesklinkModule.definition() on startup. Expo's plugin covers every SDK, and
|
|
7
|
+
// applies kotlin-android and the default Expo module dependencies itself.
|
|
8
|
+
apply plugin: 'expo-module-gradle-plugin'
|
|
3
9
|
|
|
4
10
|
group = 'expo.modules.desklink'
|
|
5
11
|
version = '0.1.1'
|
|
6
12
|
|
|
13
|
+
// This package ships from npm, not from Expo's Maven repository, so the
|
|
14
|
+
// plugin's publishing task has nothing to publish.
|
|
15
|
+
expoModule {
|
|
16
|
+
canBePublished = false
|
|
17
|
+
}
|
|
18
|
+
|
|
7
19
|
android {
|
|
8
20
|
namespace 'expo.modules.desklink'
|
|
9
21
|
compileSdk rootProject.ext.has('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 36
|
|
@@ -681,8 +681,10 @@ class DesktopView(context: Context, appContext: AppContext) : ExpoView(context,
|
|
|
681
681
|
return true
|
|
682
682
|
}
|
|
683
683
|
// The whole desktop has nowhere to move to, so the finger moves the
|
|
684
|
-
// desktop's pointer instead, without a button.
|
|
685
|
-
|
|
684
|
+
// desktop's pointer instead, without a button. A fill that landed on
|
|
685
|
+
// the fit scale has nowhere to move to either, portrait most of all,
|
|
686
|
+
// and it is `fitted`, not the fill, that says there is room.
|
|
687
|
+
gesture = if (!fitted) Gesture.PAN else if (downOnPicture) Gesture.HOVER else Gesture.LETTERBOX
|
|
686
688
|
lastX = event.x
|
|
687
689
|
lastY = event.y
|
|
688
690
|
if (gesture == Gesture.HOVER) hoverTo(active, event.x, event.y)
|
package/package.json
CHANGED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import type { ClipboardTransfer } from './useDesktopSession';
|
|
5
|
+
|
|
6
|
+
export interface ClipboardConfirmationProps {
|
|
7
|
+
/** The session's `clipboard`. */
|
|
8
|
+
transfer: ClipboardTransfer | null;
|
|
9
|
+
style?: StyleProp<ViewStyle>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** How long a confirmation stays up. */
|
|
13
|
+
export const CLIPBOARD_CONFIRMATION_MS = 1500;
|
|
14
|
+
const PREVIEW_CHARS = 24;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A short pill confirming each clipboard transfer: where it went and the start
|
|
18
|
+
* of the text. Place it in reserved space outside the desktop
|
|
19
|
+
* surface. It ignores touches and goes away on its own.
|
|
20
|
+
*/
|
|
21
|
+
export function ClipboardConfirmation({ transfer, style }: ClipboardConfirmationProps) {
|
|
22
|
+
const [shown, setShown] = React.useState<ClipboardTransfer | null>(null);
|
|
23
|
+
const seenId = React.useRef<number | null>(transfer?.id ?? null);
|
|
24
|
+
React.useEffect(() => {
|
|
25
|
+
if (transfer == null) return;
|
|
26
|
+
if (transfer.id === seenId.current) return;
|
|
27
|
+
seenId.current = transfer.id;
|
|
28
|
+
setShown(transfer);
|
|
29
|
+
const timer = setTimeout(() => setShown(null), CLIPBOARD_CONFIRMATION_MS);
|
|
30
|
+
return () => clearTimeout(timer);
|
|
31
|
+
}, [transfer?.id]);
|
|
32
|
+
if (shown == null) return null;
|
|
33
|
+
return (
|
|
34
|
+
<View pointerEvents="none" style={[styles.place, style]}>
|
|
35
|
+
<View testID="desklink-clipboard" accessibilityLiveRegion="polite" style={styles.pill}>
|
|
36
|
+
<Text numberOfLines={1} style={styles.text}>{describeTransfer(shown)}</Text>
|
|
37
|
+
</View>
|
|
38
|
+
</View>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** "Copied to phone · “first words…”" */
|
|
43
|
+
export function describeTransfer({ direction, text }: ClipboardTransfer): string {
|
|
44
|
+
const where = direction === 'to-phone' ? 'Copied to phone' : 'Sent to desktop';
|
|
45
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
46
|
+
const chars = [...flat];
|
|
47
|
+
const preview = chars.length > PREVIEW_CHARS ? `${chars.slice(0, PREVIEW_CHARS).join('')}…` : flat;
|
|
48
|
+
return preview === '' ? `${where}` : `${where} · “${preview}”`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const PILL_COLORS = { backing: 'rgba(17, 18, 20, 0.94)', text: '#ffffff' };
|
|
52
|
+
|
|
53
|
+
const styles = StyleSheet.create({
|
|
54
|
+
place: { paddingHorizontal: 16, alignItems: 'center' },
|
|
55
|
+
pill: { maxWidth: '100%', paddingHorizontal: 14, paddingVertical: 8, borderRadius: 999, backgroundColor: PILL_COLORS.backing },
|
|
56
|
+
text: { color: PILL_COLORS.text, fontSize: 14, fontWeight: '600' },
|
|
57
|
+
});
|
|
@@ -266,6 +266,23 @@ describe('DesktopView.ios parity with DesktopView.kt', () => {
|
|
|
266
266
|
expect(sent.filter((message) => message.kind === 'pointer' && message.phase === 'move')).not.toEqual([]);
|
|
267
267
|
});
|
|
268
268
|
|
|
269
|
+
it('carries a one-finger drag from the moment the picture exists, in portrait', async () => {
|
|
270
|
+
// The state a session starts in: the picture at its default fill, before
|
|
271
|
+
// anything has been zoomed. In portrait that fill is the fit, so the
|
|
272
|
+
// picture has nowhere to pan to and the finger belongs to the desktop.
|
|
273
|
+
const { sent, rtcStyle, grant, move, release } = await openView({ whole: false });
|
|
274
|
+
const before = rtcStyle();
|
|
275
|
+
grant([touch(100, 100)]);
|
|
276
|
+
move([touch(160, 120)]);
|
|
277
|
+
move([touch(220, 140)]);
|
|
278
|
+
release();
|
|
279
|
+
const moves = sent.filter((message) => message.kind === 'pointer' && message.phase === 'move');
|
|
280
|
+
expect(moves).toHaveLength(2);
|
|
281
|
+
expect(moves[1].x).toBeGreaterThan(moves[0].x);
|
|
282
|
+
expect(moves[1].y).toBeGreaterThan(moves[0].y);
|
|
283
|
+
expect(rtcStyle()).toEqual(before);
|
|
284
|
+
});
|
|
285
|
+
|
|
269
286
|
it('zooms around the pinch focus and re-clamps after zooming out', async () => {
|
|
270
287
|
const { rtcStyle, grant, move } = await openView();
|
|
271
288
|
grant([touch(100, 400)]);
|
package/src/DesktopView.ios.tsx
CHANGED
|
@@ -67,11 +67,16 @@ function loadRTCView(): React.ComponentType<Record<string, unknown>> | null {
|
|
|
67
67
|
/** An offset that the edge clamp turns into the desktop's top-left corner. */
|
|
68
68
|
const TOP_LEFT: Point = { x: Infinity, y: Infinity };
|
|
69
69
|
|
|
70
|
-
export function DesktopView({ sessionId, style, placeholder, accessibilityLabel, keyboardClearance = 0, insets, gestures = 'desktop' }: DesktopViewProps) {
|
|
70
|
+
export function DesktopView({ sessionId, style, placeholder, accessibilityLabel, keyboardClearance = 0, insets, gestures = 'desktop', onPictureFrame }: DesktopViewProps) {
|
|
71
71
|
const [revision, refresh] = React.useReducer((n: number) => n + 1, 0);
|
|
72
72
|
const [bounds, setBounds] = React.useState({ width: 0, height: 0 });
|
|
73
73
|
const [size, setSize] = React.useState(() => getDesktopSize(sessionId) ?? { width: 0, height: 0 });
|
|
74
|
-
|
|
74
|
+
/** How much of the view's bottom the keyboard covers, measured against where the view is on screen. */
|
|
75
|
+
const [keyboardOverlap, setKeyboardOverlap] = React.useState(0);
|
|
76
|
+
/** The keyboard's top on screen while it is up. */
|
|
77
|
+
const keyboardTop = React.useRef<number | null>(null);
|
|
78
|
+
/** Where the desktop's pointer was last sent, in desktop pixels: where a tap just put the caret. */
|
|
79
|
+
const pointerAt = React.useRef<Point | null>(null);
|
|
75
80
|
/** Zoom over the whole-desktop fit; null fills the view, the default. */
|
|
76
81
|
const [zoom, setZoom] = React.useState<number | null>(null);
|
|
77
82
|
/** From the middle of the uncovered part of the view; clamped to the picture's edges when shown. */
|
|
@@ -100,9 +105,11 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
|
|
|
100
105
|
return observeDesktopSurface(() => { refresh(); setEnabled(desktopInputEnabled(sessionId)); });
|
|
101
106
|
}, [sessionId]);
|
|
102
107
|
React.useEffect(() => {
|
|
103
|
-
const show = Keyboard.addListener('keyboardWillShow', (event) =>
|
|
104
|
-
const hide = Keyboard.addListener('keyboardWillHide', () =>
|
|
108
|
+
const show = Keyboard.addListener('keyboardWillShow', (event) => { keyboardTop.current = event.endCoordinates.screenY; measureKeyboard(); });
|
|
109
|
+
const hide = Keyboard.addListener('keyboardWillHide', () => { keyboardTop.current = null; setKeyboardOverlap(0); });
|
|
105
110
|
return () => { show.remove(); hide.remove(); };
|
|
111
|
+
// `measureKeyboard` reads refs only.
|
|
112
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
106
113
|
}, []);
|
|
107
114
|
React.useEffect(() => {
|
|
108
115
|
// A held drag belongs to the old meaning: let go of the button rather
|
|
@@ -135,7 +142,7 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
|
|
|
135
142
|
const left = insets?.left ?? 0;
|
|
136
143
|
const safeWidth = Math.max(1, bounds.width - left - (insets?.right ?? 0));
|
|
137
144
|
const safeHeight = Math.max(1, bounds.height - top - (insets?.bottom ?? 0));
|
|
138
|
-
const visibleBottom = Math.min(top + safeHeight, bounds.height - (
|
|
145
|
+
const visibleBottom = Math.min(top + safeHeight, bounds.height - (keyboardOverlap ? keyboardOverlap + keyboardClearance : 0));
|
|
139
146
|
const visibleHeight = Math.max(1, visibleBottom - top);
|
|
140
147
|
const fit = size.width && size.height ? Math.min(safeWidth / size.width, visibleHeight / size.height) : 1;
|
|
141
148
|
const defaultScale = bounds.width <= bounds.height
|
|
@@ -169,6 +176,31 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
|
|
|
169
176
|
const shown = clampOffset(offset.x, offset.y);
|
|
170
177
|
const originX = left + (safeWidth - pictureWidth) / 2 + shown.x;
|
|
171
178
|
const originY = top + (zoom === null && pictureHeight <= visibleHeight ? 0 : (visibleHeight - pictureHeight) / 2) + shown.y;
|
|
179
|
+
// The keyboard took (or gave back) the bottom of the view. A picture taller
|
|
180
|
+
// than what is left is moved so the pointer, where a tap just put the
|
|
181
|
+
// caret, stays in sight, as `DesktopView.kt` does.
|
|
182
|
+
const shownBottom = React.useRef(visibleBottom);
|
|
183
|
+
React.useLayoutEffect(() => {
|
|
184
|
+
const before = shownBottom.current;
|
|
185
|
+
shownBottom.current = visibleBottom;
|
|
186
|
+
const at = pointerAt.current;
|
|
187
|
+
if (Math.abs(visibleBottom - before) < 0.5 || !at) return;
|
|
188
|
+
const y = originY + (at.y + 0.5) * scale;
|
|
189
|
+
const margin = Math.min(56, visibleBottom / 4);
|
|
190
|
+
if (y > visibleBottom - margin) setOffset(clampOffset(shown.x, shown.y - (y - (visibleBottom - margin))));
|
|
191
|
+
// Only a change in what the keyboard covers moves the picture.
|
|
192
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
193
|
+
}, [visibleBottom]);
|
|
194
|
+
// An application's controls can sit under the picture instead of over it,
|
|
195
|
+
// so it needs to know where the picture ended. Reported only when the
|
|
196
|
+
// picture moved: the callback sets state, and a fresh callback each render
|
|
197
|
+
// would otherwise loop.
|
|
198
|
+
const frame = React.useRef({ top: -1, height: -1 });
|
|
199
|
+
React.useEffect(() => {
|
|
200
|
+
if (frame.current.top === originY && frame.current.height === pictureHeight) return;
|
|
201
|
+
frame.current = { top: originY, height: pictureHeight };
|
|
202
|
+
onPictureFrame?.(frame.current);
|
|
203
|
+
}, [onPictureFrame, originY, pictureHeight]);
|
|
172
204
|
const send = (control: Record<string, unknown>) => { if (sessionId) nativeDesklink.sendControl(sessionId, JSON.stringify(control)); };
|
|
173
205
|
const flushWheel = (force: boolean) => {
|
|
174
206
|
const { x, y } = wheel.current;
|
|
@@ -179,6 +211,7 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
|
|
|
179
211
|
};
|
|
180
212
|
const pointer = (phase: string, at: Point, button?: number, mark = true) => {
|
|
181
213
|
send({ kind: 'pointer', phase, x: at.x, y: at.y, ...(button ? { button } : {}) });
|
|
214
|
+
pointerAt.current = at;
|
|
182
215
|
// A device screen shows the touch itself; a cursor would be a second finger that lies.
|
|
183
216
|
// A trackpad or mouse has the system's own pointer on screen.
|
|
184
217
|
if (!mark) setCursor(null);
|
|
@@ -287,7 +320,13 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
|
|
|
287
320
|
g.mode = 'scroll';
|
|
288
321
|
const under = point(at.x, at.y);
|
|
289
322
|
if (under) pointer('move', under);
|
|
290
|
-
} else
|
|
323
|
+
} else {
|
|
324
|
+
// A whole desktop has nowhere to move to, so the finger moves the
|
|
325
|
+
// desktop's pointer instead. It is the zoom over the fit, not the
|
|
326
|
+
// default fill, that says there is room to pan: a fill that landed
|
|
327
|
+
// on the fit has none.
|
|
328
|
+
g.mode = zoomed > 1.001 ? 'pan' : 'hover';
|
|
329
|
+
}
|
|
291
330
|
}
|
|
292
331
|
if (g.mode === 'scroll') {
|
|
293
332
|
// Content follows the finger: moving it up scrolls the page down.
|
|
@@ -410,7 +449,19 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
|
|
|
410
449
|
wheel.current.y += -dy / scale / PIXELS_PER_DETENT;
|
|
411
450
|
flushWheel(false);
|
|
412
451
|
};
|
|
413
|
-
|
|
452
|
+
/**
|
|
453
|
+
* The keyboard covers only the part of the view below its top: the view can
|
|
454
|
+
* end above the screen's bottom. The hidden input sits at the view's bottom
|
|
455
|
+
* edge, so it says where that edge is on screen.
|
|
456
|
+
*/
|
|
457
|
+
const measureKeyboard = () => {
|
|
458
|
+
const keyboardAt = keyboardTop.current;
|
|
459
|
+
if (keyboardAt === null) return;
|
|
460
|
+
input.current?.measureInWindow((_x, y, _width, height) => {
|
|
461
|
+
if (keyboardTop.current === keyboardAt) setKeyboardOverlap(Math.max(0, y + height - keyboardAt));
|
|
462
|
+
});
|
|
463
|
+
};
|
|
464
|
+
const onLayout = (event: LayoutChangeEvent) => { setBounds(event.nativeEvent.layout); measureKeyboard(); };
|
|
414
465
|
const surface: ViewProps = {
|
|
415
466
|
style: [styles.surface, style], onLayout, accessible: accessibilityLabel !== undefined, accessibilityLabel,
|
|
416
467
|
onStartShouldSetResponder: () => enabled, onMoveShouldSetResponder: () => enabled,
|
package/src/DesktopView.tsx
CHANGED
|
@@ -35,6 +35,13 @@ export interface DesktopViewProps {
|
|
|
35
35
|
* (one finger scrolls), or a device screen (one finger presses and drags).
|
|
36
36
|
*/
|
|
37
37
|
gestures?: GestureProfile;
|
|
38
|
+
/**
|
|
39
|
+
* Where the picture ended, in the view's own points, whenever it moves.
|
|
40
|
+
* A wide desktop on a tall screen leaves a band under the picture; this is
|
|
41
|
+
* how an application lays its own controls into that band instead of
|
|
42
|
+
* guessing the desktop's aspect.
|
|
43
|
+
*/
|
|
44
|
+
onPictureFrame?: (frame: { top: number; height: number }) => void;
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
interface NativeSurfaceProps {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import type { StickyModifier, StickyModifiers, StickyState } from './stickyModifiers';
|
|
5
|
+
|
|
6
|
+
export interface ModifierKeysProps {
|
|
7
|
+
/** The session's `modifiers`. */
|
|
8
|
+
modifiers: StickyModifiers;
|
|
9
|
+
/** The session's `tapModifier`. */
|
|
10
|
+
onTap: (name: StickyModifier) => void;
|
|
11
|
+
style?: StyleProp<ViewStyle>;
|
|
12
|
+
/**
|
|
13
|
+
* Tablet targets: the keys share the width and take the height the row is
|
|
14
|
+
* given, instead of sitting at 44 pt in a row of a phone's. A row shorter
|
|
15
|
+
* than the keys' minimum keeps the minimum.
|
|
16
|
+
*/
|
|
17
|
+
grow?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const LABELS: Record<StickyModifier, string> = { Control: 'Ctrl', Shift: 'Shift', Alt: 'Alt', Meta: 'Meta' };
|
|
21
|
+
/** What a screen reader says for each state; "once" is the latch the user sees. */
|
|
22
|
+
export const STATE_NAMES: Record<StickyState, string> = { off: 'off', once: 'latched', lock: 'locked' };
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The sticky modifier keys as a row the user can read at a glance.
|
|
26
|
+
*
|
|
27
|
+
* Off is an outlined dark key, latched is a filled key, and locked is a
|
|
28
|
+
* filled key with a white rim and a bar under its label, so the three differ
|
|
29
|
+
* in shape as well as colour. The row carries its own backing, so it reads
|
|
30
|
+
* the same over a light desktop as over a dark one.
|
|
31
|
+
*/
|
|
32
|
+
export function ModifierKeys({ modifiers, onTap, style, grow }: ModifierKeysProps) {
|
|
33
|
+
return (
|
|
34
|
+
<View style={[styles.row, grow && styles.grown, style]}>
|
|
35
|
+
{(['Control', 'Shift', 'Alt', 'Meta'] as const).map((name) => {
|
|
36
|
+
const state = modifiers[name];
|
|
37
|
+
return (
|
|
38
|
+
<Pressable
|
|
39
|
+
key={name}
|
|
40
|
+
testID={`desklink-key-${name}`}
|
|
41
|
+
accessibilityRole="button"
|
|
42
|
+
accessibilityLabel={LABELS[name]}
|
|
43
|
+
accessibilityState={{ selected: state !== 'off' }}
|
|
44
|
+
accessibilityValue={{ text: STATE_NAMES[state] }}
|
|
45
|
+
onPress={() => onTap(name)}
|
|
46
|
+
style={[styles.key, grow && styles.keyGrown, state === 'off' ? styles.off : styles.on, state === 'lock' && styles.lock]}
|
|
47
|
+
>
|
|
48
|
+
<Text style={state === 'off' ? styles.offLabel : styles.onLabel}>{LABELS[name]}</Text>
|
|
49
|
+
<View style={[styles.bar, state === 'lock' && styles.barShown]} />
|
|
50
|
+
</Pressable>
|
|
51
|
+
);
|
|
52
|
+
})}
|
|
53
|
+
</View>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const KEY_COLORS = {
|
|
58
|
+
backing: 'rgba(22, 23, 26, 0.94)',
|
|
59
|
+
off: '#2b2d33',
|
|
60
|
+
offLabel: '#c9ccd3',
|
|
61
|
+
on: '#2563eb',
|
|
62
|
+
onLabel: '#ffffff',
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const styles = StyleSheet.create({
|
|
66
|
+
row: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, padding: 8, backgroundColor: KEY_COLORS.backing },
|
|
67
|
+
grown: { width: '100%', flexGrow: 1, maxHeight: 160 },
|
|
68
|
+
key: { minWidth: 56, maxWidth: '100%', minHeight: 44, paddingHorizontal: 12, borderRadius: 8, borderWidth: 2, alignItems: 'center', justifyContent: 'center' },
|
|
69
|
+
keyGrown: { flexGrow: 1, minHeight: 96, paddingHorizontal: 24 },
|
|
70
|
+
off: { backgroundColor: KEY_COLORS.off, borderColor: '#4b4e57' },
|
|
71
|
+
on: { backgroundColor: KEY_COLORS.on, borderColor: KEY_COLORS.on },
|
|
72
|
+
lock: { borderColor: '#ffffff' },
|
|
73
|
+
offLabel: { color: KEY_COLORS.offLabel, fontSize: 15, fontWeight: '500' },
|
|
74
|
+
onLabel: { color: KEY_COLORS.onLabel, fontSize: 15, fontWeight: '700' },
|
|
75
|
+
bar: { marginTop: 3, width: 18, height: 3, borderRadius: 2, backgroundColor: 'transparent' },
|
|
76
|
+
barShown: { backgroundColor: '#ffffff' },
|
|
77
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import TestRenderer, { act } from 'react-test-renderer';
|
|
4
|
+
|
|
5
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
6
|
+
|
|
7
|
+
/** The key row and the clipboard pill, rendered with `react-native` primitives as host strings. */
|
|
8
|
+
vi.mock('react-native', () => ({
|
|
9
|
+
StyleSheet: { create: (styles: unknown) => styles, absoluteFill: {} },
|
|
10
|
+
Linking: { getInitialURL: async () => null, addEventListener: () => ({ remove: () => undefined }) },
|
|
11
|
+
Platform: { OS: 'ios' },
|
|
12
|
+
Pressable: 'Pressable',
|
|
13
|
+
ScrollView: 'ScrollView',
|
|
14
|
+
StatusBar: 'StatusBar',
|
|
15
|
+
Text: 'Text',
|
|
16
|
+
View: 'View',
|
|
17
|
+
Settings: { get: () => 'ws://localhost/desktop?token=test' },
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock('react-native-safe-area-context', () => ({
|
|
21
|
+
SafeAreaProvider: ({ children }: { children: React.ReactNode }) => children,
|
|
22
|
+
useSafeAreaInsets: () => ({ top: 0, left: 0, bottom: 0, right: 0 }),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
const example = vi.hoisted(() => ({
|
|
26
|
+
write: vi.fn(),
|
|
27
|
+
copy: vi.fn(),
|
|
28
|
+
connect: vi.fn(),
|
|
29
|
+
}));
|
|
30
|
+
vi.mock('expo-clipboard', () => ({ setStringAsync: example.write }));
|
|
31
|
+
vi.mock('@desklink/react-native', async () => ({
|
|
32
|
+
CONTROL_PERMISSIONS: ['view', 'control', 'clipboard'],
|
|
33
|
+
ClipboardConfirmation: (await import('./ClipboardConfirmation')).ClipboardConfirmation,
|
|
34
|
+
ModifierKeys: (await import('./ModifierKeys')).ModifierKeys,
|
|
35
|
+
DesktopView: 'DesktopView',
|
|
36
|
+
useDesktopSession: () => ({
|
|
37
|
+
snapshot: { status: 'live' },
|
|
38
|
+
nativeId: 'example-session',
|
|
39
|
+
modifiers: { Control: 'off', Shift: 'off', Alt: 'off', Meta: 'off' },
|
|
40
|
+
clipboard: null,
|
|
41
|
+
connect: example.connect,
|
|
42
|
+
setInputEnabled: vi.fn(),
|
|
43
|
+
copyRemoteToLocal: example.copy,
|
|
44
|
+
tapModifier: vi.fn(),
|
|
45
|
+
}),
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
import { KEY_COLORS, ModifierKeys } from './ModifierKeys';
|
|
49
|
+
import { CLIPBOARD_CONFIRMATION_MS, ClipboardConfirmation, PILL_COLORS, describeTransfer } from './ClipboardConfirmation';
|
|
50
|
+
import { NO_MODIFIERS } from './stickyModifiers';
|
|
51
|
+
import type { ClipboardTransfer } from './useDesktopSession';
|
|
52
|
+
import App from '../example/App';
|
|
53
|
+
|
|
54
|
+
type Style = Record<string, unknown>;
|
|
55
|
+
const flatten = (style: unknown): Style =>
|
|
56
|
+
Array.isArray(style) ? Object.assign({}, ...style.map(flatten)) : style ? (style as Style) : {};
|
|
57
|
+
|
|
58
|
+
/** WCAG contrast of two colours, the first drawn with its alpha over `under`. */
|
|
59
|
+
function contrast(top: string, bottom: string, under = '#000000'): number {
|
|
60
|
+
const rgba = (c: string) => {
|
|
61
|
+
const m = c.match(/rgba?\(([^)]+)\)/);
|
|
62
|
+
if (m) { const [r, g, b, a = 1] = m[1].split(',').map(Number); return [r, g, b, a]; }
|
|
63
|
+
return [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16)).concat(1);
|
|
64
|
+
};
|
|
65
|
+
const over = (c: string) => {
|
|
66
|
+
const [r, g, b, a] = rgba(c);
|
|
67
|
+
const [ur, ug, ub] = rgba(under);
|
|
68
|
+
return [r * a + ur * (1 - a), g * a + ug * (1 - a), b * a + ub * (1 - a)];
|
|
69
|
+
};
|
|
70
|
+
const lum = (rgb: number[]) => {
|
|
71
|
+
const [r, g, b] = rgb.map((v) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; });
|
|
72
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
73
|
+
};
|
|
74
|
+
const [l1, l2] = [lum(over(top)), lum(over(bottom))].sort((a, b) => b - a);
|
|
75
|
+
return (l1 + 0.05) / (l2 + 0.05);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe('ModifierKeys', () => {
|
|
79
|
+
function render(modifiers = NO_MODIFIERS, onTap = vi.fn()) {
|
|
80
|
+
let tree!: TestRenderer.ReactTestRenderer;
|
|
81
|
+
act(() => { tree = TestRenderer.create(<ModifierKeys modifiers={modifiers} onTap={onTap} />); });
|
|
82
|
+
const key = (name: string) => tree.root.find((node) => node.props.testID === `desklink-key-${name}`);
|
|
83
|
+
return { key, onTap };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
it('draws off, latched and locked as three different keys and names each state', () => {
|
|
87
|
+
const { key } = render({ Control: 'off', Shift: 'once', Alt: 'lock', Meta: 'off' });
|
|
88
|
+
const look = (name: string) => {
|
|
89
|
+
const node = key(name);
|
|
90
|
+
const [, bar] = node.props.children;
|
|
91
|
+
return { key: flatten(node.props.style), bar: flatten(bar.props.style), said: node.props.accessibilityValue.text };
|
|
92
|
+
};
|
|
93
|
+
const off = look('Control');
|
|
94
|
+
const latched = look('Shift');
|
|
95
|
+
const locked = look('Alt');
|
|
96
|
+
expect([off.said, latched.said, locked.said]).toEqual(['off', 'latched', 'locked']);
|
|
97
|
+
expect(off.key.backgroundColor).not.toBe(latched.key.backgroundColor);
|
|
98
|
+
// Latched and locked share a fill and differ in shape: the rim and the bar.
|
|
99
|
+
expect(locked.key.borderColor).not.toBe(latched.key.borderColor);
|
|
100
|
+
expect(locked.bar.backgroundColor).not.toBe(latched.bar.backgroundColor);
|
|
101
|
+
expect(key('Control').props.accessibilityState).toEqual({ selected: false });
|
|
102
|
+
expect(key('Shift').props.accessibilityState).toEqual({ selected: true });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('hands a tap to the session', () => {
|
|
106
|
+
const { key, onTap } = render();
|
|
107
|
+
key('Control').props.onPress();
|
|
108
|
+
expect(onTap).toHaveBeenCalledWith('Control');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('keeps every label at 4.5:1 or better', () => {
|
|
112
|
+
expect(contrast(KEY_COLORS.offLabel, KEY_COLORS.off)).toBeGreaterThanOrEqual(4.5);
|
|
113
|
+
expect(contrast(KEY_COLORS.onLabel, KEY_COLORS.on)).toBeGreaterThanOrEqual(4.5);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('ClipboardConfirmation', () => {
|
|
118
|
+
beforeEach(() => { vi.useFakeTimers(); });
|
|
119
|
+
afterEach(() => { vi.useRealTimers(); });
|
|
120
|
+
|
|
121
|
+
const transfer = (id: number, text = 'hello world'): ClipboardTransfer => ({ id, direction: 'to-desktop', text, truncated: false });
|
|
122
|
+
|
|
123
|
+
it('shows each transfer, ignores touches, and goes away after about 1.5 s', () => {
|
|
124
|
+
let tree!: TestRenderer.ReactTestRenderer;
|
|
125
|
+
act(() => { tree = TestRenderer.create(<ClipboardConfirmation transfer={null} />); });
|
|
126
|
+
expect(tree.toJSON()).toBeNull();
|
|
127
|
+
|
|
128
|
+
act(() => { tree.update(<ClipboardConfirmation transfer={transfer(1)} />); });
|
|
129
|
+
const place = tree.toJSON() as TestRenderer.ReactTestRendererJSON;
|
|
130
|
+
expect(place.props.pointerEvents).toBe('none');
|
|
131
|
+
expect(flatten(place.props.style).position).toBeUndefined();
|
|
132
|
+
expect(JSON.stringify(place)).toContain('Sent to desktop · “hello world”');
|
|
133
|
+
|
|
134
|
+
act(() => { vi.advanceTimersByTime(CLIPBOARD_CONFIRMATION_MS - 1); });
|
|
135
|
+
expect(tree.toJSON()).not.toBeNull();
|
|
136
|
+
act(() => { vi.advanceTimersByTime(1); });
|
|
137
|
+
expect(tree.toJSON()).toBeNull();
|
|
138
|
+
|
|
139
|
+
// The same text again is a new transfer and is confirmed again.
|
|
140
|
+
act(() => { tree.update(<ClipboardConfirmation transfer={transfer(2)} />); });
|
|
141
|
+
expect(tree.toJSON()).not.toBeNull();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('describes where the text went and how it starts, with no byte count', () => {
|
|
145
|
+
expect(describeTransfer({ id: 1, direction: 'to-phone', text: 'a\n b', truncated: false })).toBe('Copied to phone · “a b”');
|
|
146
|
+
expect(describeTransfer({ id: 1, direction: 'to-phone', text: 'x'.repeat(2048), truncated: true })).toBe(`Copied to phone · “${'x'.repeat(24)}…”`);
|
|
147
|
+
expect(describeTransfer({ id: 1, direction: 'to-desktop', text: ' ', truncated: false })).toBe('Sent to desktop');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('reads at 4.5:1 or better over a white or a black desktop', () => {
|
|
151
|
+
expect(contrast(PILL_COLORS.text, PILL_COLORS.backing, '#ffffff')).toBeGreaterThanOrEqual(4.5);
|
|
152
|
+
expect(contrast(PILL_COLORS.text, PILL_COLORS.backing, '#000000')).toBeGreaterThanOrEqual(4.5);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('example Copy', () => {
|
|
157
|
+
it('writes only on a tap and retains a backed accessible failure until the next tap', async () => {
|
|
158
|
+
example.write.mockReset();
|
|
159
|
+
example.copy.mockReset();
|
|
160
|
+
example.copy.mockImplementation(async (write: (text: string) => Promise<void>) => { await write('desktop text'); });
|
|
161
|
+
let tree!: TestRenderer.ReactTestRenderer;
|
|
162
|
+
await act(async () => { tree = TestRenderer.create(<App />); });
|
|
163
|
+
expect(example.write).not.toHaveBeenCalled();
|
|
164
|
+
const tap = () => tree.root.findByProps({ testID: 'desklink-copy' }).props.onPress();
|
|
165
|
+
|
|
166
|
+
example.copy.mockRejectedValueOnce(new Error('clipboard is unavailable for this desktop source'));
|
|
167
|
+
await act(async () => { tap(); });
|
|
168
|
+
expect(tree.root.findByProps({ testID: 'desklink-copy-error' }).findByType('Text').props.children)
|
|
169
|
+
.toBe('Clipboard failed: clipboard is unavailable for this desktop source');
|
|
170
|
+
expect(example.write).not.toHaveBeenCalled();
|
|
171
|
+
|
|
172
|
+
example.write.mockResolvedValue(false);
|
|
173
|
+
await act(async () => { tap(); });
|
|
174
|
+
expect(example.write).toHaveBeenCalledWith('desktop text');
|
|
175
|
+
const error = tree.root.findByProps({ testID: 'desklink-copy-error' });
|
|
176
|
+
expect(error.props.accessible).toBe(true);
|
|
177
|
+
expect(error.props.accessibilityRole).toBe('alert');
|
|
178
|
+
expect(error.props.accessibilityLiveRegion).toBe('polite');
|
|
179
|
+
const style = flatten(error.props.style);
|
|
180
|
+
expect(style.paddingHorizontal).toBeGreaterThan(0);
|
|
181
|
+
expect(style.paddingVertical).toBeGreaterThan(0);
|
|
182
|
+
expect(contrast('#ffffff', style.backgroundColor as string)).toBeGreaterThanOrEqual(4.5);
|
|
183
|
+
expect(error.findByType('Text').props.children).toBe('Clipboard failed: The phone refused the clipboard write.');
|
|
184
|
+
expect(tree.root.findAllByProps({ testID: 'desklink-clipboard' })).toHaveLength(0);
|
|
185
|
+
|
|
186
|
+
vi.useFakeTimers();
|
|
187
|
+
try {
|
|
188
|
+
act(() => { vi.advanceTimersByTime(5000); });
|
|
189
|
+
expect(tree.root.findAllByProps({ testID: 'desklink-copy-error' })).toHaveLength(1);
|
|
190
|
+
} finally { vi.useRealTimers(); }
|
|
191
|
+
|
|
192
|
+
example.write.mockRejectedValue(new Error('native write refused'));
|
|
193
|
+
await act(async () => { tap(); });
|
|
194
|
+
expect(tree.root.findByProps({ testID: 'desklink-copy-error' }).findByType('Text').props.children).toBe('Clipboard failed: native write refused');
|
|
195
|
+
|
|
196
|
+
let finish!: (written: boolean) => void;
|
|
197
|
+
example.write.mockImplementation(() => new Promise<boolean>((resolve) => { finish = resolve; }));
|
|
198
|
+
await act(async () => { tap(); });
|
|
199
|
+
expect(tree.root.findAllByProps({ testID: 'desklink-copy-error' })).toHaveLength(0);
|
|
200
|
+
expect(tree.root.findAllByProps({ testID: 'desklink-clipboard' })).toHaveLength(0);
|
|
201
|
+
await act(async () => { finish(true); });
|
|
202
|
+
expect(tree.root.findAllByProps({ testID: 'desklink-copy-error' })).toHaveLength(0);
|
|
203
|
+
act(() => { tree.unmount(); });
|
|
204
|
+
});
|
|
205
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -13,9 +13,12 @@ export { desktopAvailable } from './availability';
|
|
|
13
13
|
export { DesktopView, type DesktopViewProps, type EdgeInsets } from './DesktopView';
|
|
14
14
|
export { observeWebKeyboardMotion } from './webKeyboardMotion';
|
|
15
15
|
export type { StickyModifier, StickyModifiers, StickyState } from './stickyModifiers';
|
|
16
|
+
export { ModifierKeys, type ModifierKeysProps } from './ModifierKeys';
|
|
17
|
+
export { ClipboardConfirmation, type ClipboardConfirmationProps } from './ClipboardConfirmation';
|
|
16
18
|
export {
|
|
17
19
|
useDesktopSession,
|
|
18
20
|
CONTROL_PERMISSIONS,
|
|
21
|
+
type ClipboardTransfer,
|
|
19
22
|
type DesktopSession,
|
|
20
23
|
type DesktopSessionOptions,
|
|
21
24
|
} from './useDesktopSession';
|
package/src/protocol.ts
CHANGED
|
@@ -56,10 +56,17 @@ export interface SessionOpenRequest {
|
|
|
56
56
|
iceServers?: IceServerConfig[];
|
|
57
57
|
restoreToken?: string;
|
|
58
58
|
ttlSeconds?: number;
|
|
59
|
+
/**
|
|
60
|
+
* Ask the engine to also offer ICE over TCP on its own loopback, for a
|
|
61
|
+
* client whose only way to this computer is a forward of that loopback
|
|
62
|
+
* (an SSH tunnel, `adb reverse`). A client with a UDP path keeps it: ICE
|
|
63
|
+
* prefers UDP whenever it has one.
|
|
64
|
+
*/
|
|
65
|
+
loopbackTcp?: boolean;
|
|
59
66
|
}
|
|
60
67
|
|
|
61
68
|
export interface SessionOpenResult {
|
|
62
|
-
cursor?: { mode: 'embedded' | 'hidden' | 'metadata' | 'unavailable'; positions: boolean };
|
|
69
|
+
cursor?: { mode: 'embedded' | 'hidden' | 'metadata' | 'unavailable'; positions: boolean; source?: 'ext-image-copy-capture-v1'; limitation?: string };
|
|
63
70
|
sessionId: string;
|
|
64
71
|
generation: number;
|
|
65
72
|
source: { kind: string; width: number; height: number; origin: { x: number; y: number } };
|
|
@@ -83,6 +90,7 @@ export interface CursorSample {
|
|
|
83
90
|
y: number;
|
|
84
91
|
visible: boolean;
|
|
85
92
|
timestamp_us: number;
|
|
93
|
+
hotspot?: { x: number; y: number };
|
|
86
94
|
}
|
|
87
95
|
|
|
88
96
|
/**
|
package/src/useDesktopSession.ts
CHANGED
|
@@ -25,6 +25,14 @@ import {
|
|
|
25
25
|
type StickyModifiers,
|
|
26
26
|
} from './stickyModifiers';
|
|
27
27
|
|
|
28
|
+
/** One clipboard transfer that reached its destination; `id` tells two equal transfers apart. */
|
|
29
|
+
export interface ClipboardTransfer {
|
|
30
|
+
id: number;
|
|
31
|
+
direction: 'to-phone' | 'to-desktop';
|
|
32
|
+
text: string;
|
|
33
|
+
truncated: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
28
36
|
/** How long a clipboard round trip may take before it is reported as lost. */
|
|
29
37
|
const CLIPBOARD_TIMEOUT_MS = 4000;
|
|
30
38
|
|
|
@@ -49,6 +57,35 @@ const STALL_AFTER_MS = 1200;
|
|
|
49
57
|
const RESTART_GRACE_MS = 2500;
|
|
50
58
|
/** How long a restart offer may go unanswered before the next attempt. */
|
|
51
59
|
const RESTART_REPLY_MS = 5000;
|
|
60
|
+
/**
|
|
61
|
+
* How long the media path may carry nothing at all before a session that says
|
|
62
|
+
* `live` stops saying so. The engine's transport state reaches the client over
|
|
63
|
+
* the signalling channel, which is not part of the media path, so a dead link
|
|
64
|
+
* with signalling up reads as `connected` over a picture that has stopped: live
|
|
65
|
+
* has to mean the picture is advancing, not that someone answered.
|
|
66
|
+
*/
|
|
67
|
+
const FROZEN_AFTER_MS = 2000;
|
|
68
|
+
/** How often the media path is read while the session claims to be live. */
|
|
69
|
+
const MEDIA_POLL_MS = 500;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* What the receiver has taken in, summed over its video streams: new decoded
|
|
73
|
+
* frames, and packets that arrived at all. Either moving means the picture is
|
|
74
|
+
* advancing; a still screen that keeps no frames moving is not a dead path, so
|
|
75
|
+
* both counts are read rather than frames alone.
|
|
76
|
+
*/
|
|
77
|
+
function mediaCounts(rows: Array<Record<string, unknown>>): { frames: number; packets: number } | null {
|
|
78
|
+
let frames = 0;
|
|
79
|
+
let packets = 0;
|
|
80
|
+
let streams = 0;
|
|
81
|
+
for (const row of rows) {
|
|
82
|
+
if (row.mediaType !== undefined && row.mediaType !== 'video') continue;
|
|
83
|
+
streams += 1;
|
|
84
|
+
frames += typeof row.framesDecoded === 'number' ? row.framesDecoded : 0;
|
|
85
|
+
packets += typeof row.packetsReceived === 'number' ? row.packetsReceived : 0;
|
|
86
|
+
}
|
|
87
|
+
return streams === 0 ? null : { frames, packets };
|
|
88
|
+
}
|
|
52
89
|
/**
|
|
53
90
|
* Full session reopens back off across about a minute, holding at patient
|
|
54
91
|
* 8 s retries: a return must never wait out a 32 s gap, and an attempt every
|
|
@@ -86,13 +123,17 @@ export type ConnectionStateName =
|
|
|
86
123
|
* What one ICE/engine-transport state means for the session status, or null
|
|
87
124
|
* when it changes nothing. `disconnected` surfaces as `reconnecting` at once —
|
|
88
125
|
* event-driven, so within about a second of the drop — and `connected` moves a
|
|
89
|
-
* session
|
|
90
|
-
* handled by the failure path, not here.
|
|
126
|
+
* session whose picture is advancing back to `live`. `failed` is terminal and
|
|
127
|
+
* is handled by the failure path, not here.
|
|
128
|
+
*
|
|
129
|
+
* `advancing` is the media path, not the transport: a transport that answers
|
|
130
|
+
* with a still image is not a live desktop, so a session whose picture has
|
|
131
|
+
* stopped stays out of `live` whatever the transport reports.
|
|
91
132
|
*/
|
|
92
133
|
export function connectionStatusFor(
|
|
93
134
|
state: string,
|
|
94
135
|
status: SessionSnapshot['status'],
|
|
95
|
-
|
|
136
|
+
advancing: boolean,
|
|
96
137
|
): SessionSnapshot['status'] | null {
|
|
97
138
|
switch (state) {
|
|
98
139
|
case 'disconnected':
|
|
@@ -100,9 +141,9 @@ export function connectionStatusFor(
|
|
|
100
141
|
case 'connected':
|
|
101
142
|
case 'completed':
|
|
102
143
|
if (status !== 'reconnecting') return null;
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
return
|
|
144
|
+
// No picture is moving: this is still the first connection, not a
|
|
145
|
+
// recovery, so it stays `connecting` until frames advance again.
|
|
146
|
+
return advancing ? 'live' : 'connecting';
|
|
106
147
|
default:
|
|
107
148
|
return null;
|
|
108
149
|
}
|
|
@@ -116,6 +157,15 @@ export function connectionStatusFor(
|
|
|
116
157
|
const UNREACHABLE_DESKTOP =
|
|
117
158
|
'The phone must reach the desktop directly, on the same network or its tailnet. Other networks need a relay route supplied by the app.';
|
|
118
159
|
|
|
160
|
+
/**
|
|
161
|
+
* A connection that never shows a frame is a connection that never formed, and
|
|
162
|
+
* saying so beats an empty picture: on a path that only reaches the desktop
|
|
163
|
+
* through a tunnel or a USB forward, the app has to ask for the loopback route.
|
|
164
|
+
*/
|
|
165
|
+
export const FIRST_FRAME_AFTER_MS = 20_000;
|
|
166
|
+
const NO_PICTURE =
|
|
167
|
+
'The desktop never sent a picture. On a path that only reaches the desktop through a tunnel or a USB forward, open the session with loopbackTcp: true.';
|
|
168
|
+
|
|
119
169
|
/**
|
|
120
170
|
* The engine's and host's own refusal tokens, mapped to this package's failure
|
|
121
171
|
* codes. A token the engine did not send is a deliberate refusal, not a network
|
|
@@ -199,8 +249,8 @@ export interface DesktopSession {
|
|
|
199
249
|
close: (reason?: string) => Promise<void>;
|
|
200
250
|
showKeyboard: () => void;
|
|
201
251
|
hideKeyboard: () => void;
|
|
202
|
-
/** Copy the
|
|
203
|
-
copyRemoteToLocal: () => Promise<{ text: string; truncated: boolean }>;
|
|
252
|
+
/** Copy using the caller's local writer, which must reject if the write fails. */
|
|
253
|
+
copyRemoteToLocal: (writeLocal: (text: string) => Promise<void>) => Promise<{ text: string; truncated: boolean }>;
|
|
204
254
|
/** Send the phone's clipboard text to the desktop. */
|
|
205
255
|
pasteLocalToRemote: (text: string) => Promise<void>;
|
|
206
256
|
/** Release anything the desktop is holding, without ending the session. */
|
|
@@ -221,6 +271,8 @@ export interface DesktopSession {
|
|
|
221
271
|
* because the engine lets go of exactly the modifiers the message names.
|
|
222
272
|
*/
|
|
223
273
|
pressKey: (name: string) => () => void;
|
|
274
|
+
/** The last clipboard transfer that completed, for the app to confirm; null until one does. */
|
|
275
|
+
clipboard: ClipboardTransfer | null;
|
|
224
276
|
}
|
|
225
277
|
|
|
226
278
|
const IDLE: SessionSnapshot = {
|
|
@@ -245,6 +297,8 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
245
297
|
const [modifiers, setModifiers] = useState<StickyModifiers>(NO_MODIFIERS);
|
|
246
298
|
/** Read by input as it arrives, which can be faster than a render. */
|
|
247
299
|
const modifiersRef = useRef<StickyModifiers>(NO_MODIFIERS);
|
|
300
|
+
const [clipboard, setClipboard] = useState<ClipboardTransfer | null>(null);
|
|
301
|
+
const clipboardCount = useRef(0);
|
|
248
302
|
|
|
249
303
|
const opened = useRef<SessionOpenResult | null>(null);
|
|
250
304
|
const signaling = useRef<Signaling | null>(null);
|
|
@@ -270,8 +324,18 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
270
324
|
const restartTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
271
325
|
/** The pending stall watchdog, armed by heartbeats. */
|
|
272
326
|
const stallTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
327
|
+
/** Armed while a first connection waits for its first frame. */
|
|
328
|
+
const firstFrameTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
273
329
|
/** When the last heartbeat arrived, or 0 before the first one. */
|
|
274
330
|
const lastPingAt = useRef(0);
|
|
331
|
+
/** The pending media read, armed while the session claims to be live. */
|
|
332
|
+
const mediaTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
333
|
+
/** The receiver's counts at the last media read, to see the next one move. */
|
|
334
|
+
const mediaRef = useRef<{ frames: number; packets: number } | null>(null);
|
|
335
|
+
/** When the media path last moved, or null while it has never moved. */
|
|
336
|
+
const mediaMovingAt = useRef<number | null>(null);
|
|
337
|
+
/** Whether the picture of an open session has stopped advancing. */
|
|
338
|
+
const frozen = useRef(false);
|
|
275
339
|
/** Renegotiation transients are not a new outage before this time. */
|
|
276
340
|
const restartGraceUntil = useRef(0);
|
|
277
341
|
/** ICE-restart attempts spent on the current drop. */
|
|
@@ -343,6 +407,24 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
343
407
|
stallTimer.current = null;
|
|
344
408
|
}, []);
|
|
345
409
|
|
|
410
|
+
const cancelFirstFrame = useCallback(() => {
|
|
411
|
+
if (firstFrameTimer.current !== null) clearTimeout(firstFrameTimer.current);
|
|
412
|
+
firstFrameTimer.current = null;
|
|
413
|
+
}, []);
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* A first connection that stays frameless for this long is not going to
|
|
417
|
+
* arrive: report why instead of leaving an empty picture on screen.
|
|
418
|
+
*/
|
|
419
|
+
const armFirstFrame = useCallback(() => {
|
|
420
|
+
cancelFirstFrame();
|
|
421
|
+
firstFrameTimer.current = setTimeout(() => {
|
|
422
|
+
firstFrameTimer.current = null;
|
|
423
|
+
if (presentedRef.current || statusRef.current !== 'connecting') return;
|
|
424
|
+
transportFailed(NO_PICTURE);
|
|
425
|
+
}, FIRST_FRAME_AFTER_MS);
|
|
426
|
+
}, [cancelFirstFrame, transportFailed]);
|
|
427
|
+
|
|
346
428
|
const scheduleRestartRef = useRef<(delay: number) => void>(() => undefined);
|
|
347
429
|
|
|
348
430
|
/**
|
|
@@ -352,7 +434,11 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
352
434
|
*/
|
|
353
435
|
const spendRestartAttempt = useCallback((): void => {
|
|
354
436
|
if (statusRef.current !== 'reconnecting' || nativeRef.current == null) return;
|
|
355
|
-
|
|
437
|
+
// A frozen picture is the media path itself, and a restart is what
|
|
438
|
+
// rebuilds it, so the short delay keeps being spent until frames move
|
|
439
|
+
// again. Backing off to half a minute while the picture is stopped
|
|
440
|
+
// leaves the user looking at a still image long after the link is back.
|
|
441
|
+
const wait = frozen.current ? RESTART_AFTER_MS : backoffDelay(RESTART_BACKOFF_MS, restartAttempts.current);
|
|
356
442
|
if (wait === null) {
|
|
357
443
|
transportFailed('the desktop did not come back; reopening the session');
|
|
358
444
|
return;
|
|
@@ -400,7 +486,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
400
486
|
// the session itself is wedged and only a reopen heals it.
|
|
401
487
|
// (While ICE is down, patience: the network may still come
|
|
402
488
|
// back, and the backoff schedule owns that case.)
|
|
403
|
-
if (restartCycles.current >= 2 && iceConnected.current) {
|
|
489
|
+
if (restartCycles.current >= 2 && iceConnected.current && !frozen.current) {
|
|
404
490
|
transportFailed('the desktop stopped responding; reopening the session');
|
|
405
491
|
return;
|
|
406
492
|
}
|
|
@@ -408,7 +494,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
408
494
|
// which the session answers as usual; without recovery by the
|
|
409
495
|
// reply deadline, spend the next attempt. A live transport
|
|
410
496
|
// answers in milliseconds, so hold it to a short leash.
|
|
411
|
-
const replyMs = iceConnected.current ? 2000 : RESTART_REPLY_MS;
|
|
497
|
+
const replyMs = frozen.current ? RESTART_AFTER_MS : iceConnected.current ? 2000 : RESTART_REPLY_MS;
|
|
412
498
|
restartTimer.current = setTimeout(() => {
|
|
413
499
|
restartTimer.current = null;
|
|
414
500
|
spendRestartAttempt();
|
|
@@ -420,22 +506,36 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
420
506
|
useEffect(() => {
|
|
421
507
|
scheduleRestartRef.current = scheduleRestart;
|
|
422
508
|
checkStallRef.current = checkStall;
|
|
509
|
+
mediaWatchRef.current = checkMedia;
|
|
423
510
|
});
|
|
424
511
|
|
|
425
512
|
const checkStallRef = useRef(() => {});
|
|
513
|
+
const mediaWatchRef = useRef<() => void>(() => undefined);
|
|
514
|
+
|
|
515
|
+
/** Arm one media read; each tick decides whether to arm another. */
|
|
516
|
+
const armMedia = useCallback((): void => {
|
|
517
|
+
if (mediaTimer.current !== null) return;
|
|
518
|
+
mediaTimer.current = setTimeout(() => mediaWatchRef.current(), MEDIA_POLL_MS);
|
|
519
|
+
}, []);
|
|
426
520
|
|
|
427
521
|
/**
|
|
428
522
|
* One ICE or engine-transport state, from either reporter. The mapping is
|
|
429
523
|
* idempotent, so the peer's event and the engine's `session.state` telling
|
|
430
524
|
* the same story neither double-counts nor fights.
|
|
431
525
|
*/
|
|
432
|
-
const onTransportState = useCallback((state: string, trackIce = true) => {
|
|
526
|
+
const onTransportState = useCallback((state: string, trackIce = true, skipGrace = false) => {
|
|
433
527
|
// Synthetic stall reports say nothing about ICE itself.
|
|
434
528
|
if (trackIce) iceConnected.current = state === 'connected' || state === 'completed';
|
|
435
|
-
const
|
|
436
|
-
if (
|
|
529
|
+
const mapped = connectionStatusFor(state, statusRef.current, presentedRef.current && !frozen.current);
|
|
530
|
+
if (mapped === null) return;
|
|
531
|
+
// A transport that answers while the picture is frozen is not a
|
|
532
|
+
// recovery. For a session that has shown frames that stays `reconnecting`,
|
|
533
|
+
// rather than falling back to the first connection it has left behind.
|
|
534
|
+
const next = mapped === 'connecting' && statusRef.current === 'reconnecting' && presentedRef.current
|
|
535
|
+
? 'reconnecting'
|
|
536
|
+
: mapped;
|
|
437
537
|
// Our own restart's transients are expected, not a new outage.
|
|
438
|
-
if (next === 'reconnecting' && Date.now() < restartGraceUntil.current) return;
|
|
538
|
+
if (!skipGrace && next === 'reconnecting' && Date.now() < restartGraceUntil.current) return;
|
|
439
539
|
update({ status: next });
|
|
440
540
|
if (next === 'reconnecting') {
|
|
441
541
|
// Only a path that once carried frames is worth restarting: an
|
|
@@ -451,9 +551,66 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
451
551
|
// from here is a stalled session, not a healthy idle one.
|
|
452
552
|
cancelStall();
|
|
453
553
|
stallTimer.current = setTimeout(() => checkStallRef.current(), STALL_AFTER_MS);
|
|
554
|
+
// And the picture is what says live, so it is watched from here.
|
|
555
|
+
armMedia();
|
|
454
556
|
}
|
|
455
557
|
}
|
|
456
|
-
}, [cancelRestart, cancelStall, scheduleRestart, update]);
|
|
558
|
+
}, [cancelRestart, cancelStall, scheduleRestart, update, armMedia]);
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* One read of the receiver's own counts: is the picture actually advancing?
|
|
562
|
+
*
|
|
563
|
+
* The engine's transport state arrives over the signalling channel, which
|
|
564
|
+
* is not the media path, so a link that carries nothing while signalling
|
|
565
|
+
* answers can hand the session a `connected` it must not believe. This is
|
|
566
|
+
* the check that keeps `live` honest, and the one that ends the freeze:
|
|
567
|
+
* frames moving again put the session back to `live` on the same session.
|
|
568
|
+
*/
|
|
569
|
+
const checkMedia = useCallback((): void => {
|
|
570
|
+
mediaTimer.current = null;
|
|
571
|
+
// A session that has shown a picture is watched for as long as it has
|
|
572
|
+
// one: a freeze that arrives while the session is already reconnecting
|
|
573
|
+
// is exactly the case where waiting for the transport to say so would
|
|
574
|
+
// say it for the rest of the outage.
|
|
575
|
+
if (!presentedRef.current) return;
|
|
576
|
+
armMedia();
|
|
577
|
+
const id = nativeRef.current;
|
|
578
|
+
if (id == null || nativeDesklink?.getStats == null) return;
|
|
579
|
+
const token = generationToken.current;
|
|
580
|
+
void nativeDesklink.getStats(id).then((raw) => {
|
|
581
|
+
if (token !== generationToken.current || nativeRef.current !== id) return;
|
|
582
|
+
let counts: { frames: number; packets: number } | null;
|
|
583
|
+
try {
|
|
584
|
+
counts = mediaCounts(JSON.parse(raw));
|
|
585
|
+
} catch {
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
// No video stats yet: there is nothing here to judge a picture by.
|
|
589
|
+
if (counts === null) return;
|
|
590
|
+
const previous = mediaRef.current;
|
|
591
|
+
mediaRef.current = counts;
|
|
592
|
+
const moving = previous === null
|
|
593
|
+
|| counts.frames > previous.frames
|
|
594
|
+
|| counts.packets > previous.packets;
|
|
595
|
+
if (moving) {
|
|
596
|
+
mediaMovingAt.current = Date.now();
|
|
597
|
+
if (!frozen.current) return;
|
|
598
|
+
frozen.current = false;
|
|
599
|
+
// The picture advances again, so the transport can be believed
|
|
600
|
+
// for this session: live returns without waiting for it to say so.
|
|
601
|
+
if (statusRef.current === 'reconnecting') onTransportState('connected', false);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
if (mediaMovingAt.current === null) return;
|
|
605
|
+
if (Date.now() - mediaMovingAt.current < FROZEN_AFTER_MS) return;
|
|
606
|
+
if (frozen.current) return;
|
|
607
|
+
frozen.current = true;
|
|
608
|
+
// The picture has stopped, so the session is not live whatever the
|
|
609
|
+
// transport reports. When the transport has already said so, there
|
|
610
|
+
// is nothing to change: the recovery it started is the right one.
|
|
611
|
+
if (statusRef.current === 'live' || statusRef.current === 'connecting') onTransportState('disconnected', false, true);
|
|
612
|
+
}, () => undefined);
|
|
613
|
+
}, [armMedia, onTransportState]);
|
|
457
614
|
|
|
458
615
|
/**
|
|
459
616
|
* One stall check: quiet too long while the session should be talking
|
|
@@ -586,6 +743,13 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
586
743
|
pendingCursorEvents.current = [];
|
|
587
744
|
restartGraceUntil.current = 0;
|
|
588
745
|
restartCycles.current = 0;
|
|
746
|
+
// A new session has its own media path: the last one's counts and its
|
|
747
|
+
// freeze say nothing about this one.
|
|
748
|
+
if (mediaTimer.current !== null) clearTimeout(mediaTimer.current);
|
|
749
|
+
mediaTimer.current = null;
|
|
750
|
+
mediaRef.current = null;
|
|
751
|
+
mediaMovingAt.current = null;
|
|
752
|
+
frozen.current = false;
|
|
589
753
|
opened.current = null;
|
|
590
754
|
signaling.current = null;
|
|
591
755
|
setNativeId(null);
|
|
@@ -615,6 +779,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
615
779
|
cancelReconnect();
|
|
616
780
|
cancelRestart();
|
|
617
781
|
cancelStall();
|
|
782
|
+
cancelFirstFrame();
|
|
618
783
|
restoreToken.current = null;
|
|
619
784
|
const openedRef = opened.current;
|
|
620
785
|
const owner = signaling.current;
|
|
@@ -622,7 +787,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
622
787
|
if (closeRemote) await endRemote(openedRef, owner);
|
|
623
788
|
update({ status: 'ended', presented: false, failure: null });
|
|
624
789
|
void reason;
|
|
625
|
-
}, [cancelReconnect, cancelRestart, cancelStall, endRemote, discardSession, update]);
|
|
790
|
+
}, [cancelReconnect, cancelRestart, cancelStall, cancelFirstFrame, endRemote, discardSession, update]);
|
|
626
791
|
|
|
627
792
|
const establish = useCallback(async () => {
|
|
628
793
|
if (nativeRef.current != null) return;
|
|
@@ -739,6 +904,10 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
739
904
|
// reopen; the app's own grant wins when it names one.
|
|
740
905
|
restore_token: authorization.session.restoreToken ?? restoreToken.current ?? undefined,
|
|
741
906
|
ttl_seconds: authorization.session.ttlSeconds,
|
|
907
|
+
// A TCP-only path (an SSH tunnel, `adb reverse`) can only be
|
|
908
|
+
// reached on the engine's loopback, which the engine offers
|
|
909
|
+
// only when the app asks. Off by default, as on the wire.
|
|
910
|
+
...(authorization.session.loopbackTcp ? { loopback_tcp: true } : {}),
|
|
742
911
|
});
|
|
743
912
|
} catch (error) {
|
|
744
913
|
const message = error instanceof Error ? error.message : 'the desktop could not start';
|
|
@@ -753,6 +922,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
753
922
|
if (sample.sessionId === openedResult.sessionId) optionsRef.current.onCursor?.(sample);
|
|
754
923
|
}
|
|
755
924
|
update({ geometry: openedResult.geometry, status: 'connecting' });
|
|
925
|
+
armFirstFrame();
|
|
756
926
|
if (token !== generationToken.current) return;
|
|
757
927
|
|
|
758
928
|
const platform = nativeDesklink;
|
|
@@ -796,7 +966,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
796
966
|
await endRemote(openedResult, authorization.signaling);
|
|
797
967
|
}
|
|
798
968
|
}
|
|
799
|
-
}, [discardSession, endRemote, refuse, teardown, transportFailed, update, onTransportState]);
|
|
969
|
+
}, [armFirstFrame, discardSession, endRemote, refuse, teardown, transportFailed, update, onTransportState]);
|
|
800
970
|
|
|
801
971
|
/**
|
|
802
972
|
* Open (or reopen) the session with fresh authority. A deliberate call
|
|
@@ -857,7 +1027,11 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
857
1027
|
restartAttempts.current = 0;
|
|
858
1028
|
restartCycles.current = 0;
|
|
859
1029
|
cancelRestart();
|
|
1030
|
+
cancelFirstFrame();
|
|
860
1031
|
update({ status: 'live', presented: true, failure: null });
|
|
1032
|
+
// A first frame is a picture, so from here the client
|
|
1033
|
+
// watches that the picture keeps coming.
|
|
1034
|
+
armMedia();
|
|
861
1035
|
return;
|
|
862
1036
|
case 'control': {
|
|
863
1037
|
const reply = parseControlReply(String(event.payload.message ?? ''));
|
|
@@ -887,6 +1061,14 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
887
1061
|
return;
|
|
888
1062
|
}
|
|
889
1063
|
if (reply.kind === 'rejected') {
|
|
1064
|
+
// These session-wide clipboard refusals have no request ID.
|
|
1065
|
+
// A generic permission rejection can belong to other input.
|
|
1066
|
+
if (reply.seq === 0 && (reply.code === 'clipboard-unsupported' || reply.code === 'session')) {
|
|
1067
|
+
for (const resolve of pendingClipboard.current.values()) {
|
|
1068
|
+
resolve({ text: '', truncated: false, error: reply.message });
|
|
1069
|
+
}
|
|
1070
|
+
pendingClipboard.current.clear();
|
|
1071
|
+
}
|
|
890
1072
|
diagnostics.current.lastRejection = `${reply.code}: ${reply.message}`;
|
|
891
1073
|
update({ diagnostics: { ...diagnostics.current } });
|
|
892
1074
|
optionsRef.current.onRejected?.({ code: reply.code, message: reply.message });
|
|
@@ -935,9 +1117,9 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
935
1117
|
dropped = true;
|
|
936
1118
|
subscription?.remove();
|
|
937
1119
|
};
|
|
938
|
-
}, [transportFailed, onTransportState, cancelRestart, spendRestartAttempt, notePing, teardown, update, pressKey, typeText]);
|
|
1120
|
+
}, [transportFailed, onTransportState, cancelRestart, spendRestartAttempt, notePing, teardown, update, pressKey, typeText, armMedia]);
|
|
939
1121
|
|
|
940
|
-
const copyRemoteToLocal = useCallback(async () => {
|
|
1122
|
+
const copyRemoteToLocal = useCallback(async (writeLocal: (text: string) => Promise<void>) => {
|
|
941
1123
|
if (!inputEnabled.current) throw new Error('Desktop control is off.');
|
|
942
1124
|
const id = nativeRef.current;
|
|
943
1125
|
if (id == null || nativeDesklink == null) throw new Error('No desktop session is open.');
|
|
@@ -951,6 +1133,8 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
951
1133
|
nativeDesklink.sendControl(id, JSON.stringify({ kind: 'clipboard_read', request }));
|
|
952
1134
|
const reply = await answer;
|
|
953
1135
|
if (reply.error != null) throw new Error(reply.error);
|
|
1136
|
+
await writeLocal(reply.text);
|
|
1137
|
+
setClipboard({ id: ++clipboardCount.current, direction: 'to-phone', text: reply.text, truncated: reply.truncated });
|
|
954
1138
|
return { text: reply.text, truncated: reply.truncated };
|
|
955
1139
|
}, []);
|
|
956
1140
|
|
|
@@ -968,6 +1152,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
968
1152
|
nativeDesklink.sendControl(id, JSON.stringify({ kind: 'clipboard_write', request, text }));
|
|
969
1153
|
const reply = await answer;
|
|
970
1154
|
if (reply.error != null) throw new Error(reply.error);
|
|
1155
|
+
setClipboard({ id: ++clipboardCount.current, direction: 'to-desktop', text, truncated: false });
|
|
971
1156
|
}, []);
|
|
972
1157
|
|
|
973
1158
|
const close = useCallback(async (reason = 'closed by the user') => {
|
|
@@ -1008,6 +1193,9 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
1008
1193
|
cancelReconnect();
|
|
1009
1194
|
cancelRestart();
|
|
1010
1195
|
cancelStall();
|
|
1196
|
+
cancelFirstFrame();
|
|
1197
|
+
if (mediaTimer.current !== null) clearTimeout(mediaTimer.current);
|
|
1198
|
+
mediaTimer.current = null;
|
|
1011
1199
|
generationToken.current += 1;
|
|
1012
1200
|
const id = nativeRef.current;
|
|
1013
1201
|
nativeRef.current = null;
|
|
@@ -1086,6 +1274,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
1086
1274
|
modifiers,
|
|
1087
1275
|
tapModifier,
|
|
1088
1276
|
pressKey,
|
|
1277
|
+
clipboard,
|
|
1089
1278
|
}), [
|
|
1090
1279
|
snapshot,
|
|
1091
1280
|
nativeId,
|
|
@@ -1104,6 +1293,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
1104
1293
|
modifiers,
|
|
1105
1294
|
tapModifier,
|
|
1106
1295
|
pressKey,
|
|
1296
|
+
clipboard,
|
|
1107
1297
|
]);
|
|
1108
1298
|
}
|
|
1109
1299
|
|