@desklink/react-native 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -8
- 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 +18 -2
- 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 +2 -1
- package/src/useDesktopSession.ts +28 -3
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();
|
|
@@ -135,7 +142,9 @@ heat and battery.
|
|
|
135
142
|
content; pinch to zoom before panning when the whole desktop already fits.
|
|
136
143
|
Pass the screen's safe-area `insets` (for example from
|
|
137
144
|
`react-native-safe-area-context`) so the default starts below the status bar
|
|
138
|
-
and beside a camera cutout.
|
|
145
|
+
and beside a camera cutout. `onPictureFrame` reports where the picture
|
|
146
|
+
ended whenever it moves, so a tall screen's band below it can carry the
|
|
147
|
+
application's own controls instead of empty space. Panning brings each desktop edge inside the safe
|
|
139
148
|
area, while landscape coverage extends beneath the cutout and home indicator
|
|
140
149
|
to limit black bars. A pinch zooms up to 2.5 view
|
|
141
150
|
pixels per desktop pixel, or out to the whole desktop. On Android the decoded frame is copied once into the view's
|
|
@@ -191,7 +200,15 @@ heat and battery.
|
|
|
191
200
|
- **The keys a phone lacks.** `modifiers`, `tapModifier` and `pressKey` give
|
|
192
201
|
sticky Ctrl and Shift: tap arms one for the next key, tap again locks it.
|
|
193
202
|
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.
|
|
203
|
+
sent as that key's chord, so Ctrl then v is Ctrl+V. `modifiers` holds each
|
|
204
|
+
key's state (`off`, `once` for latched, `lock`) for the app to read.
|
|
205
|
+
`<ModifierKeys modifiers={desktop.modifiers} onTap={desktop.tapModifier} />`
|
|
206
|
+
draws them as a backed key row: off is an outlined key, latched a filled
|
|
207
|
+
one, locked a filled one with a white rim and a bar under its label, and a
|
|
208
|
+
screen reader hears off, latched or locked. Its `grow` prop makes it a
|
|
209
|
+
tablet's row: the keys take the width and the height the row is given
|
|
210
|
+
instead of sitting at 44 pt. An app may draw its own keys
|
|
211
|
+
from `modifiers` instead.
|
|
195
212
|
- **Readiness is a rendered frame.** Android marks the first draw; iOS marks
|
|
196
213
|
the first `RTCView` video-dimensions callback and ignores later ones, so a
|
|
197
214
|
resize or remount during a reconnect does not read as live. Neither marks
|
|
@@ -240,6 +257,15 @@ heat and battery.
|
|
|
240
257
|
session close and a lost control channel release what the desktop was holding.
|
|
241
258
|
- **Explicit clipboard.** Two methods, called on a user action while control is
|
|
242
259
|
enabled. The package never polls the clipboard or uses it as a way to type.
|
|
260
|
+
`copyRemoteToLocal(writeLocal)` awaits the caller-owned local writer, which
|
|
261
|
+
must reject on failure or refusal, before recording completion. The returned
|
|
262
|
+
text and truncation flag describe what was written. `pasteLocalToRemote(text)`
|
|
263
|
+
records completion only after the desktop acknowledges the write.
|
|
264
|
+
`clipboard` is the last transfer that completed, and
|
|
265
|
+
`<ClipboardConfirmation transfer={desktop.clipboard} />` confirms each one
|
|
266
|
+
with a backed pill — where it went and the start of the text —
|
|
267
|
+
that ignores touches and leaves after 1.5 s. Place it in a reserved area
|
|
268
|
+
outside `DesktopView`, keeping that area present when the pill is dismissed.
|
|
243
269
|
|
|
244
270
|
### `Signaling`
|
|
245
271
|
|
|
@@ -308,28 +334,44 @@ bridge, and checks that the first frame is presented, that the answer puts
|
|
|
308
334
|
H.264 first with NACK when the host offers it (VP9 otherwise; both descriptions
|
|
309
335
|
are written out), that a tap clicks the host, that
|
|
310
336
|
the picture keeps following the desktop afterwards, and that hardware keys
|
|
311
|
-
pressed on the simulator reach the desktop.
|
|
337
|
+
pressed on the simulator reach the desktop. It then drives the whole
|
|
338
|
+
**phone-only control** journey from the phone's own screen, with no input sent
|
|
339
|
+
to the desktop from this machine: a click, a drag that holds the button and
|
|
340
|
+
selects, a two-finger scroll, typed text, a sticky modifier that chords the next
|
|
341
|
+
key and clears, the clipboard in both directions, and the drawn pointer mark's
|
|
342
|
+
shape and tip. Every step has its own capture, the journey is recorded as one
|
|
343
|
+
screen recording on the simulator, and the phone's own screen is read to prove it
|
|
344
|
+
carries no diagnostic text.
|
|
345
|
+
With `DESKLINK_IOS_IPAD=1` it runs on
|
|
312
346
|
an iPad simulator and also drives a trackpad through XCUITest
|
|
313
347
|
(`test/ios-pointer/`): hover, a click-drag that selects text, a right click and
|
|
314
348
|
a scroll:
|
|
315
349
|
|
|
316
350
|
```sh
|
|
317
351
|
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
|
|
352
|
+
env -u DISPLAY -u WAYLAND_DISPLAY DESKLINK_IOS_MAC=user@mac \
|
|
353
|
+
DESKLINK_IOS_DEVICE='fm-iPhone 17 Pro' npm run test:ios --workspace @desklink/react-native
|
|
319
354
|
```
|
|
320
355
|
|
|
321
356
|
The Mac needs Xcode with an iOS simulator runtime, CocoaPods, node and
|
|
322
357
|
[`axe`](https://github.com/cameroncooke/AXe) on its login `PATH`; everything
|
|
323
358
|
the run builds or caches stays in `~/desklink-ios` there. The run creates a
|
|
324
|
-
uniquely named simulator and deletes it during cleanup.
|
|
359
|
+
uniquely named simulator and deletes it during cleanup. Set
|
|
360
|
+
`DESKLINK_IOS_DEVICE` to the name or udid of one of the Mac's own simulators to
|
|
361
|
+
run on that instead and leave it in place. For host desktop isolation, see the
|
|
325
362
|
[private-lab guidance](../../README.md#develop). When the selected
|
|
326
363
|
Xcode has no simulator platform, point `DESKLINK_IOS_XCODE` at one that does;
|
|
327
364
|
`DESKLINK_IOS_RUNTIME` picks the iOS version (for example `18.6`); the
|
|
328
365
|
script's header lists the other settings. To capture the cursor tip proof, set
|
|
329
366
|
`DESKLINK_IOS_POINTER_PROOF=before` or `after`; the run writes a 4x crop, device
|
|
330
367
|
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`.
|
|
368
|
+
`DESKLINK_IOS_APP` to reuse an app bundle under the lane's directory when
|
|
369
|
+
running with `DESKLINK_IOS_SKIP_BUILD=1`. `DESKLINK_IOS_DIR` is the shared Mac
|
|
370
|
+
lab root (`fm-desklink-ios`) and `DESKLINK_IOS_LANE` names this lane's own
|
|
371
|
+
subdirectory within it; the run deletes that subdirectory and nothing else when
|
|
372
|
+
it finishes, so two lanes can build at once without one cleanup deleting the
|
|
373
|
+
other's build. Set `DESKLINK_IOS_KEEP=1` to keep it. Run once with `before`
|
|
374
|
+
before the change and
|
|
333
375
|
once with `after` after it, on both iPhone and iPad simulators, to compare the
|
|
334
376
|
pointer crop and verify the host click is within two desktop pixels of the tip.
|
|
335
377
|
To try the example by hand against any bridge:
|
|
@@ -344,7 +386,9 @@ link whose `url` query parameter is the URL-encoded bridge WebSocket address:
|
|
|
344
386
|
The example allows the bridge's plaintext private-network connection; consumer
|
|
345
387
|
apps choose their own transport policy. A new link replaces the open session.
|
|
346
388
|
The live picture has no diagnostic overlay. Its accessibility status remains
|
|
347
|
-
available as `desklink-status`.
|
|
389
|
+
available as `desklink-status`. The key row (`Ctrl`, `Shift`, `Alt`, `Meta`) and
|
|
390
|
+
the `Copy` and `Paste` buttons are the app's own chrome over the picture, and a
|
|
391
|
+
clipboard transfer is confirmed by a pill above them.
|
|
348
392
|
|
|
349
393
|
For device evidence, an optional `report` query parameter accepts a URL-encoded
|
|
350
394
|
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,7 +67,7 @@ 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 });
|
|
@@ -169,6 +169,16 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
|
|
|
169
169
|
const shown = clampOffset(offset.x, offset.y);
|
|
170
170
|
const originX = left + (safeWidth - pictureWidth) / 2 + shown.x;
|
|
171
171
|
const originY = top + (zoom === null && pictureHeight <= visibleHeight ? 0 : (visibleHeight - pictureHeight) / 2) + shown.y;
|
|
172
|
+
// An application's controls can sit under the picture instead of over it,
|
|
173
|
+
// so it needs to know where the picture ended. Reported only when the
|
|
174
|
+
// picture moved: the callback sets state, and a fresh callback each render
|
|
175
|
+
// would otherwise loop.
|
|
176
|
+
const frame = React.useRef({ top: -1, height: -1 });
|
|
177
|
+
React.useEffect(() => {
|
|
178
|
+
if (frame.current.top === originY && frame.current.height === pictureHeight) return;
|
|
179
|
+
frame.current = { top: originY, height: pictureHeight };
|
|
180
|
+
onPictureFrame?.(frame.current);
|
|
181
|
+
}, [onPictureFrame, originY, pictureHeight]);
|
|
172
182
|
const send = (control: Record<string, unknown>) => { if (sessionId) nativeDesklink.sendControl(sessionId, JSON.stringify(control)); };
|
|
173
183
|
const flushWheel = (force: boolean) => {
|
|
174
184
|
const { x, y } = wheel.current;
|
|
@@ -287,7 +297,13 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
|
|
|
287
297
|
g.mode = 'scroll';
|
|
288
298
|
const under = point(at.x, at.y);
|
|
289
299
|
if (under) pointer('move', under);
|
|
290
|
-
} else
|
|
300
|
+
} else {
|
|
301
|
+
// A whole desktop has nowhere to move to, so the finger moves the
|
|
302
|
+
// desktop's pointer instead. It is the zoom over the fit, not the
|
|
303
|
+
// default fill, that says there is room to pan: a fill that landed
|
|
304
|
+
// on the fit has none.
|
|
305
|
+
g.mode = zoomed > 1.001 ? 'pan' : 'hover';
|
|
306
|
+
}
|
|
291
307
|
}
|
|
292
308
|
if (g.mode === 'scroll') {
|
|
293
309
|
// Content follows the finger: moving it up scrolls the page down.
|
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
|
@@ -59,7 +59,7 @@ export interface SessionOpenRequest {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
export interface SessionOpenResult {
|
|
62
|
-
cursor?: { mode: 'embedded' | 'hidden' | 'metadata' | 'unavailable'; positions: boolean };
|
|
62
|
+
cursor?: { mode: 'embedded' | 'hidden' | 'metadata' | 'unavailable'; positions: boolean; source?: 'ext-image-copy-capture-v1'; limitation?: string };
|
|
63
63
|
sessionId: string;
|
|
64
64
|
generation: number;
|
|
65
65
|
source: { kind: string; width: number; height: number; origin: { x: number; y: number } };
|
|
@@ -83,6 +83,7 @@ export interface CursorSample {
|
|
|
83
83
|
y: number;
|
|
84
84
|
visible: boolean;
|
|
85
85
|
timestamp_us: number;
|
|
86
|
+
hotspot?: { x: number; y: number };
|
|
86
87
|
}
|
|
87
88
|
|
|
88
89
|
/**
|
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
|
|
|
@@ -199,8 +207,8 @@ export interface DesktopSession {
|
|
|
199
207
|
close: (reason?: string) => Promise<void>;
|
|
200
208
|
showKeyboard: () => void;
|
|
201
209
|
hideKeyboard: () => void;
|
|
202
|
-
/** Copy the
|
|
203
|
-
copyRemoteToLocal: () => Promise<{ text: string; truncated: boolean }>;
|
|
210
|
+
/** Copy using the caller's local writer, which must reject if the write fails. */
|
|
211
|
+
copyRemoteToLocal: (writeLocal: (text: string) => Promise<void>) => Promise<{ text: string; truncated: boolean }>;
|
|
204
212
|
/** Send the phone's clipboard text to the desktop. */
|
|
205
213
|
pasteLocalToRemote: (text: string) => Promise<void>;
|
|
206
214
|
/** Release anything the desktop is holding, without ending the session. */
|
|
@@ -221,6 +229,8 @@ export interface DesktopSession {
|
|
|
221
229
|
* because the engine lets go of exactly the modifiers the message names.
|
|
222
230
|
*/
|
|
223
231
|
pressKey: (name: string) => () => void;
|
|
232
|
+
/** The last clipboard transfer that completed, for the app to confirm; null until one does. */
|
|
233
|
+
clipboard: ClipboardTransfer | null;
|
|
224
234
|
}
|
|
225
235
|
|
|
226
236
|
const IDLE: SessionSnapshot = {
|
|
@@ -245,6 +255,8 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
245
255
|
const [modifiers, setModifiers] = useState<StickyModifiers>(NO_MODIFIERS);
|
|
246
256
|
/** Read by input as it arrives, which can be faster than a render. */
|
|
247
257
|
const modifiersRef = useRef<StickyModifiers>(NO_MODIFIERS);
|
|
258
|
+
const [clipboard, setClipboard] = useState<ClipboardTransfer | null>(null);
|
|
259
|
+
const clipboardCount = useRef(0);
|
|
248
260
|
|
|
249
261
|
const opened = useRef<SessionOpenResult | null>(null);
|
|
250
262
|
const signaling = useRef<Signaling | null>(null);
|
|
@@ -887,6 +899,14 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
887
899
|
return;
|
|
888
900
|
}
|
|
889
901
|
if (reply.kind === 'rejected') {
|
|
902
|
+
// These session-wide clipboard refusals have no request ID.
|
|
903
|
+
// A generic permission rejection can belong to other input.
|
|
904
|
+
if (reply.seq === 0 && (reply.code === 'clipboard-unsupported' || reply.code === 'session')) {
|
|
905
|
+
for (const resolve of pendingClipboard.current.values()) {
|
|
906
|
+
resolve({ text: '', truncated: false, error: reply.message });
|
|
907
|
+
}
|
|
908
|
+
pendingClipboard.current.clear();
|
|
909
|
+
}
|
|
890
910
|
diagnostics.current.lastRejection = `${reply.code}: ${reply.message}`;
|
|
891
911
|
update({ diagnostics: { ...diagnostics.current } });
|
|
892
912
|
optionsRef.current.onRejected?.({ code: reply.code, message: reply.message });
|
|
@@ -937,7 +957,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
937
957
|
};
|
|
938
958
|
}, [transportFailed, onTransportState, cancelRestart, spendRestartAttempt, notePing, teardown, update, pressKey, typeText]);
|
|
939
959
|
|
|
940
|
-
const copyRemoteToLocal = useCallback(async () => {
|
|
960
|
+
const copyRemoteToLocal = useCallback(async (writeLocal: (text: string) => Promise<void>) => {
|
|
941
961
|
if (!inputEnabled.current) throw new Error('Desktop control is off.');
|
|
942
962
|
const id = nativeRef.current;
|
|
943
963
|
if (id == null || nativeDesklink == null) throw new Error('No desktop session is open.');
|
|
@@ -951,6 +971,8 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
951
971
|
nativeDesklink.sendControl(id, JSON.stringify({ kind: 'clipboard_read', request }));
|
|
952
972
|
const reply = await answer;
|
|
953
973
|
if (reply.error != null) throw new Error(reply.error);
|
|
974
|
+
await writeLocal(reply.text);
|
|
975
|
+
setClipboard({ id: ++clipboardCount.current, direction: 'to-phone', text: reply.text, truncated: reply.truncated });
|
|
954
976
|
return { text: reply.text, truncated: reply.truncated };
|
|
955
977
|
}, []);
|
|
956
978
|
|
|
@@ -968,6 +990,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
968
990
|
nativeDesklink.sendControl(id, JSON.stringify({ kind: 'clipboard_write', request, text }));
|
|
969
991
|
const reply = await answer;
|
|
970
992
|
if (reply.error != null) throw new Error(reply.error);
|
|
993
|
+
setClipboard({ id: ++clipboardCount.current, direction: 'to-desktop', text, truncated: false });
|
|
971
994
|
}, []);
|
|
972
995
|
|
|
973
996
|
const close = useCallback(async (reason = 'closed by the user') => {
|
|
@@ -1086,6 +1109,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
1086
1109
|
modifiers,
|
|
1087
1110
|
tapModifier,
|
|
1088
1111
|
pressKey,
|
|
1112
|
+
clipboard,
|
|
1089
1113
|
}), [
|
|
1090
1114
|
snapshot,
|
|
1091
1115
|
nativeId,
|
|
@@ -1104,6 +1128,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
1104
1128
|
modifiers,
|
|
1105
1129
|
tapModifier,
|
|
1106
1130
|
pressKey,
|
|
1131
|
+
clipboard,
|
|
1107
1132
|
]);
|
|
1108
1133
|
}
|
|
1109
1134
|
|