@desklink/react-native 0.5.2 → 0.5.3
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 +15 -4
- package/android/src/main/java/expo/modules/desklink/DesklinkModule.kt +4 -0
- package/android/src/main/java/expo/modules/desklink/DesktopView.kt +18 -3
- package/app.plugin.js +224 -1
- package/package.json +1 -1
- package/src/DesktopView.ios.spec.tsx +4 -3
- package/src/DesktopView.ios.tsx +5 -2
- package/src/DesktopView.tsx +8 -2
- package/src/native.web.ts +9 -0
- package/src/protocol.ts +6 -1
- package/src/useDesktopSession.ts +5 -1
package/README.md
CHANGED
|
@@ -156,7 +156,8 @@ view.
|
|
|
156
156
|
VP9. Neither platform links a second WebRTC stack.
|
|
157
157
|
- **A sharp, zoomable picture.** In portrait the default is fit-width;
|
|
158
158
|
in landscape it covers the screen below the status bar (as far as the 2.5
|
|
159
|
-
zoom limit allows). The
|
|
159
|
+
zoom limit allows). The `cover` prop opts portrait into the same
|
|
160
|
+
coverage, cropping the overflowing sides instead of letterboxing. The picture stays undistorted. One finger pans cropped
|
|
160
161
|
content; pinch to zoom before panning when the whole desktop already fits.
|
|
161
162
|
Pass the screen's safe-area `insets` (for example from
|
|
162
163
|
`react-native-safe-area-context`) so the default starts below the status bar
|
|
@@ -230,9 +231,10 @@ view.
|
|
|
230
231
|
instead of sitting at 44 pt. An app may draw its own keys
|
|
231
232
|
from `modifiers` instead.
|
|
232
233
|
- **Readiness is a rendered frame.** Android marks the first draw; iOS marks
|
|
233
|
-
the first `RTCView` video-dimensions callback and ignores later ones
|
|
234
|
-
|
|
235
|
-
a
|
|
234
|
+
the first `RTCView` video-dimensions callback and ignores later ones; web
|
|
235
|
+
marks the first presented frame and ignores later repaints, so a resize,
|
|
236
|
+
remount, or renegotiation repaint during a reconnect does not read as live.
|
|
237
|
+
No platform marks a track or ICE connection as live.
|
|
236
238
|
- **Connection states, as they happen.** The session status is `idle`,
|
|
237
239
|
`opening`, `connecting`, `live`, `reconnecting`, `ended` or `failed`. The
|
|
238
240
|
peer's ICE state and the engine's `session.state` both feed it: a
|
|
@@ -312,6 +314,15 @@ interface Signaling {
|
|
|
312
314
|
candidates, state and revocation. Pixels and input do **not** use this
|
|
313
315
|
channel — they are the desktop's own WebRTC session.
|
|
314
316
|
|
|
317
|
+
`authorize()` must wait for the authenticated carrier to open before returning
|
|
318
|
+
its `Signaling`. When that carrier is gone, reject pending and future requests
|
|
319
|
+
with `code: 'transport'`; do not queue them indefinitely. An adapter may emit
|
|
320
|
+
`{ kind: 'carrier-closed' }` once to notify subscribers of that loss. This is
|
|
321
|
+
not the native peer's `closed` event or an engine revocation. Preserve the
|
|
322
|
+
engine's `session.revoked` code: `transport` indicates path loss; other codes
|
|
323
|
+
(or no code) are final. The demo adapter bounds connection setup to five seconds
|
|
324
|
+
and closes its socket and pending queue when disposed.
|
|
325
|
+
|
|
315
326
|
## Shipping on iOS
|
|
316
327
|
|
|
317
328
|
What the consuming app — not this package — must get right before review.
|
|
@@ -145,6 +145,10 @@ class DesklinkModule : Module() {
|
|
|
145
145
|
view.setGestures(gestures ?: "desktop")
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
Prop("cover") { view: DesktopView, cover: Boolean? ->
|
|
149
|
+
view.setCover(cover ?: false)
|
|
150
|
+
}
|
|
151
|
+
|
|
148
152
|
OnViewDestroys { view: DesktopView -> view.release() }
|
|
149
153
|
|
|
150
154
|
OnViewDidUpdateProps { view: DesktopView ->
|
|
@@ -114,6 +114,19 @@ class DesktopView(context: Context, appContext: AppContext) : ExpoView(context,
|
|
|
114
114
|
private var fitted = true
|
|
115
115
|
private var filling = true
|
|
116
116
|
private var filledScale = 0f
|
|
117
|
+
/**
|
|
118
|
+
* Opt-in cover: the picture covers the view in every orientation, cropping
|
|
119
|
+
* the overflowing axis, instead of fitting the portrait width. Off by
|
|
120
|
+
* default; the default path below is untouched.
|
|
121
|
+
*/
|
|
122
|
+
private var cover = false
|
|
123
|
+
|
|
124
|
+
fun setCover(value: Boolean) {
|
|
125
|
+
if (value == cover) return
|
|
126
|
+
cover = value
|
|
127
|
+
filledScale = 0f
|
|
128
|
+
layoutPicture()
|
|
129
|
+
}
|
|
117
130
|
|
|
118
131
|
/** What the system covers at each edge (bars, cutout), in pixels: left, top, right, bottom. */
|
|
119
132
|
private var insetLeft = 0f
|
|
@@ -459,9 +472,11 @@ class DesktopView(context: Context, appContext: AppContext) : ExpoView(context,
|
|
|
459
472
|
}
|
|
460
473
|
|
|
461
474
|
private fun fillScale(): Float {
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
475
|
+
// Portrait fits the width unless the app opted into cover; landscape
|
|
476
|
+
// covers. Either way the fill never goes below the fit or above the zoom cap.
|
|
477
|
+
val wide = if (width <= height && !cover) safeWidth() else width.toFloat()
|
|
478
|
+
val fill = max(wide / surfaceWidth, (height - insetTop) / surfaceHeight)
|
|
479
|
+
return max(fitScale(), min(fill, MAX_SCALE))
|
|
465
480
|
}
|
|
466
481
|
|
|
467
482
|
/** Re-derive the placement from `filling`, `fitted`, the zoom limits and the edges. */
|
package/app.plugin.js
CHANGED
|
@@ -7,10 +7,233 @@
|
|
|
7
7
|
* without it.
|
|
8
8
|
*
|
|
9
9
|
* "plugins": ["@desklink/react-native"]
|
|
10
|
+
*
|
|
11
|
+
* The plugin also repairs `expo-modules-jsi` 57.x for Xcode 26 (Swift 6.2).
|
|
12
|
+
* Two defects, both fatal, both fixed upstream in expo-modules-jsi 58 (SDK 58
|
|
13
|
+
* is out of scope and the 57 line has no fixed patch release):
|
|
14
|
+
*
|
|
15
|
+
* 1. `RuntimeScheduler.h` annotates two C++ constructors with
|
|
16
|
+
* `SWIFT_RETURNS_RETAINED`, which Swift 6.2 rejects as a hard error
|
|
17
|
+
* ("not returning a SWIFT_SHARED_REFERENCE type"), so no 57-line app
|
|
18
|
+
* compiles. Mirrored from 58: private constructors plus static `create`
|
|
19
|
+
* factories carrying the annotation, with the Swift call sites moved over.
|
|
20
|
+
* 2. `JavaScriptRuntime.swift` captures raw JSI pointers through
|
|
21
|
+
* `nonisolated(unsafe)` locals into `JavaScriptActor.assumeIsolated`
|
|
22
|
+
* closures, which Swift 6.2 rejects ("sending ... risks causing data
|
|
23
|
+
* races") now that `assumeIsolated` takes a `sending` closure. Mirrored
|
|
24
|
+
* from 58: an `UncheckedSendable` box plus `.value` at the use sites.
|
|
25
|
+
*
|
|
26
|
+
* Applied to the installed package at prebuild time, before `pod install`
|
|
27
|
+
* compiles it. Scoped to the 57 line: other versions skip untouched.
|
|
28
|
+
*/
|
|
29
|
+
const fs = require('fs');
|
|
30
|
+
const path = require('path');
|
|
31
|
+
|
|
32
|
+
const JSI_HEADER = 'apple/Sources/ExpoModulesJSI-Cxx/include/RuntimeScheduler.h';
|
|
33
|
+
const JSI_SWIFT = 'apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRuntime.swift';
|
|
34
|
+
const JSI_BOX = 'apple/Sources/ExpoModulesJSI/Utilities/UncheckedSendable.swift';
|
|
35
|
+
|
|
36
|
+
// The exact 57.x constructor block: constructors carrying the annotation that
|
|
37
|
+
// Swift 6.2 rejects. (\u2014 is the em dash in the original comment.)
|
|
38
|
+
const BROKEN_CONSTRUCTORS = `public:
|
|
39
|
+
/**
|
|
40
|
+
Constructs a scheduler bound to a host-provided native RuntimeScheduler.
|
|
41
|
+
\`scheduleTask\` dispatches through \`fn\`, which the host implements against
|
|
42
|
+
the real react::RuntimeScheduler.
|
|
43
|
+
*/
|
|
44
|
+
SWIFT_RETURNS_RETAINED RuntimeScheduler(void *scheduler, ScheduleFn fn) noexcept
|
|
45
|
+
: nativeScheduler(scheduler), scheduleFn(fn) {}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
Constructs a no-op scheduler. Scheduled tasks run synchronously on the
|
|
49
|
+
caller's thread \u2014 intended for standalone runtimes (e.g. tests) that have
|
|
50
|
+
no React scheduler.
|
|
51
|
+
*/
|
|
52
|
+
SWIFT_RETURNS_RETAINED RuntimeScheduler() {}`;
|
|
53
|
+
|
|
54
|
+
// The 58-shaped replacement: unannotated private constructors plus annotated
|
|
55
|
+
// static factories, which every Swift version accepts.
|
|
56
|
+
const FIXED_CONSTRUCTORS = ` RuntimeScheduler(void *scheduler, ScheduleFn fn) noexcept
|
|
57
|
+
: nativeScheduler(scheduler), scheduleFn(fn) {}
|
|
58
|
+
|
|
59
|
+
RuntimeScheduler() noexcept {}
|
|
60
|
+
|
|
61
|
+
public:
|
|
62
|
+
// Swift creates instances through the static \`create\` functions below, not
|
|
63
|
+
// the constructors: \`SWIFT_RETURNS_RETAINED\` on a constructor is a hard
|
|
64
|
+
// error on Swift 6.2 (Xcode 26), while a static function returning the shared
|
|
65
|
+
// reference accepts the annotation on every Swift version. This mirrors the
|
|
66
|
+
// fix Expo ships in expo-modules-jsi 58; the 57 line has no such release.
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
Creates a scheduler bound to a host-provided native RuntimeScheduler.
|
|
70
|
+
\`scheduleTask\` dispatches through \`fn\`, which the host implements against
|
|
71
|
+
the real react::RuntimeScheduler.
|
|
72
|
+
*/
|
|
73
|
+
static RuntimeScheduler *create(void *scheduler, ScheduleFn fn) noexcept SWIFT_RETURNS_RETAINED {
|
|
74
|
+
return new RuntimeScheduler(scheduler, fn);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
Creates a no-op scheduler. Scheduled tasks run synchronously on the
|
|
79
|
+
caller's thread \u2014 intended for standalone runtimes (e.g. tests) that have
|
|
80
|
+
no React scheduler.
|
|
81
|
+
*/
|
|
82
|
+
static RuntimeScheduler *create() noexcept SWIFT_RETURNS_RETAINED {
|
|
83
|
+
return new RuntimeScheduler();
|
|
84
|
+
}`;
|
|
85
|
+
|
|
86
|
+
// Verbatim from expo-modules-jsi 58's Utilities/UncheckedSendable.swift: an
|
|
87
|
+
// allocation-free `@unchecked Sendable` box. Swift 6.2 still reports
|
|
88
|
+
// "sending ... risks causing data races" for `nonisolated(unsafe)` locals
|
|
89
|
+
// captured by a `sending` closure, while a captured `Sendable` value passes.
|
|
90
|
+
const UNCHECKED_SENDABLE = `/// Immutable wrapper that makes a non-Sendable value capturable by a \`@Sendable\` closure without compiler enforcement.
|
|
91
|
+
/// Unlike \`NonisolatedUnsafeVar\`, it is a struct, so wrapping a value doesn't allocate.
|
|
92
|
+
///
|
|
93
|
+
/// Use it instead of a \`nonisolated(unsafe) let\` local captured by such a closure: Swift 6.2 still reports
|
|
94
|
+
/// "sending '...' risks causing data races" for those, while a capture of a \`Sendable\` value passes on every version.
|
|
95
|
+
/// The caller is responsible for making sure the value is never accessed concurrently.
|
|
96
|
+
internal struct UncheckedSendable<Value>: @unchecked Sendable {
|
|
97
|
+
let value: Value
|
|
98
|
+
|
|
99
|
+
init(_ value: Value) {
|
|
100
|
+
self.value = value
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
`;
|
|
104
|
+
|
|
105
|
+
function countOf(text, snippet) {
|
|
106
|
+
return text.split(snippet).length - 1;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function replaceOnceOrThrow(text, oldText, newText, what) {
|
|
110
|
+
if (countOf(text, oldText) !== 1) {
|
|
111
|
+
throw new Error('[desklink] expo-modules-jsi ' + what + ': expected one match, refusing to patch.');
|
|
112
|
+
}
|
|
113
|
+
return text.replace(oldText, newText);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Apply the 58-shaped Xcode 26 fixes to the installed expo-modules-jsi.
|
|
118
|
+
* Idempotent: a second prebuild (or an upstream-fixed copy) is a no-op.
|
|
119
|
+
* Scoped to the 57 line; anything else is left untouched. Throws on an
|
|
120
|
+
* unrecognised 57 layout rather than shipping a guess.
|
|
10
121
|
*/
|
|
11
|
-
|
|
122
|
+
function patchExpoModulesJsi(projectRoot) {
|
|
123
|
+
const root = path.join(projectRoot, 'node_modules/expo-modules-jsi');
|
|
124
|
+
const header = path.join(root, JSI_HEADER);
|
|
125
|
+
if (!fs.existsSync(header)) {
|
|
126
|
+
console.log('[desklink] expo-modules-jsi header not installed, skipping Xcode 26 fix');
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
let version = '';
|
|
130
|
+
try {
|
|
131
|
+
version = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version ?? '';
|
|
132
|
+
} catch { /* unknown version: the layout checks below decide */ }
|
|
133
|
+
if (!version.startsWith('57.')) {
|
|
134
|
+
if (version === '') {
|
|
135
|
+
throw new Error('[desklink] expo-modules-jsi has no readable version; refusing to patch blind.');
|
|
136
|
+
}
|
|
137
|
+
console.log(`[desklink] expo-modules-jsi ${version} is not a 57 release, skipping Xcode 26 fix`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const before = fs.readFileSync(header, 'utf8');
|
|
141
|
+
if (!before.includes('static RuntimeScheduler *create(')) {
|
|
142
|
+
if (countOf(before, BROKEN_CONSTRUCTORS) !== 1) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
'[desklink] expo-modules-jsi ' + version + ' RuntimeScheduler.h has an unrecognised layout; ' +
|
|
145
|
+
'refusing to patch. Update this plugin workaround for the installed version.'
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
fs.writeFileSync(header, before.replace(BROKEN_CONSTRUCTORS, FIXED_CONSTRUCTORS));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The header's only constructor callers are the Swift file beside it:
|
|
152
|
+
// point them at the factories. Member accesses (`.Priority(`, `.ScheduleFn`)
|
|
153
|
+
// never match `RuntimeScheduler(`.
|
|
154
|
+
const swift = path.join(root, JSI_SWIFT);
|
|
155
|
+
if (!fs.existsSync(swift)) {
|
|
156
|
+
throw new Error('[desklink] expo-modules-jsi ' + version + ' ships no ' + JSI_SWIFT + '.');
|
|
157
|
+
}
|
|
158
|
+
let swiftText = fs.readFileSync(swift, 'utf8');
|
|
159
|
+
if (!swiftText.includes('expo.RuntimeScheduler.create(')) {
|
|
160
|
+
const migrated = swiftText.replace(/expo\.RuntimeScheduler\(/g, 'expo.RuntimeScheduler.create(');
|
|
161
|
+
if (migrated === swiftText || /expo\.RuntimeScheduler\(/.test(migrated)) {
|
|
162
|
+
throw new Error('[desklink] expo-modules-jsi JavaScriptRuntime.swift call sites not fully migrated.');
|
|
163
|
+
}
|
|
164
|
+
swiftText = migrated;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Capture the JSI pointers through UncheckedSendable instead of
|
|
168
|
+
// `nonisolated(unsafe)`, which Swift 6.2 rejects at `sending` closures.
|
|
169
|
+
if (!swiftText.includes('resultPtr.value')) {
|
|
170
|
+
if (countOf(swiftText, 'nonisolated(unsafe) let resultPtr = resultPtr') !== 3) {
|
|
171
|
+
throw new Error('[desklink] expo-modules-jsi JavaScriptRuntime.swift pointer captures have an unrecognised layout.');
|
|
172
|
+
}
|
|
173
|
+
swiftText = replaceOnceOrThrow(swiftText,
|
|
174
|
+
'let propertyName = String(cString: propertyName)\n nonisolated(unsafe) let resultPtr = resultPtr',
|
|
175
|
+
'let propertyName = String(cString: propertyName)\n let resultPtr = UncheckedSendable(resultPtr)',
|
|
176
|
+
'getter capture');
|
|
177
|
+
swiftText = swiftText.split(
|
|
178
|
+
' nonisolated(unsafe) let thisPtr = thisPtr\n' +
|
|
179
|
+
' nonisolated(unsafe) let argumentsPtr = argumentsPtr\n' +
|
|
180
|
+
' nonisolated(unsafe) let resultPtr = resultPtr').join(
|
|
181
|
+
' let thisPtr = UncheckedSendable(thisPtr)\n' +
|
|
182
|
+
' let argumentsPtr = UncheckedSendable(argumentsPtr)\n' +
|
|
183
|
+
' let resultPtr = UncheckedSendable(resultPtr)');
|
|
184
|
+
if (countOf(swiftText, 'let thisPtr = UncheckedSendable(thisPtr)') !== 2) {
|
|
185
|
+
throw new Error('[desklink] expo-modules-jsi JavaScriptRuntime.swift trampoline captures not fully migrated.');
|
|
186
|
+
}
|
|
187
|
+
const writes = countOf(swiftText, 'writeJSIValue(to: resultPtr)');
|
|
188
|
+
if (writes !== 3) {
|
|
189
|
+
throw new Error('[desklink] expo-modules-jsi JavaScriptRuntime.swift result writes have an unrecognised layout.');
|
|
190
|
+
}
|
|
191
|
+
swiftText = swiftText.replace(/writeJSIValue\(to: resultPtr\)/g, 'writeJSIValue(to: resultPtr.value)');
|
|
192
|
+
swiftText = replaceOnceOrThrow(swiftText,
|
|
193
|
+
'UnsafeMutablePointer(mutating: thisPtr)', 'UnsafeMutablePointer(mutating: thisPtr.value)',
|
|
194
|
+
'owning-this use');
|
|
195
|
+
const starts = countOf(swiftText, 'start: argumentsPtr, count:');
|
|
196
|
+
if (starts !== 2) {
|
|
197
|
+
throw new Error('[desklink] expo-modules-jsi JavaScriptRuntime.swift buffer starts have an unrecognised layout.');
|
|
198
|
+
}
|
|
199
|
+
swiftText = swiftText.replace(/start: argumentsPtr, count:/g, 'start: argumentsPtr.value, count:');
|
|
200
|
+
swiftText = replaceOnceOrThrow(swiftText,
|
|
201
|
+
'JavaScriptUnownedValue(runtime.pointee, thisPtr)', 'JavaScriptUnownedValue(runtime.pointee, thisPtr.value)',
|
|
202
|
+
'unowned-this use');
|
|
203
|
+
swiftText = replaceOnceOrThrow(swiftText,
|
|
204
|
+
'so the `nonisolated(unsafe)` capture is sound.', 'so capturing them through `UncheckedSendable` is sound.',
|
|
205
|
+
'trampoline comment');
|
|
206
|
+
fs.writeFileSync(swift, swiftText);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const box = path.join(root, JSI_BOX);
|
|
210
|
+
if (!fs.existsSync(box)) {
|
|
211
|
+
fs.writeFileSync(box, UNCHECKED_SENDABLE);
|
|
212
|
+
}
|
|
213
|
+
console.log('[desklink] patched expo-modules-jsi ' + version + ' for Xcode 26');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function withDesklinkPointer(config) {
|
|
12
217
|
return {
|
|
13
218
|
...config,
|
|
14
219
|
ios: { ...config.ios, infoPlist: { ...config.ios?.infoPlist, UIApplicationSupportsIndirectInputEvents: true } },
|
|
15
220
|
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function withExpoJsiSwiftFix(config) {
|
|
224
|
+
let withDangerousMod;
|
|
225
|
+
try {
|
|
226
|
+
withDangerousMod = require('expo/config-plugins').withDangerousMod;
|
|
227
|
+
} catch {
|
|
228
|
+
throw new Error('[desklink] expo/config-plugins is required to apply the Xcode 26 patch.');
|
|
229
|
+
}
|
|
230
|
+
return withDangerousMod(config, ['ios', async (config) => {
|
|
231
|
+
patchExpoModulesJsi(config.modRequest.projectRoot);
|
|
232
|
+
return config;
|
|
233
|
+
}]);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
module.exports = function withDesklink(config) {
|
|
237
|
+
return withExpoJsiSwiftFix(withDesklinkPointer(config));
|
|
16
238
|
};
|
|
239
|
+
module.exports.patchExpoModulesJsi = patchExpoModulesJsi;
|
package/package.json
CHANGED
|
@@ -508,6 +508,7 @@ describe('iOS proof picture bounds', () => {
|
|
|
508
508
|
it('measures contiguous paper without including the disconnected home indicator', () => {
|
|
509
509
|
for (const density of [1, 3]) {
|
|
510
510
|
const png = { width: 390 * density, height: 844 * density, data: new Uint8Array(390 * 844 * density * density * 4) };
|
|
511
|
+
const right = (390 * density - 1) / density;
|
|
511
512
|
const paper = (left: number, top: number, right: number, bottom: number) => {
|
|
512
513
|
for (let y = top * density; y < bottom * density; y++) for (let x = left * density; x < right * density; x++) {
|
|
513
514
|
const i = (y * png.width + x) * 4;
|
|
@@ -516,14 +517,14 @@ describe('iOS proof picture bounds', () => {
|
|
|
516
517
|
};
|
|
517
518
|
paper(0, 47, 390, 266);
|
|
518
519
|
paper(125, 825, 265, 830);
|
|
519
|
-
expect(pictureBox(png, 390)).toEqual({ top: 47, left: 0, bottom: 266 });
|
|
520
|
+
expect(pictureBox(png, 390)).toEqual({ top: 47, left: 0, bottom: 266, right });
|
|
520
521
|
png.data.fill(0);
|
|
521
522
|
paper(0, 203, 390, 642);
|
|
522
523
|
paper(125, 825, 265, 830);
|
|
523
|
-
expect(pictureBox(png, 390)).toEqual({ top: 203, left: 0, bottom: 642 });
|
|
524
|
+
expect(pictureBox(png, 390)).toEqual({ top: 203, left: 0, bottom: 642, right });
|
|
524
525
|
png.data.fill(0);
|
|
525
526
|
paper(47, 0, 390, 844);
|
|
526
|
-
expect(pictureBox(png, 390)).toEqual({ top: 0, left: 47, bottom: 844 });
|
|
527
|
+
expect(pictureBox(png, 390)).toEqual({ top: 0, left: 47, bottom: 844, right });
|
|
527
528
|
}
|
|
528
529
|
});
|
|
529
530
|
});
|
package/src/DesktopView.ios.tsx
CHANGED
|
@@ -67,7 +67,7 @@ function loadRTCView(): React.ComponentType<Record<string, unknown>> | null {
|
|
|
67
67
|
/** An offset that the edge clamp turns into the desktop's top-left corner. */
|
|
68
68
|
const TOP_LEFT: Point = { x: Infinity, y: Infinity };
|
|
69
69
|
|
|
70
|
-
export function DesktopView({ sessionId, style, placeholder, accessibilityLabel, keyboardClearance = 0, insets, gestures = 'desktop', onPictureFrame }: DesktopViewProps) {
|
|
70
|
+
export function DesktopView({ sessionId, style, placeholder, accessibilityLabel, keyboardClearance = 0, insets, gestures = 'desktop', cover = false, onPictureFrame }: DesktopViewProps) {
|
|
71
71
|
const [revision, refresh] = React.useReducer((n: number) => n + 1, 0);
|
|
72
72
|
const [bounds, setBounds] = React.useState({ width: 0, height: 0 });
|
|
73
73
|
const [size, setSize] = React.useState(() => getDesktopSize(sessionId) ?? { width: 0, height: 0 });
|
|
@@ -145,7 +145,10 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
|
|
|
145
145
|
const visibleBottom = Math.min(top + safeHeight, bounds.height - (keyboardOverlap ? keyboardOverlap + keyboardClearance : 0));
|
|
146
146
|
const visibleHeight = Math.max(1, visibleBottom - top);
|
|
147
147
|
const fit = size.width && size.height ? Math.min(safeWidth / size.width, visibleHeight / size.height) : 1;
|
|
148
|
-
|
|
148
|
+
// Portrait fits the width unless the app opted into cover, when it covers
|
|
149
|
+
// like landscape does (as `DesktopView.kt` does); either way the fill never
|
|
150
|
+
// goes below the fit or above the zoom cap.
|
|
151
|
+
const defaultScale = bounds.width <= bounds.height && !cover
|
|
149
152
|
? safeWidth / size.width
|
|
150
153
|
: Math.max(bounds.width / size.width, (bounds.height - top) / size.height);
|
|
151
154
|
const fill = size.width && size.height ? Math.max(fit, Math.min(MAX_SCALE, defaultScale)) : 1;
|
package/src/DesktopView.tsx
CHANGED
|
@@ -35,6 +35,11 @@ export interface DesktopViewProps {
|
|
|
35
35
|
* (one finger scrolls), or a device screen (one finger presses and drags).
|
|
36
36
|
*/
|
|
37
37
|
gestures?: GestureProfile;
|
|
38
|
+
/**
|
|
39
|
+
* Cover the view in every orientation, cropping the overflowing axis,
|
|
40
|
+
* instead of fitting the portrait width. Off by default.
|
|
41
|
+
*/
|
|
42
|
+
cover?: boolean;
|
|
38
43
|
/**
|
|
39
44
|
* Where the picture ended, in the view's own points, whenever it moves.
|
|
40
45
|
* A wide desktop on a tall screen leaves a band under the picture; this is
|
|
@@ -53,6 +58,7 @@ interface NativeSurfaceProps {
|
|
|
53
58
|
/** `insets` as top, left, bottom, right. */
|
|
54
59
|
insets?: number[];
|
|
55
60
|
gestures?: GestureProfile;
|
|
61
|
+
cover?: boolean;
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
// The Expo view is resolved at module load; on a platform without it the
|
|
@@ -69,7 +75,7 @@ const NativeSurface: React.ComponentType<NativeSurfaceProps> | null = desktopAva
|
|
|
69
75
|
* belong to the application, which mounts this wherever it wants the desktop
|
|
70
76
|
* to appear.
|
|
71
77
|
*/
|
|
72
|
-
export function DesktopView({ sessionId, style, placeholder, accessibilityLabel, keyboardClearance = 0, insets, gestures = 'desktop' }: DesktopViewProps) {
|
|
78
|
+
export function DesktopView({ sessionId, style, placeholder, accessibilityLabel, keyboardClearance = 0, insets, gestures = 'desktop', cover = false }: DesktopViewProps) {
|
|
73
79
|
if (NativeSurface == null || sessionId == null) {
|
|
74
80
|
return (
|
|
75
81
|
<View style={[styles.surface, style]}>
|
|
@@ -78,7 +84,7 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
|
|
|
78
84
|
);
|
|
79
85
|
}
|
|
80
86
|
return <NativeSurface style={[styles.surface, style]} sessionId={sessionId} accessible={accessibilityLabel !== undefined} accessibilityLabel={accessibilityLabel} keyboardClearance={keyboardClearance}
|
|
81
|
-
insets={[insets?.top ?? 0, insets?.left ?? 0, insets?.bottom ?? 0, insets?.right ?? 0]} gestures={gestures} />;
|
|
87
|
+
insets={[insets?.top ?? 0, insets?.left ?? 0, insets?.bottom ?? 0, insets?.right ?? 0]} gestures={gestures} cover={cover} />;
|
|
82
88
|
}
|
|
83
89
|
|
|
84
90
|
const styles = StyleSheet.create({
|
package/src/native.web.ts
CHANGED
|
@@ -127,6 +127,8 @@ interface WebSession {
|
|
|
127
127
|
chordsDown: Set<string>;
|
|
128
128
|
/** Re-measure what the keyboard covers; null until a surface is attached. */
|
|
129
129
|
followKeyboard: (() => void) | null;
|
|
130
|
+
/** Whether the first frame was already reported; renegotiation must not re-mark a stale picture. */
|
|
131
|
+
presented: boolean;
|
|
130
132
|
/** Candidates that arrived before the offer; applied once it is set. */
|
|
131
133
|
remoteDescriptionSet: boolean;
|
|
132
134
|
pendingCandidates: RTCIceCandidateInit[];
|
|
@@ -960,6 +962,7 @@ export const nativeDesklink: NativeDesklinkModule = {
|
|
|
960
962
|
compositionSent: '',
|
|
961
963
|
composing: false,
|
|
962
964
|
chordsDown: new Set<string>(),
|
|
965
|
+
presented: false,
|
|
963
966
|
remoteDescriptionSet: false,
|
|
964
967
|
pendingCandidates: [],
|
|
965
968
|
detach: null,
|
|
@@ -982,6 +985,12 @@ export const nativeDesklink: NativeDesklinkModule = {
|
|
|
982
985
|
// track and produce nothing.
|
|
983
986
|
const presented = (): void => {
|
|
984
987
|
if (sessions.get(id) !== session) return;
|
|
988
|
+
// Readiness is the first frame. Renegotiation re-fires ontrack
|
|
989
|
+
// and the compositor repaints the frame it already holds, and a
|
|
990
|
+
// repaint is not a new frame: a second mark would read a stale
|
|
991
|
+
// picture as live and reset the recovery budget mid-outage.
|
|
992
|
+
if (session.presented) return;
|
|
993
|
+
session.presented = true;
|
|
985
994
|
video.style.opacity = '1';
|
|
986
995
|
emit(id, 'presented', {});
|
|
987
996
|
};
|
package/src/protocol.ts
CHANGED
|
@@ -103,6 +103,8 @@ export interface CursorSample {
|
|
|
103
103
|
* the new session corrupts or kills a healthy recovery.
|
|
104
104
|
*/
|
|
105
105
|
export type SessionEvent =
|
|
106
|
+
/** Optional carrier notification, distinct from the native peer's `closed`. */
|
|
107
|
+
| { kind: 'carrier-closed'; sessionId?: never }
|
|
106
108
|
| { kind: 'description'; description: RtcDescription; sessionId?: string }
|
|
107
109
|
| { kind: 'candidate'; candidate: RtcCandidate; sessionId?: string }
|
|
108
110
|
| ({ kind: 'cursor' } & CursorSample)
|
|
@@ -120,10 +122,13 @@ export type SessionEvent =
|
|
|
120
122
|
* The package calls these and nothing else, so an application can carry them
|
|
121
123
|
* over its existing encrypted request path, a socket, or an in-process bridge.
|
|
122
124
|
* `subscribe` delivers engine notifications until the returned function runs.
|
|
125
|
+
* The application's authorize() resolves only once this carrier is open.
|
|
123
126
|
*/
|
|
124
127
|
export interface Signaling {
|
|
125
128
|
/**
|
|
126
|
-
* Send one request.
|
|
129
|
+
* Send one request. Reject pending and future requests with `code: 'transport'`
|
|
130
|
+
* once the carrier is gone; never queue requests on a dead carrier.
|
|
131
|
+
* A rejection may carry a stable `code` — the engine's or
|
|
127
132
|
* the host's own token — which the package reads to classify a refusal, so a
|
|
128
133
|
* refusal is not retried as though it were a network failure.
|
|
129
134
|
*/
|
package/src/useDesktopSession.ts
CHANGED
|
@@ -804,7 +804,8 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
804
804
|
try {
|
|
805
805
|
authorization = await optionsRef.current.authorize();
|
|
806
806
|
} catch (error) {
|
|
807
|
-
|
|
807
|
+
const code = (error as { code?: unknown } | null)?.code === 'transport' ? 'transport' : 'permission';
|
|
808
|
+
refuse(error instanceof Error ? error.message : 'the desktop was not authorized', code);
|
|
808
809
|
return;
|
|
809
810
|
}
|
|
810
811
|
if (token !== generationToken.current) return;
|
|
@@ -1023,6 +1024,9 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
1023
1024
|
// Readiness is not "a track arrived": it is a frame on screen.
|
|
1024
1025
|
return;
|
|
1025
1026
|
case 'presented':
|
|
1027
|
+
// One mark per session: a producer that re-marks (a repaint
|
|
1028
|
+
// after renegotiation) must not read a stale picture as live.
|
|
1029
|
+
if (presentedRef.current) return;
|
|
1026
1030
|
attempts.current = 0;
|
|
1027
1031
|
restartAttempts.current = 0;
|
|
1028
1032
|
restartCycles.current = 0;
|