@desklink/react-native 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,209 @@
1
+ import ExpoModulesCore
2
+ import UIKit
3
+
4
+ /**
5
+ * The desktop surface on iOS: the view around the picture and the hidden text
6
+ * field, reporting the input React Native's touch system never sees.
7
+ *
8
+ * Keys: while control is enabled this view is the first responder, so a
9
+ * hardware keyboard's presses arrive here with their down and up — arrows,
10
+ * Esc, Tab and Command chords a text field does not report. The hidden text
11
+ * field takes over while the app shows the on-screen keyboard; keys it does not
12
+ * use still bubble up to this view, and the view takes the keyboard back when
13
+ * the field lets go.
14
+ *
15
+ * Pointer: an iPad trackpad or mouse hovering the view and its scroll. Its
16
+ * presses arrive with the button that made them only once the app sets
17
+ * `UIApplicationSupportsIndirectInputEvents` (the package's config plugin);
18
+ * until then UIKit hands them to React Native as touches.
19
+ */
20
+ final class DesklinkInputView: ExpoView {
21
+ let onKey = EventDispatcher()
22
+ let onPointer = EventDispatcher()
23
+ let onWheel = EventDispatcher()
24
+
25
+ var enabled = false {
26
+ didSet {
27
+ guard enabled != oldValue else { return }
28
+ if enabled {
29
+ takeKeyboard(fromTouch: false)
30
+ } else {
31
+ releaseKeys()
32
+ if isFirstResponder { resignFirstResponder() }
33
+ }
34
+ }
35
+ }
36
+
37
+ /** Keys pressed here and not yet released, so none stays down on the desktop. */
38
+ private var held: [UIKeyboardHIDUsage: UIKey] = [:]
39
+
40
+ required init(appContext: AppContext? = nil) {
41
+ super.init(appContext: appContext)
42
+ addGestureRecognizer(UIHoverGestureRecognizer(target: self, action: #selector(hovered(_:))))
43
+ // A trackpad's or wheel's scroll only: touches stay with React Native.
44
+ let scroll = UIPanGestureRecognizer(target: self, action: #selector(scrolled(_:)))
45
+ scroll.allowedScrollTypesMask = .all
46
+ scroll.allowedTouchTypes = []
47
+ addGestureRecognizer(scroll)
48
+ NotificationCenter.default.addObserver(
49
+ self, selector: #selector(editingEnded(_:)), name: UITextField.textDidEndEditingNotification, object: nil)
50
+ }
51
+
52
+ // MARK: - Keyboard focus
53
+
54
+ override var canBecomeFirstResponder: Bool { enabled }
55
+
56
+ override func didMoveToWindow() {
57
+ super.didMoveToWindow()
58
+ if window == nil { releaseKeys() } else { takeKeyboard(fromTouch: false) }
59
+ }
60
+
61
+ override func resignFirstResponder() -> Bool {
62
+ let resigned = super.resignFirstResponder()
63
+ if resigned { releaseKeys() }
64
+ return resigned
65
+ }
66
+
67
+ /**
68
+ * Become the keyboard's target. On its own the view never takes the keyboard
69
+ * from a responder that has it — another field of the app, say. A touch on
70
+ * the desktop does, like a tap on any field, except from the view's own text
71
+ * field, which keeps the on-screen keyboard up.
72
+ */
73
+ private func takeKeyboard(fromTouch: Bool) {
74
+ guard enabled, window != nil, !isFirstResponder else { return }
75
+ if let current = DesklinkInputView.currentFirstResponder() {
76
+ if !fromTouch { return }
77
+ if let view = current as? UIView, view.isDescendant(of: self) { return }
78
+ }
79
+ becomeFirstResponder()
80
+ }
81
+
82
+ @objc private func editingEnded(_ notification: Notification) {
83
+ guard let field = notification.object as? UIView, field.isDescendant(of: self) else { return }
84
+ // Once the field has let go, the keyboard comes back to the desktop.
85
+ DispatchQueue.main.async { [weak self] in self?.takeKeyboard(fromTouch: false) }
86
+ }
87
+
88
+ fileprivate static weak var found: UIResponder?
89
+
90
+ static func currentFirstResponder() -> UIResponder? {
91
+ found = nil
92
+ UIApplication.shared.sendAction(#selector(UIResponder.desklinkReportFirstResponder), to: nil, from: nil, for: nil)
93
+ // With no first responder the action ends at the application itself.
94
+ return found?.isFirstResponder == true ? found : nil
95
+ }
96
+
97
+ // MARK: - Keys
98
+
99
+ override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
100
+ var rest = Set<UIPress>()
101
+ for press in presses {
102
+ guard enabled, let key = press.key else { rest.insert(press); continue }
103
+ held[key.keyCode] = key
104
+ report(key, down: true)
105
+ }
106
+ if !rest.isEmpty { super.pressesBegan(rest, with: event) }
107
+ }
108
+
109
+ override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
110
+ let rest = release(presses)
111
+ if !rest.isEmpty { super.pressesEnded(rest, with: event) }
112
+ }
113
+
114
+ override func pressesCancelled(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
115
+ let rest = release(presses)
116
+ if !rest.isEmpty { super.pressesCancelled(rest, with: event) }
117
+ }
118
+
119
+ /** Report the keys among `presses` that went down here; the rest belong to someone else. */
120
+ private func release(_ presses: Set<UIPress>) -> Set<UIPress> {
121
+ var rest = Set<UIPress>()
122
+ for press in presses {
123
+ guard let key = press.key, held.removeValue(forKey: key.keyCode) != nil else { rest.insert(press); continue }
124
+ report(key, down: false)
125
+ }
126
+ return rest
127
+ }
128
+
129
+ /** Let go of every key still down: the desktop must not keep one held. */
130
+ private func releaseKeys() {
131
+ for key in held.values { report(key, down: false) }
132
+ held.removeAll()
133
+ }
134
+
135
+ /** The key's HID usage and the characters it types; JavaScript names it. Command is the desktop's Meta. */
136
+ private func report(_ key: UIKey, down: Bool) {
137
+ var modifiers: [String] = []
138
+ if key.modifierFlags.contains(.control) { modifiers.append("Control") }
139
+ if key.modifierFlags.contains(.alternate) { modifiers.append("Alt") }
140
+ if key.modifierFlags.contains(.command) { modifiers.append("Meta") }
141
+ if key.modifierFlags.contains(.shift) { modifiers.append("Shift") }
142
+ onKey(["down": down, "usage": key.keyCode.rawValue, "characters": key.characters, "modifiers": modifiers])
143
+ }
144
+
145
+ // MARK: - Pointer
146
+
147
+ override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
148
+ super.touchesBegan(touches, with: event)
149
+ guard enabled else { return }
150
+ takeKeyboard(fromTouch: true)
151
+ report(touches, phase: "down", event)
152
+ }
153
+
154
+ override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
155
+ super.touchesMoved(touches, with: event)
156
+ if enabled { report(touches, phase: "move", event) }
157
+ }
158
+
159
+ override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
160
+ super.touchesEnded(touches, with: event)
161
+ if enabled { report(touches, phase: "up", event) }
162
+ }
163
+
164
+ override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
165
+ super.touchesCancelled(touches, with: event)
166
+ if enabled { report(touches, phase: "cancel", event) }
167
+ }
168
+
169
+ /**
170
+ * A press of a trackpad or mouse with the button that made it. The touch's
171
+ * timestamp is the one React Native reports for the same touch, so the view
172
+ * can tell this press from a finger.
173
+ */
174
+ private func report(_ touches: Set<UITouch>, phase: String, _ event: UIEvent?) {
175
+ for touch in touches where touch.type == .indirectPointer {
176
+ let at = touch.location(in: self)
177
+ onPointer([
178
+ "phase": phase, "x": at.x, "y": at.y,
179
+ "buttons": event?.buttonMask.rawValue ?? 0, "timestamp": touch.timestamp * 1000,
180
+ ])
181
+ }
182
+ }
183
+
184
+ @objc private func hovered(_ recognizer: UIHoverGestureRecognizer) {
185
+ guard enabled, recognizer.state == .began || recognizer.state == .changed else { return }
186
+ let at = recognizer.location(in: self)
187
+ onPointer(["phase": "hover", "x": at.x, "y": at.y])
188
+ }
189
+
190
+ @objc private func scrolled(_ recognizer: UIPanGestureRecognizer) {
191
+ guard enabled else { return }
192
+ let at = recognizer.location(in: self)
193
+ let moved = recognizer.translation(in: self)
194
+ recognizer.setTranslation(.zero, in: self)
195
+ let phase: String
196
+ switch recognizer.state {
197
+ case .began: phase = "begin"
198
+ case .changed: phase = "move"
199
+ default: phase = "end"
200
+ }
201
+ onWheel(["phase": phase, "dx": moved.x, "dy": moved.y, "x": at.x, "y": at.y])
202
+ }
203
+ }
204
+
205
+ extension UIResponder {
206
+ @objc fileprivate func desklinkReportFirstResponder() {
207
+ DesklinkInputView.found = self
208
+ }
209
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desklink/react-native",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Render and drive a desktop session from a React Native app",
6
6
  "main": "index.ts",
@@ -10,7 +10,9 @@
10
10
  "./availability": {
11
11
  "browser": "./src/availability.web.ts",
12
12
  "default": "./src/availability.ts"
13
- }
13
+ },
14
+ "./app.plugin.js": "./app.plugin.js",
15
+ "./package.json": "./package.json"
14
16
  },
