@desklink/react-native 0.3.0 → 0.3.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 +18 -8
- package/package.json +1 -1
- package/src/DesktopView.ios.spec.tsx +13 -3
- package/src/DesktopView.ios.tsx +4 -3
- package/src/pointer.png +0 -0
- package/src/pointer.png.d.ts +2 -0
- package/src/pointer.svg +7 -0
- package/src/pointer@2x.png +0 -0
- package/src/pointer@3x.png +0 -0
- package/src/protocol.ts +11 -0
- package/src/useDesktopSession.ts +16 -0
package/README.md
CHANGED
|
@@ -130,11 +130,12 @@ heat and battery.
|
|
|
130
130
|
stamps every frame alike, so the iOS peer declines that hint and keeps the
|
|
131
131
|
receiver's usual jitter buffer. `fitToView()` shows the whole desktop again.
|
|
132
132
|
- **A pointer a phone can see.** Once a touch has sent the desktop's pointer
|
|
133
|
-
somewhere, the view draws
|
|
134
|
-
own cursor is a few pixels tall or not captured at all.
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
133
|
+
somewhere, the view draws an outlined arrow there at a readable size, over a
|
|
134
|
+
picture whose own cursor is a few pixels tall or not captured at all. On iOS,
|
|
135
|
+
the arrow tip marks the desktop pointer position. Android hides the extra
|
|
136
|
+
mark when a mouse is attached, iOS when an iPad trackpad or mouse drives it,
|
|
137
|
+
and the `device` profile draws no mark on any platform — the screen being
|
|
138
|
+
touched shows the finger itself.
|
|
138
139
|
- **The picture above the keyboard.** While the phone's keyboard is up, the
|
|
139
140
|
picture sits above it and above the room the app keeps for its own controls
|
|
140
141
|
(`keyboardClearance`). Android follows the keyboard animation and keeps the
|
|
@@ -290,15 +291,24 @@ a scroll:
|
|
|
290
291
|
|
|
291
292
|
```sh
|
|
292
293
|
npm run build && cargo build --manifest-path packages/desktop-host/engine/Cargo.toml
|
|
293
|
-
DESKLINK_IOS_MAC=user@mac npm run test:ios --workspace @desklink/react-native
|
|
294
|
+
env -u DISPLAY -u WAYLAND_DISPLAY DESKLINK_IOS_MAC=user@mac npm run test:ios --workspace @desklink/react-native
|
|
294
295
|
```
|
|
295
296
|
|
|
296
297
|
The Mac needs Xcode with an iOS simulator runtime, CocoaPods, node and
|
|
297
298
|
[`axe`](https://github.com/cameroncooke/AXe) on its login `PATH`; everything
|
|
298
|
-
the run builds or caches stays in `~/desklink-ios` there.
|
|
299
|
+
the run builds or caches stays in `~/desklink-ios` there. The run creates a
|
|
300
|
+
uniquely named simulator and deletes it during cleanup. For host desktop isolation, see the
|
|
301
|
+
[private-lab guidance](../../README.md#develop). When the selected
|
|
299
302
|
Xcode has no simulator platform, point `DESKLINK_IOS_XCODE` at one that does;
|
|
300
303
|
`DESKLINK_IOS_RUNTIME` picks the iOS version (for example `18.6`); the
|
|
301
|
-
script's header lists the other settings. To
|
|
304
|
+
script's header lists the other settings. To capture the cursor tip proof, set
|
|
305
|
+
`DESKLINK_IOS_POINTER_PROOF=before` or `after`; the run writes a 4x crop, device
|
|
306
|
+
frame, proof metadata, and host click log under `DESKLINK_IOS_OUT`. Set
|
|
307
|
+
`DESKLINK_IOS_APP` to reuse an app bundle under `DESKLINK_IOS_DIR` when running
|
|
308
|
+
with `DESKLINK_IOS_SKIP_BUILD=1`. Run once with `before` before the change and
|
|
309
|
+
once with `after` after it, on both iPhone and iPad simulators, to compare the
|
|
310
|
+
pointer crop and verify the host click is within two desktop pixels of the tip.
|
|
311
|
+
To try the example by hand against any bridge:
|
|
302
312
|
|
|
303
313
|
```sh
|
|
304
314
|
xcrun simctl launch <device> dev.desklink.example -desklinkUrl 'ws://HOST:PORT/desktop?token=TOKEN'
|
package/package.json
CHANGED
|
@@ -18,6 +18,7 @@ vi.mock('react-native', () => ({
|
|
|
18
18
|
Keyboard: { addListener: () => ({ remove: () => undefined }), dismiss: () => undefined },
|
|
19
19
|
StyleSheet: { create: (styles: unknown) => styles },
|
|
20
20
|
TextInput: 'TextInput',
|
|
21
|
+
Image: 'Image',
|
|
21
22
|
View: 'View',
|
|
22
23
|
}));
|
|
23
24
|
|
|
@@ -204,15 +205,24 @@ describe('DesktopView.ios parity with DesktopView.kt', () => {
|
|
|
204
205
|
});
|
|
205
206
|
|
|
206
207
|
it('puts the cursor mark tip on the pointer with a hotspot', async () => {
|
|
207
|
-
const { renderer, grant, move } = await openView();
|
|
208
|
+
const { renderer, sent, grant, move, release } = await openView();
|
|
208
209
|
grant([touch(195, 420)]);
|
|
209
210
|
move([touch(250, 430)]);
|
|
210
|
-
const
|
|
211
|
+
const image = renderer.root.findByType('Image');
|
|
212
|
+
const cursor = image.props.style as unknown[];
|
|
211
213
|
const mark = cursor[1] as { left: number; top: number };
|
|
212
214
|
const desktop = { x: Math.floor(250 / SCALE), y: Math.floor((430 - ORIGIN_Y) / SCALE) };
|
|
213
215
|
expect(mark.left).toBeCloseTo((desktop.x + 0.5) * SCALE - 3, 1);
|
|
214
216
|
expect(mark.top).toBeCloseTo(ORIGIN_Y + (desktop.y + 0.5) * SCALE - 3, 1);
|
|
215
|
-
expect(
|
|
217
|
+
expect(image.props.accessible).toBe(false);
|
|
218
|
+
expect(image.props.pointerEvents).toBe('none');
|
|
219
|
+
release();
|
|
220
|
+
grant([touch(mark.left + 3, mark.top + 3)]);
|
|
221
|
+
release();
|
|
222
|
+
expect(sent.filter((message) => message.kind === 'pointer').slice(-2)).toMatchObject([
|
|
223
|
+
{ kind: 'pointer', phase: 'down', ...desktop, button: 1 },
|
|
224
|
+
{ kind: 'pointer', phase: 'up', ...desktop, button: 1 },
|
|
225
|
+
]);
|
|
216
226
|
});
|
|
217
227
|
|
|
218
228
|
it('exposes the surface to VoiceOver only with a label', async () => {
|
package/src/DesktopView.ios.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
-
import { Keyboard, StyleSheet, TextInput, View, type GestureResponderEvent, type LayoutChangeEvent, type ViewProps } from 'react-native';
|
|
2
|
+
import { Image, Keyboard, StyleSheet, TextInput, View, type GestureResponderEvent, type LayoutChangeEvent, type ViewProps } from 'react-native';
|
|
3
|
+
import pointerArrow from './pointer.png';
|
|
3
4
|
import { requireNativeViewManager, requireOptionalNativeModule } from 'expo-modules-core';
|
|
4
5
|
import type { DesktopViewProps } from './DesktopView';
|
|
5
6
|
import { hardwareKey, type HardwareKey } from './hardwareKeys';
|
|
@@ -391,7 +392,7 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
|
|
|
391
392
|
{sessionId && stream && RTCView && size.width > 0 && size.height > 0 ?
|
|
392
393
|
<RTCView pointerEvents="none" streamURL={stream.toURL()} objectFit="contain" style={{ position: 'absolute', left: originX, top: originY, width: pictureWidth, height: pictureHeight }}
|
|
393
394
|
onDimensionsChange={(event) => { if (event.nativeEvent.width > 0 && event.nativeEvent.height > 0) markDesktopPresented(sessionId); }} /> : placeholder}
|
|
394
|
-
{cursor && <
|
|
395
|
+
{cursor && <Image source={pointerArrow} pointerEvents="none" accessible={false} style={[styles.cursor, { left: originX + (cursor.x + 0.5) * scale - CURSOR_HOTSPOT, top: originY + (cursor.y + 0.5) * scale - CURSOR_HOTSPOT }]} />}
|
|
395
396
|
<TextInput ref={input} style={styles.input} value={keyboardText} onChangeText={changeText} autoCorrect={false} autoCapitalize="none" submitBehavior="submit" accessible={false}
|
|
396
397
|
onSubmitEditing={() => {
|
|
397
398
|
// While the app holds a sticky modifier, Enter is the app's to
|
|
@@ -408,6 +409,6 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
|
|
|
408
409
|
|
|
409
410
|
const styles = StyleSheet.create({
|
|
410
411
|
surface: { backgroundColor: '#000', overflow: 'hidden' },
|
|
411
|
-
cursor: { position: 'absolute', width:
|
|
412
|
+
cursor: { position: 'absolute', width: 28, height: 28 },
|
|
412
413
|
input: { position: 'absolute', width: 1, height: 1, opacity: 0, left: 0, bottom: 0 },
|
|
413
414
|
});
|
package/src/pointer.png
ADDED
|
Binary file
|
package/src/pointer.svg
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<!-- Same outline and (3, 3) tip hotspot as web and Android. Generate the
|
|
2
|
+
iOS density assets with rsvg-convert -w SIZE -h SIZE pointer.svg -o FILE:
|
|
3
|
+
SIZE=28/56/84, FILE=pointer.png/pointer@2x.png/pointer@3x.png. -->
|
|
4
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28">
|
|
5
|
+
<path d="M3 3 L3 22.25 L7.73 17.96 L10.92 25.22 L14 23.9 L10.92 16.86 L17.08 16.86 Z" fill="none" stroke="#fff" stroke-width="2.2" stroke-linejoin="round"/>
|
|
6
|
+
<path d="M3 3 L3 22.25 L7.73 17.96 L10.92 25.22 L14 23.9 L10.92 16.86 L17.08 16.86 Z" fill="#000"/>
|
|
7
|
+
</svg>
|
|
Binary file
|
|
Binary file
|
package/src/protocol.ts
CHANGED
|
@@ -41,6 +41,7 @@ export interface SurfaceGeometry {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
export interface SessionOpenRequest {
|
|
44
|
+
cursor?: 'embedded' | 'hidden';
|
|
44
45
|
permissions: Permission[];
|
|
45
46
|
maxWidth?: number;
|
|
46
47
|
maxHeight?: number;
|
|
@@ -52,6 +53,7 @@ export interface SessionOpenRequest {
|
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
export interface SessionOpenResult {
|
|
56
|
+
cursor?: { mode: 'embedded' | 'hidden' | 'metadata' | 'unavailable'; positions: boolean };
|
|
55
57
|
sessionId: string;
|
|
56
58
|
generation: number;
|
|
57
59
|
source: { kind: string; width: number; height: number; origin: { x: number; y: number } };
|
|
@@ -69,6 +71,14 @@ export interface RtcCandidate {
|
|
|
69
71
|
sdpMLineIndex?: number | null;
|
|
70
72
|
}
|
|
71
73
|
|
|
74
|
+
export interface CursorSample {
|
|
75
|
+
sessionId: string;
|
|
76
|
+
x: number;
|
|
77
|
+
y: number;
|
|
78
|
+
visible: boolean;
|
|
79
|
+
timestamp_us: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
72
82
|
/**
|
|
73
83
|
* Engine → client lifecycle events, already unwrapped from the local protocol.
|
|
74
84
|
*
|
|
@@ -81,6 +91,7 @@ export interface RtcCandidate {
|
|
|
81
91
|
export type SessionEvent =
|
|
82
92
|
| { kind: 'description'; description: RtcDescription; sessionId?: string }
|
|
83
93
|
| { kind: 'candidate'; candidate: RtcCandidate; sessionId?: string }
|
|
94
|
+
| ({ kind: 'cursor' } & CursorSample)
|
|
84
95
|
| { kind: 'state'; capture: string; transport: string; firstFrame: boolean; sessionId?: string }
|
|
85
96
|
| { kind: 'restoreToken'; token: string; sessionId?: string }
|
|
86
97
|
/**
|
package/src/useDesktopSession.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { AppState } from 'react-native';
|
|
|
4
4
|
import type { NativeDesklinkModule } from './native';
|
|
5
5
|
import type {
|
|
6
6
|
ControlMessage,
|
|
7
|
+
CursorSample,
|
|
7
8
|
IceServerConfig,
|
|
8
9
|
Permission,
|
|
9
10
|
SessionFailure,
|
|
@@ -178,6 +179,7 @@ export interface DesktopSessionOptions {
|
|
|
178
179
|
*/
|
|
179
180
|
authorize: () => Promise<{ signaling: Signaling; session: SessionOpenRequest }>;
|
|
180
181
|
onStateChange?: (snapshot: SessionSnapshot) => void;
|
|
182
|
+
onCursor?: (sample: CursorSample) => void;
|
|
181
183
|
onError?: (failure: SessionFailure) => void;
|
|
182
184
|
/**
|
|
183
185
|
* The engine refused one input message and the session carries on, such as
|
|
@@ -253,6 +255,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
253
255
|
* before the native session exists waits here and is replayed into it.
|
|
254
256
|
*/
|
|
255
257
|
const pendingOffer = useRef<{ sdp: string; sessionId?: string } | null>(null);
|
|
258
|
+
const pendingCursorEvents = useRef<CursorSample[]>([]);
|
|
256
259
|
const pendingCandidates = useRef<Array<{ candidate: string; sdpMid: string | null; sdpMLineIndex: number | null; sessionId?: string }>>([]);
|
|
257
260
|
const attempts = useRef(0);
|
|
258
261
|
/**
|
|
@@ -572,6 +575,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
572
575
|
pendingClipboard.current.clear();
|
|
573
576
|
pendingOffer.current = null;
|
|
574
577
|
pendingCandidates.current = [];
|
|
578
|
+
pendingCursorEvents.current = [];
|
|
575
579
|
restartGraceUntil.current = 0;
|
|
576
580
|
restartCycles.current = 0;
|
|
577
581
|
opened.current = null;
|
|
@@ -615,6 +619,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
615
619
|
const establish = useCallback(async () => {
|
|
616
620
|
if (nativeRef.current != null) return;
|
|
617
621
|
const token = ++generationToken.current;
|
|
622
|
+
pendingCursorEvents.current = [];
|
|
618
623
|
if (!(await loadPlatform())) {
|
|
619
624
|
if (token === generationToken.current) refuse('this build cannot show a desktop surface');
|
|
620
625
|
return;
|
|
@@ -672,6 +677,13 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
672
677
|
event.candidate.sdpMLineIndex ?? null,
|
|
673
678
|
);
|
|
674
679
|
return;
|
|
680
|
+
case 'cursor':
|
|
681
|
+
if (held == null) {
|
|
682
|
+
pendingCursorEvents.current.push(event);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
if (current) optionsRef.current.onCursor?.(event);
|
|
686
|
+
return;
|
|
675
687
|
case 'state':
|
|
676
688
|
if (!current) return;
|
|
677
689
|
diagnostics.current.transport = event.transport;
|
|
@@ -707,6 +719,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
707
719
|
openedResult = await authorization.signaling.request<SessionOpenResult>('session.open', {
|
|
708
720
|
permissions: authorization.session.permissions,
|
|
709
721
|
max_width: authorization.session.maxWidth,
|
|
722
|
+
...(authorization.session.cursor === undefined ? {} : { cursor: authorization.session.cursor }),
|
|
710
723
|
max_height: authorization.session.maxHeight,
|
|
711
724
|
bitrate_kbps: authorization.session.bitrateKbps,
|
|
712
725
|
max_fps: authorization.session.maxFps,
|
|
@@ -725,6 +738,9 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
725
738
|
try {
|
|
726
739
|
if (token !== generationToken.current) return;
|
|
727
740
|
opened.current = openedResult;
|
|
741
|
+
for (const sample of pendingCursorEvents.current.splice(0)) {
|
|
742
|
+
if (sample.sessionId === openedResult.sessionId) optionsRef.current.onCursor?.(sample);
|
|
743
|
+
}
|
|
728
744
|
update({ geometry: openedResult.geometry, status: 'connecting' });
|
|
729
745
|
if (token !== generationToken.current) return;
|
|
730
746
|
|