@desklink/react-native 0.2.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 +174 -18
- package/app.plugin.js +16 -0
- package/expo-module.config.json +4 -1
- package/ios/DesklinkInput.podspec +22 -0
- package/ios/DesklinkInputModule.swift +20 -0
- package/ios/DesklinkInputView.swift +209 -0
- package/package.json +9 -3
- package/src/DesktopView.ios.spec.tsx +364 -0
- package/src/DesktopView.ios.tsx +217 -30
- package/src/hardwareKeys.ts +61 -0
- package/src/native.ios.ts +53 -5
- 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 +17 -2
- package/src/useDesktopSession.ts +99 -15
- package/src/webrtc.ios.ts +10 -0
package/README.md
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">@desklink/react-native</h1>
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<p align="center">
|
|
4
|
+
<a href="https://www.npmjs.com/package/@desklink/react-native"><img alt="npm" src="https://img.shields.io/npm/v/@desklink/react-native?style=flat&label=npm" /></a>
|
|
5
|
+
<a href="https://github.com/umeranjum17/desklink/actions/workflows/ci.yml"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/umeranjum17/desklink/ci.yml?style=flat&branch=main" /></a>
|
|
6
|
+
<a href="LICENSE"><img alt="Apache 2.0" src="https://img.shields.io/badge/license-Apache--2.0-666?style=flat" /></a>
|
|
7
|
+
<img alt="Android, iOS and web" src="https://img.shields.io/badge/Android%20%7C%20iOS%20%7C%20web-111?style=flat" />
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
<p align="center">
|
|
11
|
+
<strong>Show a live desktop in a React Native app, and drive it from the phone.</strong><br/>
|
|
12
|
+
It renders a desktop that <code>@desklink/host</code> serves on Android, iOS and the web, with touch as a desktop pointer the phone can see, sticky modifier keys, and a session that recovers from a dropped network on its own.
|
|
13
|
+
</p>
|
|
4
14
|
|
|
5
15
|
The package is a **view and a session**, not a screen: it renders the picture and
|
|
6
16
|
turns touch, the keyboard and the clipboard into that session's input. Where it
|
|
@@ -9,6 +19,10 @@ the application.
|
|
|
9
19
|
|
|
10
20
|
## Install
|
|
11
21
|
|
|
22
|
+
The npm badge at the top of this page tracks the registry version, and npm
|
|
23
|
+
verifies package integrity on install. See the [changelog](../../CHANGELOG.md)
|
|
24
|
+
for release changes. Supported platforms: Android, iOS and web.
|
|
25
|
+
|
|
12
26
|
The package contains native code and is not an Expo Go package. Inside this
|
|
13
27
|
repository it resolves through the workspace; anywhere else, install it from npm:
|
|
14
28
|
|
|
@@ -17,10 +31,29 @@ npx expo install @desklink/react-native # or yarn add, then prebuild/rebuil
|
|
|
17
31
|
```
|
|
18
32
|
|
|
19
33
|
Android uses an Expo native module; iOS uses the app's existing
|
|
20
|
-
`react-native-webrtc` peer connection and `RTCView` from JavaScript
|
|
21
|
-
|
|
34
|
+
`react-native-webrtc` peer connection and `RTCView` from JavaScript, plus a small
|
|
35
|
+
Expo module of its own for the hardware keyboard and an iPad pointer. Both need a
|
|
36
|
+
dev client or native build containing that binding ([development builds](https://docs.expo.dev/develop/development-builds/introduction/); [Expo Go](https://docs.expo.dev/workflow/expo-go/) cannot load them). Web uses the browser's WebRTC.
|
|
22
37
|
No second WebRTC binary is linked. The Android module has its own hardware-first
|
|
23
|
-
decoder factory; iOS uses the decoder shipped by `react-native-webrtc
|
|
38
|
+
decoder factory; iOS uses the decoder shipped by `react-native-webrtc` and asks
|
|
39
|
+
the host for H.264, which that decoder runs in hardware.
|
|
40
|
+
|
|
41
|
+
On iOS the WebRTC binding also needs its config plugin
|
|
42
|
+
(`@config-plugins/react-native-webrtc` — 14.x for Expo SDK 55, 15.x for
|
|
43
|
+
Expo SDK 56 and later):
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{ "expo": { "plugins": ["@config-plugins/react-native-webrtc", "@desklink/react-native"] } }
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
On iPad, a trackpad or mouse hovers and scrolls the desktop as it is. For its
|
|
50
|
+
presses to carry their buttons — a click-drag that selects, a secondary click
|
|
51
|
+
that is a right click — the package's entry in the plugins list above sets
|
|
52
|
+
[`UIApplicationSupportsIndirectInputEvents`](https://developer.apple.com/documentation/bundleresources/information-property-list/uiapplicationsupportsindirectinputevents).
|
|
53
|
+
|
|
54
|
+
That key changes how every screen of the app receives trackpad input, so the
|
|
55
|
+
package never sets it on its own; without the plugin nothing about the app
|
|
56
|
+
changes, and trackpad presses reach the desktop as touches.
|
|
24
57
|
|
|
25
58
|
## Use
|
|
26
59
|
|
|
@@ -56,22 +89,53 @@ import { desktopAvailable } from '@desklink/react-native/availability';
|
|
|
56
89
|
{desktopAvailable && <ComputerAction onPress={openDesktop} />}
|
|
57
90
|
```
|
|
58
91
|
|
|
92
|
+
### Frame rate and encode size
|
|
93
|
+
|
|
94
|
+
The engine streams at up to 60 fps unless `session.maxFps` names a lower cap —
|
|
95
|
+
omit it for the smooth path. A still desktop costs nothing at 60, because the
|
|
96
|
+
engine only produces frames on change.
|
|
97
|
+
|
|
98
|
+
Keep the encoded picture inside a 1920×1080 box:
|
|
99
|
+
|
|
100
|
+
```tsx
|
|
101
|
+
session: { permissions: CONTROL_PERMISSIONS, maxWidth: 1920, maxHeight: 1080 },
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The engine never upscales, so a smaller desktop is sent at its own size and a
|
|
105
|
+
larger one is scaled to fit. Above that box the encoder cannot sustain 50+ fps
|
|
106
|
+
under full-screen motion.
|
|
107
|
+
|
|
108
|
+
On iOS, keep the box at or below the device's own pixels and `maxFps` at 30:
|
|
109
|
+
the iOS receiver asks the engine for H.264, which iOS decodes in hardware
|
|
110
|
+
(VideoToolbox), but a host without an H.264 encoder still sends VP9, which
|
|
111
|
+
iOS decodes in software — so a 1440p or 4K picture on such a host costs CPU,
|
|
112
|
+
heat and battery.
|
|
113
|
+
|
|
59
114
|
### What the package guarantees
|
|
60
115
|
|
|
61
|
-
- **
|
|
116
|
+
- **Hardware decoding where the platform has it.** Android and web decode the
|
|
117
|
+
host's default VP9; Android builds a session-scoped hardware-first decoder
|
|
62
118
|
factory with software fallback. iOS uses the app's `react-native-webrtc`
|
|
63
|
-
decoder
|
|
119
|
+
decoder, which decodes H.264 through VideoToolbox but VP9 only in software,
|
|
120
|
+
so the iOS receiver asks for H.264 by putting it first in the offer it
|
|
121
|
+
applies; a host with an H.264 encoder then sends H.264, and one without sends
|
|
122
|
+
VP9. Neither platform links a second WebRTC stack.
|
|
64
123
|
- **A sharp, zoomable picture.** The desktop fits the view by default; a pinch
|
|
65
124
|
zooms up to 2.5 view pixels per desktop pixel and one finger moves around the
|
|
66
125
|
zoomed desktop. On Android the decoded frame is copied once into the view's
|
|
67
126
|
own texture and drawn with a multi-tap filter when it is shown smaller than
|
|
68
127
|
its size, so a fitted 4K desktop does not alias and a pinch redraws at once.
|
|
69
|
-
iOS uses `RTCView`'s native renderer.
|
|
128
|
+
iOS uses `RTCView`'s native renderer. That renderer draws a frame only when
|
|
129
|
+
its timestamp is new, and a receiver honouring the engine's zero playout delay
|
|
130
|
+
stamps every frame alike, so the iOS peer declines that hint and keeps the
|
|
131
|
+
receiver's usual jitter buffer. `fitToView()` shows the whole desktop again.
|
|
70
132
|
- **A pointer a phone can see.** Once a touch has sent the desktop's pointer
|
|
71
|
-
somewhere, the view draws
|
|
72
|
-
own cursor is a few pixels tall or not captured at all.
|
|
73
|
-
|
|
74
|
-
|
|
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.
|
|
75
139
|
- **The picture above the keyboard.** While the phone's keyboard is up, the
|
|
76
140
|
picture sits above it and above the room the app keeps for its own controls
|
|
77
141
|
(`keyboardClearance`). Android follows the keyboard animation and keeps the
|
|
@@ -97,19 +161,31 @@ import { desktopAvailable } from '@desklink/react-native/availability';
|
|
|
97
161
|
release, so a tap clicks and a hold holds with no long-press right click,
|
|
98
162
|
and no drawn pointer — while the default `desktop` is the trackpad above.
|
|
99
163
|
Two fingers scroll and pinch the same under every profile.
|
|
164
|
+
- **A hardware keyboard and a trackpad.** A hardware keyboard's keys reach the
|
|
165
|
+
desktop with their down and up and what is held: arrows, Esc, Tab, Home/End,
|
|
166
|
+
Page Up/Down, Delete and the modifiers, with Control and Command
|
|
167
|
+
chords as the key's own letter (iOS sends Command as the desktop's Meta);
|
|
168
|
+
other keys type their text. On iOS the view holds the keyboard while control
|
|
169
|
+
is enabled and gives it to the on-screen keyboard while the app shows it;
|
|
170
|
+
shortcuts the system keeps, such as Command-Tab and the Globe key, stay with
|
|
171
|
+
the system. An iPad pointer hovers the desktop's pointer, scrolls under it,
|
|
172
|
+
and — with the config plugin — presses, drags and right-clicks with its own
|
|
173
|
+
buttons.
|
|
100
174
|
- **The keys a phone lacks.** `modifiers`, `tapModifier` and `pressKey` give
|
|
101
175
|
sticky Ctrl and Shift: tap arms one for the next key, tap again locks it.
|
|
102
176
|
While one is armed, the next key or character the phone's keyboard types is
|
|
103
177
|
sent as that key's chord, so Ctrl then v is Ctrl+V. The app draws the keys.
|
|
104
|
-
- **Readiness is a rendered frame.** Android marks the first draw; iOS
|
|
105
|
-
`RTCView`
|
|
106
|
-
|
|
178
|
+
- **Readiness is a rendered frame.** Android marks the first draw; iOS marks
|
|
179
|
+
the first `RTCView` video-dimensions callback and ignores later ones, so a
|
|
180
|
+
resize or remount during a reconnect does not read as live. Neither marks
|
|
181
|
+
a track or ICE connection as live.
|
|
107
182
|
- **Connection states, as they happen.** The session status is `idle`,
|
|
108
183
|
`opening`, `connecting`, `live`, `reconnecting`, `ended` or `failed`. The
|
|
109
184
|
peer's ICE state and the engine's `session.state` both feed it: a
|
|
110
185
|
`disconnected` path reads as `reconnecting` within about a second of the
|
|
111
186
|
drop — never a frozen picture that still says live — and a recovered path
|
|
112
|
-
reads as `live` again once ICE is `connected`.
|
|
187
|
+
reads as `live` again once ICE is `connected`. A path that reports
|
|
188
|
+
`failed` reopens the session with fresh authority instead of staying stuck.
|
|
113
189
|
Every event carries its engine session id where the carrier preserves it,
|
|
114
190
|
and the session ignores anything naming a session it no longer holds, so a
|
|
115
191
|
previous generation's queued offer or revocation cannot corrupt or kill a
|
|
@@ -126,7 +202,12 @@ import { desktopAvailable } from '@desklink/react-native/availability';
|
|
|
126
202
|
`session.description` event and is answered as usual, all without reopening
|
|
127
203
|
the session. The first restart waits ~2 s for blips that heal alone;
|
|
128
204
|
retries back off across ~60 s, so a 30–60 s outage still recovers when the
|
|
129
|
-
network returns.
|
|
205
|
+
network returns. When the restart schedule is spent without the peer
|
|
206
|
+
reporting `failed`, the session reopens instead of sitting in
|
|
207
|
+
`reconnecting`. An engine revocation with code `transport` (the path was
|
|
208
|
+
lost) also reopens the session; any other revocation code, or none, ends
|
|
209
|
+
it, as does a deliberate close. A return to the foreground after ~30 s away
|
|
210
|
+
reopens at once rather than spending restarts on a lapsed path. Those
|
|
130
211
|
reopens back off across ~60 s too instead of spending one attempt while the
|
|
131
212
|
network is still down. Renegotiation transients from a restart the session
|
|
132
213
|
asked for itself are expected, not a new outage: they neither flip the
|
|
@@ -144,7 +225,10 @@ import { desktopAvailable } from '@desklink/react-native/availability';
|
|
|
144
225
|
### `Signaling`
|
|
145
226
|
|
|
146
227
|
The one thing the app must supply. It carries the engine's local protocol over
|
|
147
|
-
whatever authenticated channel the app already has
|
|
228
|
+
whatever authenticated channel the app already has.
|
|
229
|
+
|
|
230
|
+
See the [BYOKit integration guidance](../../README.md#why-desklink-exists)
|
|
231
|
+
for the application's pairing, identity and signaling transport.
|
|
148
232
|
|
|
149
233
|
```ts
|
|
150
234
|
interface Signaling {
|
|
@@ -158,6 +242,78 @@ interface Signaling {
|
|
|
158
242
|
candidates, state and revocation. Pixels and input do **not** use this
|
|
159
243
|
channel — they are the desktop's own WebRTC session.
|
|
160
244
|
|
|
245
|
+
## Shipping on iOS
|
|
246
|
+
|
|
247
|
+
What the consuming app — not this package — must get right before review.
|
|
248
|
+
|
|
249
|
+
- **Local network.** A direct LAN session connects to host ICE candidates,
|
|
250
|
+
which prompts for local-network access, so the app needs
|
|
251
|
+
`NSLocalNetworkUsageDescription`. The first connection can be denied before
|
|
252
|
+
the user answers; the session's restart/reopen path recovers from that.
|
|
253
|
+
`NSBonjourServices` is only needed if the app adds its own Bonjour
|
|
254
|
+
discovery — desklink discovers no hosts. Relay-only sessions never prompt.
|
|
255
|
+
For app-supplied routes, see the
|
|
256
|
+
[BYOKit integration guidance](../../README.md#why-desklink-exists).
|
|
257
|
+
See [TN3179](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy).
|
|
258
|
+
- **Export compliance.** The viewer's DTLS-SRTP comes from libwebrtc bundled
|
|
259
|
+
with `react-native-webrtc`, not from the OS, so the app must check the
|
|
260
|
+
exemption criteria itself rather than assume
|
|
261
|
+
`ITSAppUsesNonExemptEncryption` is `NO` — standard-protocol encryption is
|
|
262
|
+
commonly exempt but can still need a yearly self-classification report.
|
|
263
|
+
See [complying with encryption export regulations](https://developer.apple.com/documentation/security/complying-with-encryption-export-regulations).
|
|
264
|
+
- **Review.** A viewer that mirrors the whole host device generically falls
|
|
265
|
+
outside the remote-desktop conditions; one that mirrors only specific
|
|
266
|
+
software must connect LAN-only to a user-owned host. Which case applies is
|
|
267
|
+
the app's product shape to decide. Declare no `audio` or `voip` background
|
|
268
|
+
mode to keep the stream alive — review rejects that. See the
|
|
269
|
+
[App Store review guidelines](https://developer.apple.com/app-store/review/guidelines/)
|
|
270
|
+
(§4.2.7, §2.5.4).
|
|
271
|
+
- **Backgrounding.** There is no compliant background mode for a desktop
|
|
272
|
+
stream: an app away longer than ~30 s comes back to a closed session, and
|
|
273
|
+
the session reopens on return to the foreground rather than resuming. Keep
|
|
274
|
+
disabling input on background; the engine releases held keys within
|
|
275
|
+
seconds of the path dying, so nothing stays stuck.
|
|
276
|
+
|
|
277
|
+
## iOS simulator proof
|
|
278
|
+
|
|
279
|
+
`example/` is the smallest app that shows a desktop: a native (non-Expo Go)
|
|
280
|
+
build with `react-native-webrtc`, connecting to `desklink-host bridge` with the
|
|
281
|
+
URL it is launched with. `test/ios-flow.mjs` builds it for the iOS simulator on
|
|
282
|
+
a Mac over ssh, serves a private Xvfb desktop from this machine through the
|
|
283
|
+
bridge, and checks that the first frame is presented, that the answer puts
|
|
284
|
+
H.264 first with NACK when the host offers it (VP9 otherwise; both descriptions
|
|
285
|
+
are written out), that a tap clicks the host, that
|
|
286
|
+
the picture keeps following the desktop afterwards, and that hardware keys
|
|
287
|
+
pressed on the simulator reach the desktop. With `DESKLINK_IOS_IPAD=1` it runs on
|
|
288
|
+
an iPad simulator and also drives a trackpad through XCUITest
|
|
289
|
+
(`test/ios-pointer/`): hover, a click-drag that selects text, a right click and
|
|
290
|
+
a scroll:
|
|
291
|
+
|
|
292
|
+
```sh
|
|
293
|
+
npm run build && cargo build --manifest-path packages/desktop-host/engine/Cargo.toml
|
|
294
|
+
env -u DISPLAY -u WAYLAND_DISPLAY DESKLINK_IOS_MAC=user@mac npm run test:ios --workspace @desklink/react-native
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
The Mac needs Xcode with an iOS simulator runtime, CocoaPods, node and
|
|
298
|
+
[`axe`](https://github.com/cameroncooke/AXe) on its login `PATH`; everything
|
|
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
|
|
302
|
+
Xcode has no simulator platform, point `DESKLINK_IOS_XCODE` at one that does;
|
|
303
|
+
`DESKLINK_IOS_RUNTIME` picks the iOS version (for example `18.6`); the
|
|
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:
|
|
312
|
+
|
|
313
|
+
```sh
|
|
314
|
+
xcrun simctl launch <device> dev.desklink.example -desklinkUrl 'ws://HOST:PORT/desktop?token=TOKEN'
|
|
315
|
+
```
|
|
316
|
+
|
|
161
317
|
## Licence
|
|
162
318
|
|
|
163
319
|
Apache-2.0. See `NOTICE`.
|
package/app.plugin.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in config plugin: an iPad trackpad's or mouse's presses reach the
|
|
3
|
+
* desktop with the button that made them — a drag selects, a secondary click
|
|
4
|
+
* is a right click. It sets `UIApplicationSupportsIndirectInputEvents`, which
|
|
5
|
+
* changes how every screen of the app receives that input, so the package
|
|
6
|
+
* never sets it on its own. Hover, scroll and the hardware keyboard work
|
|
7
|
+
* without it.
|
|
8
|
+
*
|
|
9
|
+
* "plugins": ["@desklink/react-native"]
|
|
10
|
+
*/
|
|
11
|
+
module.exports = function withDesklinkPointer(config) {
|
|
12
|
+
return {
|
|
13
|
+
...config,
|
|
14
|
+
ios: { ...config.ios, infoPlist: { ...config.ios?.infoPlist, UIApplicationSupportsIndirectInputEvents: true } },
|
|
15
|
+
};
|
|
16
|
+
};
|
package/expo-module.config.json
CHANGED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
|
|
4
|
+
|
|
5
|
+
Pod::Spec.new do |s|
|
|
6
|
+
s.name = 'DesklinkInput'
|
|
7
|
+
s.version = package['version']
|
|
8
|
+
s.summary = package['description']
|
|
9
|
+
s.description = package['description']
|
|
10
|
+
s.license = package['license']
|
|
11
|
+
s.author = 'desklink'
|
|
12
|
+
s.homepage = 'https://github.com/umeranjum17/desklink'
|
|
13
|
+
s.platforms = { :ios => '15.1' }
|
|
14
|
+
s.swift_version = '5.9'
|
|
15
|
+
s.source = { git: 'https://github.com/umeranjum17/desklink.git' }
|
|
16
|
+
s.static_framework = true
|
|
17
|
+
|
|
18
|
+
s.dependency 'ExpoModulesCore'
|
|
19
|
+
|
|
20
|
+
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' }
|
|
21
|
+
s.source_files = '*.swift'
|
|
22
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The iOS view's hardware input: the keys and the pointer React Native's touch
|
|
5
|
+
* system cannot see. The session, the picture and touch gestures stay in
|
|
6
|
+
* JavaScript; this module only reports what UIKit delivers.
|
|
7
|
+
*/
|
|
8
|
+
public final class DesklinkInputModule: Module {
|
|
9
|
+
public func definition() -> ModuleDefinition {
|
|
10
|
+
Name("DesklinkInput")
|
|
11
|
+
|
|
12
|
+
View(DesklinkInputView.self) {
|
|
13
|
+
Events("onKey", "onPointer", "onWheel")
|
|
14
|
+
|
|
15
|
+
Prop("enabled") { (view: DesklinkInputView, enabled: Bool) in
|
|
16
|
+
view.enabled = enabled
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -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.
|
|
3
|
+
"version": "0.3.1",
|
|
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"
|
|
@@ -31,6 +36,7 @@
|
|
|
31
36
|
"react-native-webrtc": ">=124"
|
|
32
37
|
},
|
|
33
38
|
"scripts": {
|
|
34
|
-
"test:reconnect": "node test/reconnect-flow.mjs"
|
|
39
|
+
"test:reconnect": "node test/reconnect-flow.mjs",
|
|
40
|
+
"test:ios": "node test/ios-flow.mjs"
|
|
35
41
|
}
|
|
36
42
|
}
|