15
17
  "files": [
16
18
  "index.ts",
@@ -19,6 +21,9 @@
19
21
  "android/build.gradle",
20
22
  "android/gradle.properties",
21
23
  "android/src/main",
24
+ "ios/*.swift",
25
+ "ios/*.podspec",
26
+ "app.plugin.js",
22
27
  "expo-module.config.json",
23
28
  "NOTICE",
24
29
  "README.md"
@@ -29,5 +34,9 @@
29
34
  "react": ">=19",
30
35
  "react-native": ">=0.83",
31
36
  "react-native-webrtc": ">=124"
37
+ },
38
+ "scripts": {
39
+ "test:reconnect": "node test/reconnect-flow.mjs",
40
+ "test:ios": "node test/ios-flow.mjs"
32
41
  }
33
42
  }
@@ -0,0 +1,354 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import React from 'react';
3
+ import TestRenderer from 'react-test-renderer';
4
+
5
+ /** `act` refuses to flush state updates unless React is told this is a test. */
6
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
7
+
8
+ /**
9
+ * Drives the real iOS view — its gestures, keyboard and control channel — and
10
+ * reads the exact messages the desktop receives. Only the platform underneath
11
+ * is stood in for: `react-native` primitives are host strings, the WebRTC
12
+ * stack arrives through the bundler's lazy require, and the peer hands over a
13
+ * data channel that records what it is asked to send. Behaviour here must
14
+ * match `DesktopView.kt`.
15
+ */
16
+
17
+ vi.mock('react-native', () => ({
18
+ Keyboard: { addListener: () => ({ remove: () => undefined }), dismiss: () => undefined },
19
+ StyleSheet: { create: (styles: unknown) => styles },
20
+ TextInput: 'TextInput',
21
+ View: 'View',
22
+ }));
23
+
24
+ /** Whether the build carries the package's native input view; by default it stands for one without. */
25
+ const nativeInput = { installed: false };
26
+ vi.mock('expo-modules-core', () => ({
27
+ requireOptionalNativeModule: (name: string) => (nativeInput.installed && name === 'DesklinkInput' ? {} : null),
28
+ requireNativeViewManager: () => 'DesklinkInput',
29
+ }));
30
+
31
+ // The bundler's inline require, standing in: it goes through the stubbed global so the spec sees when it runs.
32
+ vi.mock('./webrtc.ios', () => ({ requireWebRTC: () => (globalThis as unknown as { require: (id: string) => unknown }).require('react-native-webrtc') }));
33
+
34
+ class MockStream {
35
+ addTrack = vi.fn();
36
+ toURL() { return 'stream'; }
37
+ }
38
+ class MockPeer {
39
+ ontrack?: (event: { track: { kind: string }; streams: never[] }) => void;
40
+ ondatachannel?: (event: { channel: unknown }) => void;
41
+ oniceconnectionstatechange?: () => void;
42
+ iceConnectionState = 'new';
43
+ localDescription = { sdp: 'answer' };
44
+ setRemoteDescription = vi.fn(async () => undefined);
45
+ addIceCandidate = vi.fn(async () => undefined);
46
+ createAnswer = vi.fn(async () => ({ type: 'answer', sdp: 'answer' }));
47
+ setLocalDescription = vi.fn(async () => undefined);
48
+ close = vi.fn();
49
+ constructor() { peers.push(this); }
50
+ }
51
+ const peers: MockPeer[] = [];
52
+ function MockRTCView() { return null; }
53
+
54
+ /** What the bundler's lazy require handed out, in order. */
55
+ const required: string[] = [];
56
+
57
+ const BOUNDS = { width: 390, height: 844 };
58
+ const DESKTOP = { width: 1280, height: 720 };
59
+ // fit = min(390/1280, 844/720); the picture is 390 wide, centred vertically.
60
+ const SCALE = Math.min(BOUNDS.width / DESKTOP.width, BOUNDS.height / DESKTOP.height);
61
+ const ORIGIN_Y = (BOUNDS.height - DESKTOP.height * SCALE) / 2;
62
+
63
+ type Touch = { locationX: number; locationY: number; timestamp?: number };
64
+ function touch(x: number, y: number, timestamp?: number): Touch { return { locationX: x, locationY: y, timestamp }; }
65
+
66
+ async function openView(options: { gestures?: 'desktop' | 'browser' | 'device'; accessibilityLabel?: string; native?: boolean } = {}) {
67
+ nativeInput.installed = options.native === true;
68
+ const { desktopInputEnabled, nativeDesklink } = await import('./native.ios');
69
+ const { DesktopView } = await import('./DesktopView.ios');
70
+ const id = nativeDesklink.createSession('[]')!;
71
+ const peer = peers.at(-1)!;
72
+ const sent: Record<string, unknown>[] = [];
73
+ peer.ondatachannel!({
74
+ channel: { readyState: 'open', send: (value: string) => { sent.push(JSON.parse(value)); }, close: () => undefined },
75
+ });
76
+ nativeDesklink.setInputEnabled(id, true);
77
+ expect(desktopInputEnabled(id)).toBe(true);
78
+ let renderer: TestRenderer.ReactTestRenderer | null = null;
79
+ TestRenderer.act(() => {
80
+ renderer = TestRenderer.create(
81
+ <DesktopView sessionId={id} gestures={options.gestures} accessibilityLabel={options.accessibilityLabel} />,
82
+ );
83
+ });
84
+ const outer = () => renderer!.root.findByType(options.native ? 'DesklinkInput' as never : 'View').props as {
85
+ onLayout: (event: { nativeEvent: { layout: { width: number; height: number } } }) => void;
86
+ onResponderGrant: (event: { nativeEvent: { touches: Touch[] } }) => void;
87
+ onResponderMove: (event: { nativeEvent: { touches: Touch[] } }) => void;
88
+ onResponderRelease: (event: { nativeEvent: { touches: Touch[] } }) => void;
89
+ [key: string]: unknown;
90
+ };
91
+ TestRenderer.act(() => {
92
+ outer().onLayout({ nativeEvent: { layout: { ...BOUNDS } } });
93
+ nativeDesklink.setSurfaceSize(id, DESKTOP.width, DESKTOP.height);
94
+ peer.ontrack!({ track: { kind: 'video' }, streams: [] });
95
+ });
96
+ const grant = (at: Touch[]) => TestRenderer.act(() => { outer().onResponderGrant({ nativeEvent: { touches: at } }); });
97
+ const move = (at: Touch[]) => TestRenderer.act(() => { outer().onResponderMove({ nativeEvent: { touches: at } }); });
98
+ const release = (at: Touch[] = []) => TestRenderer.act(() => { outer().onResponderRelease({ nativeEvent: { touches: at } }); });
99
+ const submit = () => TestRenderer.act(() => {
100
+ renderer!.root.findByType('TextInput').props.onSubmitEditing();
101
+ });
102
+ const rtcStyle = () => renderer!.root.findByType(MockRTCView).props.style as { left: number; top: number; width: number; height: number };
103
+ /** What the native input view reports, as its events. */
104
+ const native = {
105
+ key: (usage: number, down: boolean, characters = '', modifiers: string[] = []) => TestRenderer.act(() => {
106
+ (outer().onKey as (event: unknown) => void)({ nativeEvent: { usage, down, characters, modifiers } });
107
+ }),
108
+ pointer: (phase: string, x: number, y: number, buttons = 0, timestamp?: number) => TestRenderer.act(() => {
109
+ (outer().onPointer as (event: unknown) => void)({ nativeEvent: { phase, x, y, buttons, timestamp } });
110
+ }),
111
+ wheel: (phase: string, dx: number, dy: number, x = 195, y = 420) => TestRenderer.act(() => {
112
+ (outer().onWheel as (event: unknown) => void)({ nativeEvent: { phase, dx, dy, x, y } });
113
+ }),
114
+ };
115
+ return { id, nativeDesklink, renderer: renderer!, sent, outer, grant, move, release, submit, rtcStyle, native };
116
+ }
117
+
118
+ beforeEach(() => {
119
+ peers.length = 0;
120
+ required.length = 0;
121
+ // The device bundler evaluates `react-native-webrtc` on first require;
122
+ // nothing in this package may require it at import time.
123
+ vi.stubGlobal('require', (mod: string) => {
124
+ required.push(mod);
125
+ if (mod === 'react-native-webrtc') return { MediaStream: MockStream, RTCPeerConnection: MockPeer, RTCView: MockRTCView };
126
+ throw new Error(`unexpected require: ${mod}`);
127
+ });
128
+ });
129
+ afterEach(() => { vi.unstubAllGlobals(); });
130
+
131
+ describe('DesktopView.ios parity with DesktopView.kt', () => {
132
+ it('ignores a one-finger drag that starts in the letterbox', async () => {
133
+ const { sent, grant, move, release } = await openView();
134
+ // y = 10 is above the fitted picture (it starts at ORIGIN_Y).
135
+ grant([touch(10, 10)]);
136
+ move([touch(50, 50)]);
137
+ release();
138
+ expect(sent.filter((message) => message.kind === 'pointer')).toEqual([]);
139
+ // Sanity: the same drag on the picture carries the pointer.
140
+ grant([touch(195, 420)]);
141
+ move([touch(250, 430)]);
142
+ release();
143
+ expect(sent.filter((message) => message.kind === 'pointer' && message.phase === 'move')).not.toEqual([]);
144
+ });
145
+
146
+ it('zooms around the pinch focus and re-clamps after zooming out', async () => {
147
+ const { rtcStyle, grant, move } = await openView();
148
+ grant([touch(100, 400)]);
149
+ move([touch(100, 400), touch(140, 400)]);
150
+ move([touch(80, 400), touch(160, 400)]);
151
+ expect(rtcStyle().width).toBeCloseTo(DESKTOP.width * SCALE * 2, 0);
152
+ // The desktop point under the focus (120, 400) stays under it.
153
+ expect(rtcStyle().left).toBeCloseTo(-120, 0);
154
+ // Pinching back to 1x recentres: no stale offset left behind.
155
+ move([touch(100, 400), touch(140, 400)]);
156
+ expect(rtcStyle().width).toBeCloseTo(BOUNDS.width, 0);
157
+ expect(rtcStyle().left).toBeCloseTo(0, 5);
158
+ });
159
+
160
+ it('accumulates wheel below the send threshold and moves the pointer to the scroll focus', async () => {
161
+ const { sent, grant, move, release } = await openView();
162
+ grant([touch(190, 420)]);
163
+ move([touch(190, 420), touch(200, 420)]);
164
+ move([touch(192, 421), touch(202, 421)]);
165
+ move([touch(194, 422), touch(204, 422)]);
166
+ move([touch(196, 423), touch(206, 423)]);
167
+ // The last step crosses the slop into a scroll, but its own single
168
+ // pixel stays below the 0.05 detent threshold: held back.
169
+ move([touch(197, 424), touch(207, 424)]);
170
+ const wheels = () => sent.filter((message) => message.kind === 'wheel');
171
+ expect(wheels()).toEqual([]);
172
+ // A second pixel crosses it: one fractional wheel step goes out.
173
+ move([touch(198, 424), touch(208, 424)]);
174
+ expect(wheels()).toHaveLength(1);
175
+ expect(wheels()[0].dx).toBeCloseTo(-2 / SCALE / 120, 5);
176
+ expect(wheels()[0].dy).toBeCloseTo(-1 / SCALE / 120, 5);
177
+ // The desktop scrolled whatever was under its pointer.
178
+ expect(sent.filter((message) => message.kind === 'pointer' && message.phase === 'move')).not.toEqual([]);
179
+ release();
180
+ });
181
+
182
+ it('releases a held device drag when the profile switches away', async () => {
183
+ const first = await openView({ gestures: 'device' });
184
+ first.grant([touch(195, 420)]);
185
+ expect(first.sent.at(-1)).toMatchObject({ kind: 'pointer', phase: 'down', button: 1 });
186
+ const { DesktopView } = await import('./DesktopView.ios');
187
+ TestRenderer.act(() => {
188
+ first.renderer.update(<DesktopView sessionId={first.id} gestures="desktop" />);
189
+ });
190
+ expect(first.sent.at(-1)).toMatchObject({ kind: 'release_all' });
191
+ });
192
+
193
+ it('sends Enter through capture while a sticky modifier is held', async () => {
194
+ const { id, nativeDesklink, sent, submit } = await openView();
195
+ const keys: Record<string, unknown>[] = [];
196
+ const listener = nativeDesklink.addListener!('onSessionEvent', (event) => {
197
+ if (event.sessionId === id && event.name === 'keyboard') keys.push(event.payload);
198
+ });
199
+ nativeDesklink.captureKeyboard(id, true);
200
+ submit();
201
+ listener.remove();
202
+ expect(keys).toEqual([{ key: 'Enter' }]);
203
+ expect(sent.filter((message) => message.kind === 'key')).toEqual([]);
204
+ });
205
+
206
+ it('puts the cursor mark tip on the pointer with a hotspot', async () => {
207
+ const { renderer, grant, move } = await openView();
208
+ grant([touch(195, 420)]);
209
+ move([touch(250, 430)]);
210
+ const cursor = renderer.root.findAllByType('View')[1].props.style as unknown[];
211
+ const mark = cursor[1] as { left: number; top: number };
212
+ const desktop = { x: Math.floor(250 / SCALE), y: Math.floor((430 - ORIGIN_Y) / SCALE) };
213
+ expect(mark.left).toBeCloseTo((desktop.x + 0.5) * SCALE - 3, 1);
214
+ expect(mark.top).toBeCloseTo(ORIGIN_Y + (desktop.y + 0.5) * SCALE - 3, 1);
215
+ expect(renderer.root.findAllByType('View')[1].props.accessible).toBe(false);
216
+ });
217
+
218
+ it('exposes the surface to VoiceOver only with a label', async () => {
219
+ const labelled = await openView({ accessibilityLabel: 'Desktop' });
220
+ expect(labelled.outer().accessible).toBe(true);
221
+ const unlabelled = await openView();
222
+ expect(unlabelled.outer().accessible).toBe(false);
223
+ expect(unlabelled.renderer.root.findByType('TextInput').props.accessible).toBe(false);
224
+ });
225
+
226
+ it('keeps WebRTC out of the import and loads it on first render', async () => {
227
+ vi.resetModules();
228
+ await import('./DesktopView.ios');
229
+ expect(required).not.toContain('react-native-webrtc');
230
+ await openView();
231
+ expect(required).toContain('react-native-webrtc');
232
+ });
233
+ });
234
+
235
+ describe('DesktopView.ios hardware keyboard and pointer', () => {
236
+ /** Desktop pixels under a point of the view. */
237
+ const pixel = (x: number, y: number) => ({ x: Math.floor(x / SCALE), y: Math.floor((y - ORIGIN_Y) / SCALE) });
238
+ const pointers = (sent: Record<string, unknown>[]) => sent.filter((message) => message.kind === 'pointer')
239
+ .map(({ phase, x, y, button }) => ({ phase, x, y, button }));
240
+
241
+ it('keeps a plain view where the build has no native input view', async () => {
242
+ const { renderer } = await openView();
243
+ expect(renderer.root.findAllByType('DesklinkInput' as never)).toEqual([]);
244
+ });
245
+
246
+ it('arms the native view only while control is enabled', async () => {
247
+ const { id, nativeDesklink, outer } = await openView({ native: true });
248
+ expect(outer().enabled).toBe(true);
249
+ TestRenderer.act(() => { nativeDesklink.setInputEnabled(id, false); });
250
+ expect(outer().enabled).toBe(false);
251
+ });
252
+
253
+ it('sends arrows, Esc, Tab and a Command chord from the hardware keyboard', async () => {
254
+ const { sent, native } = await openView({ native: true });
255
+ native.key(0x52, true); native.key(0x52, false);
256
+ native.key(0x29, true); native.key(0x29, false);
257
+ native.key(0x2b, true, '\t'); native.key(0x2b, false, '\t');
258
+ native.key(0xe3, true, '', ['Meta']);
259
+ native.key(0x06, true, 'c', ['Meta']);
260
+ native.key(0x06, false, 'c', ['Meta']);
261
+ native.key(0xe3, false);
262
+ native.key(0x0b, true, 'h'); native.key(0x0b, false, 'h');
263
+ expect(sent.map(({ seq: _seq, ...message }) => message)).toEqual([
264
+ { kind: 'key', name: 'ArrowUp', modifiers: [], down: true }, { kind: 'key', name: 'ArrowUp', modifiers: [], down: false },
265
+ { kind: 'key', name: 'Escape', modifiers: [], down: true }, { kind: 'key', name: 'Escape', modifiers: [], down: false },
266
+ { kind: 'key', name: 'Tab', modifiers: [], down: true }, { kind: 'key', name: 'Tab', modifiers: [], down: false },
267
+ { kind: 'key', name: 'Meta', modifiers: ['Meta'], down: true },
268
+ { kind: 'key', character: 'c', modifiers: ['Meta'], down: true }, { kind: 'key', character: 'c', modifiers: ['Meta'], down: false },
269
+ { kind: 'key', name: 'Meta', modifiers: [], down: false },
270
+ { kind: 'text', text: 'h' },
271
+ ]);
272
+ });
273
+
274
+ it('hands hardware keys to the session while a sticky modifier waits', async () => {
275
+ const { id, nativeDesklink, sent, native } = await openView({ native: true });
276
+ const keys: Record<string, unknown>[] = [];
277
+ const listener = nativeDesklink.addListener!('onSessionEvent', (event) => {
278
+ if (event.sessionId === id && event.name === 'keyboard') keys.push(event.payload);
279
+ });
280
+ nativeDesklink.captureKeyboard(id, true);
281
+ native.key(0x19, true, 'v');
282
+ native.key(0x28, true, '\r');
283
+ listener.remove();
284
+ expect(keys).toEqual([{ text: 'v' }, { key: 'Enter' }]);
285
+ expect(sent).toEqual([]);
286
+ });
287
+
288
+ it('hovers the desktop pointer without a button or a drawn mark, and not from the letterbox', async () => {
289
+ const { sent, native, renderer } = await openView({ native: true });
290
+ native.pointer('hover', 10, 10);
291
+ native.pointer('hover', 200, 400);
292
+ expect(pointers(sent)).toEqual([{ phase: 'move', ...pixel(200, 400), button: undefined }]);
293
+ // The system draws the trackpad's own pointer.
294
+ expect(renderer.root.findAllByType('View')).toEqual([]);
295
+ });
296
+
297
+ it('drags with a trackpad press the native view reported before touch saw it', async () => {
298
+ const { sent, native, grant, move, release } = await openView({ native: true });
299
+ native.pointer('down', 100, 400, 1, 5000);
300
+ grant([touch(100, 400, 5000)]);
301
+ native.pointer('move', 160, 410, 1);
302
+ move([touch(160, 410, 5010)]);
303
+ native.pointer('up', 160, 410, 0);
304
+ release();
305
+ expect(pointers(sent)).toEqual([
306
+ { phase: 'down', ...pixel(100, 400), button: 1 },
307
+ { phase: 'move', ...pixel(160, 410), button: 1 },
308
+ { phase: 'up', ...pixel(160, 410), button: 1 },
309
+ ]);
310
+ });
311
+
312
+ it('right-clicks with a secondary press touch saw first, and no long press follows', async () => {
313
+ vi.useFakeTimers();
314
+ try {
315
+ const { sent, native, grant, release } = await openView({ native: true });
316
+ grant([touch(100, 400, 6000)]);
317
+ native.pointer('down', 100, 400, 2, 6000);
318
+ TestRenderer.act(() => { vi.advanceTimersByTime(600); });
319
+ native.pointer('up', 100, 400, 0);
320
+ release();
321
+ expect(pointers(sent)).toEqual([
322
+ { phase: 'down', ...pixel(100, 400), button: 3 },
323
+ { phase: 'up', ...pixel(100, 400), button: 3 },
324
+ ]);
325
+ } finally {
326
+ vi.useRealTimers();
327
+ }
328
+ });
329
+
330
+ it('does not press twice when a device-profile touch already pressed for the trackpad', async () => {
331
+ const { sent, native, grant, release } = await openView({ native: true, gestures: 'device' });
332
+ grant([touch(100, 400, 7000)]);
333
+ native.pointer('down', 100, 400, 1, 7000);
334
+ native.pointer('up', 100, 400, 0);
335
+ release();
336
+ expect(pointers(sent)).toEqual([
337
+ { phase: 'down', ...pixel(100, 400), button: 1 },
338
+ { phase: 'up', ...pixel(100, 400), button: 1 },
339
+ ]);
340
+ });
341
+
342
+ it('scrolls the desktop under the pointer with a trackpad, content following the fingers', async () => {
343
+ const { sent, native } = await openView({ native: true });
344
+ native.wheel('begin', 0, -30, 200, 400);
345
+ native.wheel('move', 0, -0.01, 200, 400);
346
+ native.wheel('end', 0, 0, 200, 400);
347
+ expect(pointers(sent)).toEqual([{ phase: 'move', ...pixel(200, 400), button: undefined }]);
348
+ const wheels = sent.filter((message) => message.kind === 'wheel');
349
+ expect(wheels).toHaveLength(2);
350
+ expect(wheels[0].dy).toBeCloseTo(30 / SCALE / 120, 5);
351
+ // What stayed below the send threshold goes out when the scroll ends.
352
+ expect(wheels[1].dy).toBeCloseTo(0.01 / SCALE / 120, 5);
353
+ });
354
+ });