@desklink/react-native 0.4.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 +81 -13
- package/android/build.gradle +13 -1
- package/android/src/main/java/expo/modules/desklink/DesklinkModule.kt +15 -0
- package/android/src/main/java/expo/modules/desklink/DesktopSession.kt +27 -0
- package/android/src/main/java/expo/modules/desklink/DesktopView.kt +77 -16
- package/package.json +1 -1
- package/src/ClipboardConfirmation.tsx +57 -0
- package/src/DesktopView.ios.spec.tsx +171 -6
- package/src/DesktopView.ios.tsx +65 -20
- package/src/DesktopView.tsx +23 -2
- package/src/DesktopView.web.tsx +12 -0
- package/src/ModifierKeys.tsx +77 -0
- package/src/controls.spec.tsx +205 -0
- package/src/index.ts +4 -1
- package/src/native.ios.ts +5 -0
- package/src/native.ts +2 -1
- package/src/native.web.ts +5 -0
- package/src/protocol.ts +2 -1
- package/src/useDesktopSession.ts +38 -3
- package/src/videoStats.ts +16 -0
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,7 +79,12 @@ 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());
|
|
86
|
+
// Optional receiver evidence: codec, decoder and video counters; no ICE addresses.
|
|
87
|
+
const stats = await desktop.getStats();
|
|
79
88
|
```
|
|
80
89
|
|
|
81
90
|
Pairing is the only trust boundary. `authorize` is the app's pairing check: it
|
|
@@ -127,15 +136,25 @@ heat and battery.
|
|
|
127
136
|
so the iOS receiver asks for H.264 by putting it first in the offer it
|
|
128
137
|
applies; a host with an H.264 encoder then sends H.264, and one without sends
|
|
129
138
|
VP9. Neither platform links a second WebRTC stack.
|
|
130
|
-
- **A sharp, zoomable picture.**
|
|
131
|
-
|
|
132
|
-
|
|
139
|
+
- **A sharp, zoomable picture.** In portrait the default is fit-width;
|
|
140
|
+
in landscape it covers the screen below the status bar (as far as the 2.5
|
|
141
|
+
zoom limit allows). The picture stays undistorted. One finger pans cropped
|
|
142
|
+
content; pinch to zoom before panning when the whole desktop already fits.
|
|
143
|
+
Pass the screen's safe-area `insets` (for example from
|
|
144
|
+
`react-native-safe-area-context`) so the default starts below the status bar
|
|
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
|
|
148
|
+
area, while landscape coverage extends beneath the cutout and home indicator
|
|
149
|
+
to limit black bars. A pinch zooms up to 2.5 view
|
|
150
|
+
pixels per desktop pixel, or out to the whole desktop. On Android the decoded frame is copied once into the view's
|
|
133
151
|
own texture and drawn with a multi-tap filter when it is shown smaller than
|
|
134
152
|
its size, so a fitted 4K desktop does not alias and a pinch redraws at once.
|
|
135
153
|
iOS uses `RTCView`'s native renderer. That renderer draws a frame only when
|
|
136
154
|
its timestamp is new, and a receiver honouring the engine's zero playout delay
|
|
137
155
|
stamps every frame alike, so the iOS peer declines that hint and keeps the
|
|
138
|
-
receiver's usual jitter buffer. `fitToView()` shows the whole desktop
|
|
156
|
+
receiver's usual jitter buffer. `fitToView()` shows the whole desktop; a new
|
|
157
|
+
desktop size or a rotation reapplies the orientation default from the top-left.
|
|
139
158
|
- **A pointer a phone can see.** Once a touch has sent the desktop's pointer
|
|
140
159
|
somewhere, the view draws an outlined arrow there at a readable size, over a
|
|
141
160
|
picture whose own cursor is a few pixels tall or not captured at all. On iOS,
|
|
@@ -148,7 +167,7 @@ heat and battery.
|
|
|
148
167
|
(`keyboardClearance`). Android follows the keyboard animation and keeps the
|
|
149
168
|
pointer in sight; iOS follows the keyboard show/hide bounds.
|
|
150
169
|
- **Contained geometry.** Touch maps through the picture's actual placement.
|
|
151
|
-
|
|
170
|
+
On the whole desktop, a one-finger drag starting in the letterbox does not move the pointer; a
|
|
152
171
|
second finger on the picture can still start a pinch or scroll. A drag that
|
|
153
172
|
leaves the picture is held to its edge rather than released somewhere unseen.
|
|
154
173
|
- **The gestures remote-desktop viewers settled on,** with a slop threshold:
|
|
@@ -181,7 +200,15 @@ heat and battery.
|
|
|
181
200
|
- **The keys a phone lacks.** `modifiers`, `tapModifier` and `pressKey` give
|
|
182
201
|
sticky Ctrl and Shift: tap arms one for the next key, tap again locks it.
|
|
183
202
|
While one is armed, the next key or character the phone's keyboard types is
|
|
184
|
-
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.
|
|
185
212
|
- **Readiness is a rendered frame.** Android marks the first draw; iOS marks
|
|
186
213
|
the first `RTCView` video-dimensions callback and ignores later ones, so a
|
|
187
214
|
resize or remount during a reconnect does not read as live. Neither marks
|
|
@@ -230,6 +257,15 @@ heat and battery.
|
|
|
230
257
|
session close and a lost control channel release what the desktop was holding.
|
|
231
258
|
- **Explicit clipboard.** Two methods, called on a user action while control is
|
|
232
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.
|
|
233
269
|
|
|
234
270
|
### `Signaling`
|
|
235
271
|
|
|
@@ -283,11 +319,12 @@ What the consuming app — not this package — must get right before review.
|
|
|
283
319
|
disabling input on background; the engine releases held keys within
|
|
284
320
|
seconds of the path dying, so nothing stays stuck.
|
|
285
321
|
|
|
286
|
-
##
|
|
322
|
+
## Example app proof
|
|
287
323
|
|
|
288
324
|
`example/` is the smallest app that shows a desktop: a native (non-Expo Go)
|
|
289
325
|
build with `react-native-webrtc`, connecting to `desklink-host bridge` with the
|
|
290
|
-
URL it is launched with
|
|
326
|
+
URL it is launched with on iOS, or the connection link it is opened with on
|
|
327
|
+
Android. Its hidden accessibility status shows plain messages: `Connected`
|
|
291
328
|
once a frame is presented, `Can’t reach your desktop. Reconnecting…` while
|
|
292
329
|
retrying, and `Your desktop ended this session` after revocation. It never
|
|
293
330
|
displays the bridge URL, pairing token, raw failure text or frame metrics.
|
|
@@ -297,28 +334,44 @@ bridge, and checks that the first frame is presented, that the answer puts
|
|
|
297
334
|
H.264 first with NACK when the host offers it (VP9 otherwise; both descriptions
|
|
298
335
|
are written out), that a tap clicks the host, that
|
|
299
336
|
the picture keeps following the desktop afterwards, and that hardware keys
|
|
300
|
-
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
|
|
301
346
|
an iPad simulator and also drives a trackpad through XCUITest
|
|
302
347
|
(`test/ios-pointer/`): hover, a click-drag that selects text, a right click and
|
|
303
348
|
a scroll:
|
|
304
349
|
|
|
305
350
|
```sh
|
|
306
351
|
npm run build && cargo build --manifest-path packages/desktop-host/engine/Cargo.toml
|
|
307
|
-
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
|
|
308
354
|
```
|
|
309
355
|
|
|
310
356
|
The Mac needs Xcode with an iOS simulator runtime, CocoaPods, node and
|
|
311
357
|
[`axe`](https://github.com/cameroncooke/AXe) on its login `PATH`; everything
|
|
312
358
|
the run builds or caches stays in `~/desklink-ios` there. The run creates a
|
|
313
|
-
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
|
|
314
362
|
[private-lab guidance](../../README.md#develop). When the selected
|
|
315
363
|
Xcode has no simulator platform, point `DESKLINK_IOS_XCODE` at one that does;
|
|
316
364
|
`DESKLINK_IOS_RUNTIME` picks the iOS version (for example `18.6`); the
|
|
317
365
|
script's header lists the other settings. To capture the cursor tip proof, set
|
|
318
366
|
`DESKLINK_IOS_POINTER_PROOF=before` or `after`; the run writes a 4x crop, device
|
|
319
367
|
frame, proof metadata, and host click log under `DESKLINK_IOS_OUT`. Set
|
|
320
|
-
`DESKLINK_IOS_APP` to reuse an app bundle under
|
|
321
|
-
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
|
|
322
375
|
once with `after` after it, on both iPhone and iPad simulators, to compare the
|
|
323
376
|
pointer crop and verify the host click is within two desktop pixels of the tip.
|
|
324
377
|
To try the example by hand against any bridge:
|
|
@@ -327,6 +380,21 @@ To try the example by hand against any bridge:
|
|
|
327
380
|
xcrun simctl launch <device> dev.desklink.example -desklinkUrl 'ws://HOST:PORT/desktop?token=TOKEN'
|
|
328
381
|
```
|
|
329
382
|
|
|
383
|
+
On Android, build the same example with `npm run android` and open a connection
|
|
384
|
+
link whose `url` query parameter is the URL-encoded bridge WebSocket address:
|
|
385
|
+
`desklink-example://connect?url=ws%3A%2F%2FHOST%3APORT%2Fdesktop%3Ftoken%3DTOKEN`.
|
|
386
|
+
The example allows the bridge's plaintext private-network connection; consumer
|
|
387
|
+
apps choose their own transport policy. A new link replaces the open session.
|
|
388
|
+
The live picture has no diagnostic overlay. Its accessibility status remains
|
|
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.
|
|
392
|
+
|
|
393
|
+
For device evidence, an optional `report` query parameter accepts a URL-encoded
|
|
394
|
+
HTTP endpoint. While live, the example posts receiver statistics and connection
|
|
395
|
+
and first-presentation timestamps once per second. Collection failures do not
|
|
396
|
+
interrupt the session. Without that parameter, no evidence is posted.
|
|
397
|
+
|
|
330
398
|
## Licence
|
|
331
399
|
|
|
332
400
|
Apache-2.0. See `NOTICE`.
|
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
|
|
@@ -5,6 +5,7 @@ import android.content.pm.ActivityInfo
|
|
|
5
5
|
import android.content.res.Configuration
|
|
6
6
|
import expo.modules.kotlin.modules.Module
|
|
7
7
|
import expo.modules.kotlin.modules.ModuleDefinition
|
|
8
|
+
import expo.modules.kotlin.Promise
|
|
8
9
|
import java.util.concurrent.ConcurrentHashMap
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -113,6 +114,12 @@ class DesklinkModule : Module() {
|
|
|
113
114
|
|
|
114
115
|
Function("isAvailable") { true }
|
|
115
116
|
|
|
117
|
+
AsyncFunction("getStats") { id: String, promise: Promise ->
|
|
118
|
+
val session = sessions[id]
|
|
119
|
+
if (session == null) promise.resolve("[]")
|
|
120
|
+
else session.getStats { promise.resolve(it) }
|
|
121
|
+
}
|
|
122
|
+
|
|
116
123
|
View(DesktopView::class) {
|
|
117
124
|
Name("DesklinkSurface")
|
|
118
125
|
|
|
@@ -126,6 +133,14 @@ class DesklinkModule : Module() {
|
|
|
126
133
|
view.setKeyboardClearance(clearance ?: 0f)
|
|
127
134
|
}
|
|
128
135
|
|
|
136
|
+
Prop("insets") { view: DesktopView, insets: List<Double>? ->
|
|
137
|
+
val edges = insets.orEmpty()
|
|
138
|
+
view.setInsets(
|
|
139
|
+
edges.getOrNull(0)?.toFloat() ?: 0f, edges.getOrNull(1)?.toFloat() ?: 0f,
|
|
140
|
+
edges.getOrNull(2)?.toFloat() ?: 0f, edges.getOrNull(3)?.toFloat() ?: 0f,
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
129
144
|
Prop("gestures") { view: DesktopView, gestures: String? ->
|
|
130
145
|
view.setGestures(gestures ?: "desktop")
|
|
131
146
|
}
|
|
@@ -370,6 +370,33 @@ class DesktopSession(
|
|
|
370
370
|
return servers
|
|
371
371
|
}
|
|
372
372
|
|
|
373
|
+
/** A bounded diagnostics snapshot; never expose ICE addresses or control content. */
|
|
374
|
+
@Synchronized
|
|
375
|
+
fun getStats(done: (String) -> Unit) {
|
|
376
|
+
if (closed) {
|
|
377
|
+
done("[]")
|
|
378
|
+
return
|
|
379
|
+
}
|
|
380
|
+
io.execute {
|
|
381
|
+
val connection = peer
|
|
382
|
+
if (closed || connection == null) {
|
|
383
|
+
done("[]")
|
|
384
|
+
return@execute
|
|
385
|
+
}
|
|
386
|
+
connection.getStats { report ->
|
|
387
|
+
val fields = setOf("kind", "mediaType", "codecId", "mimeType", "clockRate", "payloadType",
|
|
388
|
+
"packetsReceived", "packetsLost", "bytesReceived", "framesReceived", "framesDecoded",
|
|
389
|
+
"keyFramesDecoded", "framesDropped", "framesPerSecond", "frameWidth", "frameHeight",
|
|
390
|
+
"nackCount", "pliCount", "firCount", "totalDecodeTime", "decoderImplementation", "jitterBufferDelay")
|
|
391
|
+
val stats = report.statsMap.values.filter { it.type == "inbound-rtp" || it.type == "codec" }
|
|
392
|
+
.map { stat -> JSONObject(stat.members.filterKeys { it in fields }).apply {
|
|
393
|
+
put("id", stat.id); put("type", stat.type); put("timestampUs", stat.timestampUs)
|
|
394
|
+
} }
|
|
395
|
+
done(org.json.JSONArray(stats).toString())
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
373
400
|
private fun observer(target: Long) = object : PeerConnection.Observer {
|
|
374
401
|
override fun onSignalingChange(state: PeerConnection.SignalingState?) = Unit
|
|
375
402
|
|
|
@@ -65,7 +65,8 @@ private const val MIN_WHEEL_STEP = 0.05f
|
|
|
65
65
|
* - tap: click where the finger lands; two taps: a double click on the same spot;
|
|
66
66
|
* - press and hold: a right click on release, or drag after it to hold the left
|
|
67
67
|
* button (select text, move a window);
|
|
68
|
-
* - one finger: move around a zoomed-in desktop
|
|
68
|
+
* - one finger: move around a zoomed-in desktop — the picture starts filled,
|
|
69
|
+
* covering the view, so this is the default; on the whole desktop,
|
|
69
70
|
* move its pointer, which follows the finger without pressing a button —
|
|
70
71
|
* unless `gestures` is `browser`, where it scrolls the page, or `device`,
|
|
71
72
|
* where it lands with the button down and drags with it held;
|
|
@@ -102,11 +103,23 @@ class DesktopView(context: Context, appContext: AppContext) : ExpoView(context,
|
|
|
102
103
|
|
|
103
104
|
// The picture's placement: surface pixels per desktop pixel, and where the
|
|
104
105
|
// desktop's top-left corner sits in this view. `fitted` keeps a picture that
|
|
105
|
-
// was showing the whole desktop showing it when the view changes size
|
|
106
|
+
// was showing the whole desktop showing it when the view changes size;
|
|
107
|
+
// `filling` keeps the default, a picture covering the view, until the user
|
|
108
|
+
// zooms or asks for the whole desktop. `filledScale` is the fill it was last
|
|
109
|
+
// placed at, so a new fill (a rotation, a new surface) starts at the
|
|
110
|
+
// desktop's top-left corner again.
|
|
106
111
|
private var scale = 1f
|
|
107
112
|
private var originX = 0f
|
|
108
113
|
private var originY = 0f
|
|
109
114
|
private var fitted = true
|
|
115
|
+
private var filling = true
|
|
116
|
+
private var filledScale = 0f
|
|
117
|
+
|
|
118
|
+
/** What the system covers at each edge (bars, cutout), in pixels: left, top, right, bottom. */
|
|
119
|
+
private var insetLeft = 0f
|
|
120
|
+
private var insetTop = 0f
|
|
121
|
+
private var insetRight = 0f
|
|
122
|
+
private var insetBottom = 0f
|
|
110
123
|
|
|
111
124
|
/**
|
|
112
125
|
* How much of this view's bottom the phone's keyboard, and the app's own
|
|
@@ -254,6 +267,8 @@ class DesktopView(context: Context, appContext: AppContext) : ExpoView(context,
|
|
|
254
267
|
surfaceWidth = width
|
|
255
268
|
surfaceHeight = height
|
|
256
269
|
fitted = true
|
|
270
|
+
filling = true
|
|
271
|
+
filledScale = 0f
|
|
257
272
|
// A point on the old geometry says nothing about the new one.
|
|
258
273
|
pointerAt = null
|
|
259
274
|
layoutPicture()
|
|
@@ -296,6 +311,22 @@ class DesktopView(context: Context, appContext: AppContext) : ExpoView(context,
|
|
|
296
311
|
/** Show the whole desktop again. */
|
|
297
312
|
fun fitToView() {
|
|
298
313
|
fitted = true
|
|
314
|
+
filling = false
|
|
315
|
+
layoutPicture()
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* What the system covers at each edge of the view, in points. The picture
|
|
320
|
+
* still reaches under it, but starts and pans inside it.
|
|
321
|
+
*/
|
|
322
|
+
fun setInsets(top: Float, left: Float, bottom: Float, right: Float) {
|
|
323
|
+
val density = resources.displayMetrics.density
|
|
324
|
+
if (top * density == insetTop && left * density == insetLeft && bottom * density == insetBottom && right * density == insetRight) return
|
|
325
|
+
insetTop = top * density
|
|
326
|
+
insetLeft = left * density
|
|
327
|
+
insetBottom = bottom * density
|
|
328
|
+
insetRight = right * density
|
|
329
|
+
filledScale = 0f
|
|
299
330
|
layoutPicture()
|
|
300
331
|
}
|
|
301
332
|
|
|
@@ -347,9 +378,9 @@ class DesktopView(context: Context, appContext: AppContext) : ExpoView(context,
|
|
|
347
378
|
*/
|
|
348
379
|
private fun coverBottom(next: Float) {
|
|
349
380
|
if (abs(next - covered) < 0.5f) return
|
|
350
|
-
val before =
|
|
381
|
+
val before = visibleBottom()
|
|
351
382
|
covered = next
|
|
352
|
-
val visible =
|
|
383
|
+
val visible = visibleBottom()
|
|
353
384
|
val at = pointerAt
|
|
354
385
|
if (at == null) {
|
|
355
386
|
originY += (visible - before) / 2f
|
|
@@ -361,7 +392,13 @@ class DesktopView(context: Context, appContext: AppContext) : ExpoView(context,
|
|
|
361
392
|
layoutPicture()
|
|
362
393
|
}
|
|
363
394
|
|
|
364
|
-
|
|
395
|
+
/** Where the uncovered part of the view ends: above the keyboard, or above what the system covers. */
|
|
396
|
+
private fun visibleBottom(): Float = max(insetTop + 1f, min(height - covered, height - insetBottom))
|
|
397
|
+
|
|
398
|
+
private fun safeWidth(): Float = max(1f, width - insetLeft - insetRight)
|
|
399
|
+
|
|
400
|
+
/** The whole uncovered height, keyboard or not: the keyboard moves the picture rather than shrinking it. */
|
|
401
|
+
private fun safeHeight(): Float = max(1f, height - insetTop - insetBottom)
|
|
365
402
|
|
|
366
403
|
private fun inputMethods() = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
|
367
404
|
|
|
@@ -386,8 +423,10 @@ class DesktopView(context: Context, appContext: AppContext) : ExpoView(context,
|
|
|
386
423
|
|
|
387
424
|
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
|
388
425
|
super.onSizeChanged(w, h, oldw, oldh)
|
|
389
|
-
// A rotation:
|
|
390
|
-
|
|
426
|
+
// A rotation: a filled picture starts again at its top-left corner;
|
|
427
|
+
// a zoomed one keeps what the user was looking at.
|
|
428
|
+
if (filling) filledScale = 0f
|
|
429
|
+
else if (oldw > 0 && oldh > 0 && !fitted) {
|
|
391
430
|
originX += (w - oldw) / 2f
|
|
392
431
|
originY += (h - oldh) / 2f
|
|
393
432
|
}
|
|
@@ -416,34 +455,54 @@ class DesktopView(context: Context, appContext: AppContext) : ExpoView(context,
|
|
|
416
455
|
*/
|
|
417
456
|
private fun fitScale(): Float {
|
|
418
457
|
if (width == 0 || height == 0 || surfaceWidth == 0 || surfaceHeight == 0) return 1f
|
|
419
|
-
return min(
|
|
458
|
+
return min(safeWidth() / surfaceWidth, safeHeight() / surfaceHeight)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private fun fillScale(): Float {
|
|
462
|
+
val cover = if (width <= height) safeWidth() / surfaceWidth
|
|
463
|
+
else max(width.toFloat() / surfaceWidth, (height - insetTop) / surfaceHeight)
|
|
464
|
+
return max(fitScale(), min(cover, MAX_SCALE))
|
|
420
465
|
}
|
|
421
466
|
|
|
422
|
-
/** Re-derive the placement from `fitted`, the zoom limits and the edges. */
|
|
467
|
+
/** Re-derive the placement from `filling`, `fitted`, the zoom limits and the edges. */
|
|
423
468
|
private fun layoutPicture() {
|
|
424
469
|
if (surfaceWidth == 0 || surfaceHeight == 0 || width == 0 || height == 0) return
|
|
425
470
|
val fit = fitScale()
|
|
426
|
-
|
|
471
|
+
if (filling) {
|
|
472
|
+
val fill = fillScale()
|
|
473
|
+
if (fill != filledScale) {
|
|
474
|
+
scale = fill
|
|
475
|
+
filledScale = fill
|
|
476
|
+
// The desktop's top-left corner, where a page's first words are.
|
|
477
|
+
originX = insetLeft
|
|
478
|
+
originY = insetTop
|
|
479
|
+
}
|
|
480
|
+
} else {
|
|
481
|
+
scale = if (fitted) fit else scale.coerceIn(fit, max(fit, MAX_SCALE))
|
|
482
|
+
}
|
|
427
483
|
fitted = scale <= fit * 1.001f
|
|
428
484
|
clampOrigin()
|
|
429
485
|
publishTransform()
|
|
430
486
|
}
|
|
431
487
|
|
|
432
488
|
/**
|
|
433
|
-
* A picture smaller than
|
|
434
|
-
* one
|
|
489
|
+
* A picture smaller than the uncovered part of the view is centred in it on
|
|
490
|
+
* that axis; a larger one can move until either edge reaches that part's
|
|
491
|
+
* edge. Above the keyboard, only the part above it counts.
|
|
435
492
|
*/
|
|
436
493
|
private fun clampOrigin() {
|
|
437
494
|
val pictureWidth = surfaceWidth * scale
|
|
438
495
|
val pictureHeight = surfaceHeight * scale
|
|
439
|
-
val
|
|
440
|
-
|
|
441
|
-
|
|
496
|
+
val right = width - insetRight
|
|
497
|
+
val bottom = visibleBottom()
|
|
498
|
+
originX = if (pictureWidth <= right - insetLeft) insetLeft + (right - insetLeft - pictureWidth) / 2f else originX.coerceIn(right - pictureWidth, insetLeft)
|
|
499
|
+
originY = if (pictureHeight <= bottom - insetTop) insetTop + (if (filling) 0f else (bottom - insetTop - pictureHeight) / 2f) else originY.coerceIn(bottom - pictureHeight, insetTop)
|
|
442
500
|
}
|
|
443
501
|
|
|
444
502
|
private fun zoomAround(focusX: Float, focusY: Float, factor: Float) {
|
|
445
503
|
val fit = fitScale()
|
|
446
504
|
val next = (scale * factor).coerceIn(fit, max(fit, MAX_SCALE))
|
|
505
|
+
filling = false
|
|
447
506
|
val applied = next / scale
|
|
448
507
|
originX = focusX - (focusX - originX) * applied
|
|
449
508
|
originY = focusY - (focusY - originY) * applied
|
|
@@ -622,7 +681,9 @@ class DesktopView(context: Context, appContext: AppContext) : ExpoView(context,
|
|
|
622
681
|
return true
|
|
623
682
|
}
|
|
624
683
|
// The whole desktop has nowhere to move to, so the finger moves the
|
|
625
|
-
// desktop's pointer instead, without a button.
|
|
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.
|
|
626
687
|
gesture = if (!fitted) Gesture.PAN else if (downOnPicture) Gesture.HOVER else Gesture.LETTERBOX
|
|
627
688
|
lastX = event.x
|
|
628
689
|
lastY = 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
|
+
});
|