@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 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.** The desktop fits the view by default; a pinch
131
- zooms up to 2.5 view pixels per desktop pixel and one finger moves around the
132
- zoomed desktop. On Android the decoded frame is copied once into the view's
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 again.
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
- A one-finger drag starting in the letterbox does not move the pointer; a
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. The app draws the keys.
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
- ## iOS simulator proof
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. Its status line shows plain messages: `Connected`
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. With `DESKLINK_IOS_IPAD=1` it runs on
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 npm run test:ios --workspace @desklink/react-native
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. For host desktop isolation, see the
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 `DESKLINK_IOS_DIR` when running
321
- with `DESKLINK_IOS_SKIP_BUILD=1`. Run once with `before` before the change and
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`.
@@ -1,9 +1,21 @@
1
1
  apply plugin: 'com.android.library'
2
- apply plugin: 'kotlin-android'
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; on the whole 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 = visibleHeight()
381
+ val before = visibleBottom()
351
382
  covered = next
352
- val visible = visibleHeight()
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
- private fun visibleHeight(): Float = max(1f, height - covered)
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: keep what the user was looking at.
390
- if (oldw > 0 && oldh > 0 && !fitted) {
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(width.toFloat() / surfaceWidth, height.toFloat() / surfaceHeight)
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
- scale = if (fitted) fit else scale.coerceIn(fit, max(fit, MAX_SCALE))
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 what is visible is centred on that axis; a larger
434
- * one covers it. Above the keyboard, only the uncovered part counts.
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 visible = visibleHeight()
440
- originX = if (pictureWidth <= width) (width - pictureWidth) / 2f else originX.coerceIn(width - pictureWidth, 0f)
441
- originY = if (pictureHeight <= visible) (visible - pictureHeight) / 2f else originY.coerceIn(visible - pictureHeight, 0f)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desklink/react-native",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",
@@ -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
+ });