@desklink/react-native 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,478 @@
1
+ package expo.modules.desklink
2
+
3
+ import android.content.Context
4
+ import android.os.Handler
5
+ import android.os.Looper
6
+ import android.util.Log
7
+ import org.json.JSONObject
8
+ import org.webrtc.DataChannel
9
+ import org.webrtc.DefaultVideoDecoderFactory
10
+ import org.webrtc.EglBase
11
+ import org.webrtc.IceCandidate
12
+ import org.webrtc.MediaConstraints
13
+ import org.webrtc.MediaStream
14
+ import org.webrtc.MediaStreamTrack
15
+ import org.webrtc.PeerConnection
16
+ import org.webrtc.PeerConnectionFactory
17
+ import org.webrtc.RtpReceiver
18
+ import org.webrtc.RtpTransceiver
19
+ import org.webrtc.SdpObserver
20
+ import org.webrtc.SessionDescription
21
+ import org.webrtc.SoftwareVideoDecoderFactory
22
+ import org.webrtc.VideoDecoder
23
+ import org.webrtc.VideoDecoderFactory
24
+ import org.webrtc.VideoCodecInfo
25
+ import org.webrtc.VideoTrack
26
+ import java.nio.ByteBuffer
27
+ import java.nio.charset.StandardCharsets
28
+ import java.util.UUID
29
+ import java.util.concurrent.ExecutorService
30
+ import java.util.concurrent.Executors
31
+
32
+ private const val TAG = "DesklinkSession"
33
+ private const val MOVE_BUFFER_LIMIT = 4096L
34
+
35
+ /**
36
+ * One desktop session, native side.
37
+ *
38
+ * Owns its **own** [PeerConnectionFactory] rather than reusing the app's shared
39
+ * WebRTC module, for two reasons:
40
+ *
41
+ * - the shared factory picks hardware H.264 and *software* VP9/VP8/AV1, so
42
+ * rendering a desktop through it would decode VP9 on the CPU;
43
+ * - its decoder factory is process-global and set once, so changing it would
44
+ * change every other WebRTC user in the app.
45
+ *
46
+ * The factory below asks for [DefaultVideoDecoderFactory], which is
47
+ * hardware-first across VP8/VP9/H264/AV1 with a software fallback, and it is
48
+ * scoped to this session. The underlying WebRTC native library is the app's
49
+ * existing one: this class is compiled against the `org.webrtc` classes the
50
+ * installed binding already ships, so no second copy of the native library is
51
+ * linked in.
52
+ */
53
+ class DesktopSession(
54
+ private val context: Context,
55
+ internal val eglBase: EglBase.Context,
56
+ private val onEvent: (String, Map<String, Any?>) -> Unit,
57
+ ) {
58
+ private val io: ExecutorService = Executors.newSingleThreadExecutor { runnable ->
59
+ Thread(runnable, "desklink-session")
60
+ }
61
+ private val ui = Handler(Looper.getMainLooper())
62
+ private var inputSeq = 0L
63
+ private var pendingMove: JSONObject? = null
64
+
65
+ @Volatile private var factory: PeerConnectionFactory? = null
66
+ @Volatile private var peer: PeerConnection? = null
67
+ @Volatile private var channel: DataChannel? = null
68
+ @Volatile private var videoTrack: VideoTrack? = null
69
+ @Volatile private var epoch: Long = 0
70
+ @Volatile private var closed = false
71
+ @Volatile var inputEnabled = false
72
+ private set
73
+
74
+ @Synchronized
75
+ fun setInputEnabled(enabled: Boolean) {
76
+ inputEnabled = enabled
77
+ if (!enabled) {
78
+ pendingMove = null
79
+ sendJson(mapOf("kind" to "release_all"))
80
+ }
81
+ }
82
+ private val pendingCandidates = mutableListOf<IceCandidate>()
83
+ @Volatile private var remoteDescriptionSet = false
84
+ @Volatile private var presented = false
85
+
86
+ /** The renderer, attached by the view. A sink is (re)registered when it changes. */
87
+ @Volatile var frameSink: ((VideoTrack) -> Unit)? = null
88
+ set(value) {
89
+ field = value
90
+ // Negotiation can deliver the track before React mounts its view.
91
+ // Both attachment paths run on the UI thread, including late/remounts.
92
+ ui.post { if (!closed) videoTrack?.let { frameSink?.invoke(it) } }
93
+ }
94
+
95
+ fun start(iceServersJson: String) {
96
+ io.execute {
97
+ if (closed) return@execute
98
+ try {
99
+ val servers = parseIceServers(iceServersJson)
100
+ val configuration = PeerConnection.RTCConfiguration(servers).apply {
101
+ sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN
102
+ }
103
+ val created = buildFactory()
104
+ factory = created
105
+ val target = ++epoch
106
+ val connection = created.createPeerConnection(configuration, observer(target))
107
+ ?: throw IllegalStateException("WebRTC refused the peer connection configuration")
108
+ peer = connection
109
+ // The engine offers; this side answers. The engine also creates the
110
+ // control channel, so the client waits for it in `onDataChannel`.
111
+ emit("ready", emptyMap())
112
+ } catch (error: Throwable) {
113
+ Log.w(TAG, "Could not start the desktop session", error)
114
+ fail("transport", error.message ?: "the desktop session could not start")
115
+ }
116
+ }
117
+ }
118
+
119
+ private fun buildFactory(): PeerConnectionFactory {
120
+ // Initialisation is process-wide and idempotent; the app's binding has
121
+ // already done it, so a second call is tolerated rather than required.
122
+ runCatching {
123
+ PeerConnectionFactory.initialize(
124
+ PeerConnectionFactory.InitializationOptions.builder(context)
125
+ .setEnableInternalTracer(false)
126
+ .createInitializationOptions(),
127
+ )
128
+ }
129
+ // Deliberately no global mutation: the app's shared factory options are left
130
+ // exactly as they were.
131
+ return PeerConnectionFactory.builder()
132
+ .setVideoDecoderFactory(hardwareFirstDecoderFactory())
133
+ .createPeerConnectionFactory()
134
+ }
135
+
136
+ /**
137
+ * Hardware-first, VP9 included, with an explicit software fallback so a device
138
+ * without a VP9 hardware decoder still shows the desktop instead of a black
139
+ * rectangle.
140
+ */
141
+ private fun hardwareFirstDecoderFactory(): VideoDecoderFactory =
142
+ DefaultVideoDecoderFactory(eglBase)
143
+
144
+ fun setRemoteDescription(type: String, sdp: String) {
145
+ io.execute {
146
+ val connection = peer ?: return@execute
147
+ val target = epoch
148
+ if (type != "offer") {
149
+ fail("transport", "the engine must send an offer")
150
+ return@execute
151
+ }
152
+ connection.setRemoteDescription(
153
+ object : SdpObserver {
154
+ override fun onCreateSuccess(description: SessionDescription?) = Unit
155
+
156
+ override fun onSetSuccess() {
157
+ if (target != epoch) return
158
+ remoteDescriptionSet = true
159
+ for (candidate in pendingCandidates) connection.addIceCandidate(candidate)
160
+ pendingCandidates.clear()
161
+ connection.createAnswer(
162
+ object : SdpObserver {
163
+ override fun onCreateSuccess(answer: SessionDescription?) {
164
+ if (answer == null || target != epoch) return
165
+ connection.setLocalDescription(
166
+ object : SdpObserver {
167
+ override fun onCreateSuccess(description: SessionDescription?) = Unit
168
+ override fun onSetSuccess() {
169
+ if (target == epoch) {
170
+ emit("answer", mapOf("sdp" to answer.description))
171
+ }
172
+ }
173
+
174
+ override fun onCreateFailure(error: String?) = fail("transport", error)
175
+ override fun onSetFailure(error: String?) = fail("transport", error)
176
+ },
177
+ answer,
178
+ )
179
+ }
180
+
181
+ override fun onSetSuccess() = Unit
182
+ override fun onCreateFailure(error: String?) = fail("transport", error)
183
+ override fun onSetFailure(error: String?) = fail("transport", error)
184
+ },
185
+ MediaConstraints(),
186
+ )
187
+ }
188
+
189
+ override fun onCreateFailure(error: String?) = fail("transport", error)
190
+ override fun onSetFailure(error: String?) = fail("transport", error)
191
+ },
192
+ SessionDescription(SessionDescription.Type.OFFER, sdp),
193
+ )
194
+ }
195
+ }
196
+
197
+ fun addRemoteCandidate(candidate: String, sdpMid: String?, sdpMLineIndex: Int) {
198
+ io.execute {
199
+ val connection = peer ?: return@execute
200
+ val ice = IceCandidate(sdpMid, sdpMLineIndex, candidate)
201
+ if (remoteDescriptionSet) {
202
+ connection.addIceCandidate(ice)
203
+ } else {
204
+ pendingCandidates.add(ice)
205
+ }
206
+ }
207
+ }
208
+
209
+ /** Stamp and send under one lock: UI gestures and JS use different threads. */
210
+ @Synchronized
211
+ private fun send(message: JSONObject) {
212
+ if (closed || (!inputEnabled && message.optString("kind") in setOf("pointer", "wheel", "key", "text", "clipboard_read", "clipboard_write"))) return
213
+ val active = channel ?: return
214
+ if (active.state() != DataChannel.State.OPEN) return
215
+ if (message.optString("kind") == "pointer" && message.optString("phase") == "move") {
216
+ if (active.bufferedAmount() > MOVE_BUFFER_LIMIT) pendingMove = message
217
+ else {
218
+ pendingMove = null
219
+ sendNow(active, message)
220
+ }
221
+ return
222
+ }
223
+ pendingMove?.let {
224
+ pendingMove = null
225
+ sendNow(active, it)
226
+ }
227
+ if (!closed) sendNow(active, message)
228
+ }
229
+
230
+ private fun sendNow(active: DataChannel, message: JSONObject) {
231
+ message.put("seq", ++inputSeq)
232
+ val sent = active.send(DataChannel.Buffer(
233
+ ByteBuffer.wrap(message.toString().toByteArray(StandardCharsets.UTF_8)), false,
234
+ ))
235
+ if (!sent) {
236
+ fail("transport", "the desktop control channel could not send")
237
+ close()
238
+ }
239
+ }
240
+
241
+ fun sendStamped(message: String) = send(JSONObject(message))
242
+
243
+ fun sendPointer(phase: String, x: Int, y: Int, withButton: Boolean = false) {
244
+ val message = mutableMapOf<String, Any?>(
245
+ "kind" to "pointer",
246
+ "phase" to phase,
247
+ "x" to x,
248
+ "y" to y,
249
+ )
250
+ if (withButton || phase == "down") message["button"] = 1
251
+ sendJson(message)
252
+ }
253
+
254
+ /** A right click where the finger rests: the context menu every desktop app offers. */
255
+ fun sendRightClick(x: Int, y: Int) {
256
+ for (phase in listOf("down", "up")) {
257
+ sendJson(mapOf("kind" to "pointer", "phase" to phase, "x" to x, "y" to y, "button" to 3))
258
+ }
259
+ }
260
+
261
+ /** Wheel detents; fractions are smooth scrolling on desktops that support it. */
262
+ fun sendWheel(dx: Float, dy: Float) {
263
+ sendJson(mapOf("kind" to "wheel", "dx" to Math.round(dx * 1000) / 1000.0, "dy" to Math.round(dy * 1000) / 1000.0))
264
+ }
265
+
266
+ fun sendKey(name: String, modifiers: List<String>, down: Boolean) {
267
+ sendJson(
268
+ mapOf(
269
+ "kind" to "key",
270
+ "name" to name,
271
+ "modifiers" to modifiers,
272
+ "down" to down,
273
+ ),
274
+ )
275
+ }
276
+
277
+ /**
278
+ * A chorded character (Ctrl+C). The engine presses the key through the
279
+ * desktop's own layout and holds the named modifiers for that key alone.
280
+ */
281
+ fun sendCharacter(character: String, modifiers: List<String>, down: Boolean) {
282
+ sendJson(
283
+ mapOf(
284
+ "kind" to "key",
285
+ "character" to character,
286
+ "modifiers" to modifiers,
287
+ "down" to down,
288
+ ),
289
+ )
290
+ }
291
+
292
+ fun sendText(text: String) {
293
+ sendJson(mapOf("kind" to "text", "text" to text))
294
+ }
295
+
296
+ /** Release everything the desktop is holding, without ending the session. */
297
+ fun sendCancel() {
298
+ sendJson(mapOf("kind" to "release_all"))
299
+ }
300
+
301
+ fun sendClipboardRead(request: String) {
302
+ sendJson(mapOf("kind" to "clipboard_read", "request" to request))
303
+ }
304
+
305
+ fun sendClipboardWrite(request: String, text: String) {
306
+ sendJson(
307
+ mapOf("kind" to "clipboard_write", "request" to request, "text" to text),
308
+ )
309
+ }
310
+
311
+ private fun sendJson(message: Map<String, Any?>) {
312
+ send(JSONObject(message))
313
+ }
314
+
315
+ /** Typing held back for an armed sticky modifier; the app sends it as a chord. */
316
+ fun emitKeyboard(input: Map<String, Any?>) = emit("keyboard", input)
317
+
318
+ fun markPresented() {
319
+ if (!presented) {
320
+ presented = true
321
+ emit("presented", emptyMap())
322
+ }
323
+ }
324
+
325
+ @Synchronized
326
+ fun close() {
327
+ if (closed) return
328
+ closed = true
329
+ pendingMove = null
330
+ ++epoch
331
+ io.execute {
332
+ // Releasing everything the far side held is the engine's job on session
333
+ // close; this side only has to stop using the channel.
334
+ runCatching { channel?.unregisterObserver() }
335
+ runCatching { channel?.close() }
336
+ runCatching { channel?.dispose() }
337
+ runCatching { peer?.close() }
338
+ runCatching { peer?.dispose() }
339
+ runCatching { factory?.dispose() }
340
+ channel = null
341
+ peer = null
342
+ factory = null
343
+ pendingCandidates.clear()
344
+ remoteDescriptionSet = false
345
+ emit("closed", emptyMap())
346
+ }
347
+ }
348
+
349
+ fun shutdown() {
350
+ close()
351
+ runCatching { io.shutdown() }
352
+ }
353
+
354
+ private fun parseIceServers(json: String): List<PeerConnection.IceServer> {
355
+ if (json.isBlank()) return emptyList()
356
+ val array = org.json.JSONArray(json)
357
+ val servers = mutableListOf<PeerConnection.IceServer>()
358
+ for (index in 0 until array.length()) {
359
+ val entry = array.optJSONObject(index) ?: continue
360
+ val urls = entry.optJSONArray("urls") ?: continue
361
+ for (urlIndex in 0 until urls.length()) {
362
+ val url = urls.optString(urlIndex)
363
+ if (url.isBlank()) continue
364
+ val builder = PeerConnection.IceServer.builder(url)
365
+ entry.optString("username").takeIf { it.isNotEmpty() }?.let { builder.setUsername(it) }
366
+ entry.optString("credential").takeIf { it.isNotEmpty() }?.let { builder.setPassword(it) }
367
+ servers.add(builder.createIceServer())
368
+ }
369
+ }
370
+ return servers
371
+ }
372
+
373
+ private fun observer(target: Long) = object : PeerConnection.Observer {
374
+ override fun onSignalingChange(state: PeerConnection.SignalingState?) = Unit
375
+
376
+ override fun onIceConnectionChange(state: PeerConnection.IceConnectionState?) {
377
+ if (target != epoch) return
378
+ emit("ice", mapOf("state" to state?.toString()))
379
+ if (state == PeerConnection.IceConnectionState.CONNECTED &&
380
+ context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE != 0
381
+ ) {
382
+ // Bounded debug-build evidence: reception/decoding is not presentation.
383
+ // Never include SDP, addresses, credentials or control payloads.
384
+ val fields = setOf("kind", "packetsReceived", "packetsLost", "bytesReceived", "framesReceived",
385
+ "framesDecoded", "keyFramesDecoded", "framesDropped", "framesPerSecond", "frameWidth", "frameHeight",
386
+ "nackCount", "pliCount", "firCount", "totalDecodeTime", "decoderImplementation", "jitterBufferDelay")
387
+ val sample = object : Runnable {
388
+ override fun run() {
389
+ if (closed || target != epoch) return
390
+ peer?.getStats { report ->
391
+ val video = report.statsMap.values.filter { it.type == "inbound-rtp" }
392
+ .map { JSONObject(it.members.filterKeys { key -> key in fields }) }
393
+ Log.i(TAG, "video receive stats: ${org.json.JSONArray(video)}")
394
+ }
395
+ ui.postDelayed(this, 5000)
396
+ }
397
+ }
398
+ ui.postDelayed(sample, 2000)
399
+ }
400
+ if (state == PeerConnection.IceConnectionState.FAILED) {
401
+ fail("transport", "the connection to the desktop was lost")
402
+ }
403
+ }
404
+
405
+ override fun onIceConnectionReceivingChange(receiving: Boolean) = Unit
406
+ override fun onIceGatheringChange(state: PeerConnection.IceGatheringState?) = Unit
407
+
408
+ override fun onIceCandidate(candidate: IceCandidate?) {
409
+ if (candidate == null || target != epoch) return
410
+ emit(
411
+ "candidate",
412
+ mapOf(
413
+ "candidate" to candidate.sdp,
414
+ "sdpMid" to candidate.sdpMid,
415
+ "sdpMLineIndex" to candidate.sdpMLineIndex,
416
+ ),
417
+ )
418
+ }
419
+
420
+ override fun onIceCandidatesRemoved(candidates: Array<out IceCandidate>?) = Unit
421
+ override fun onAddStream(stream: MediaStream?) = Unit
422
+ override fun onRemoveStream(stream: MediaStream?) = Unit
423
+ override fun onRenegotiationNeeded() = Unit
424
+ override fun onAddTrack(receiver: RtpReceiver?, streams: Array<out MediaStream>?) = Unit
425
+
426
+ override fun onDataChannel(dataChannel: DataChannel?) {
427
+ if (dataChannel == null || target != epoch) return
428
+ channel = dataChannel
429
+ dataChannel.registerObserver(object : DataChannel.Observer {
430
+ override fun onBufferedAmountChange(amount: Long) {
431
+ synchronized(this@DesktopSession) {
432
+ if (!closed && inputEnabled && channel === dataChannel && dataChannel.state() == DataChannel.State.OPEN &&
433
+ dataChannel.bufferedAmount() <= MOVE_BUFFER_LIMIT
434
+ ) {
435
+ pendingMove?.let {
436
+ pendingMove = null
437
+ sendNow(dataChannel, it)
438
+ }
439
+ }
440
+ }
441
+ }
442
+
443
+ override fun onStateChange() {
444
+ if (target == epoch) {
445
+ emit("channel", mapOf("state" to dataChannel.state().toString()))
446
+ }
447
+ }
448
+
449
+ override fun onMessage(buffer: DataChannel.Buffer) {
450
+ val bytes = ByteArray(buffer.data.remaining())
451
+ buffer.data.get(bytes)
452
+ emit("control", mapOf("message" to String(bytes, StandardCharsets.UTF_8)))
453
+ }
454
+ })
455
+ }
456
+
457
+ override fun onTrack(transceiver: RtpTransceiver?) {
458
+ val track = transceiver?.receiver?.track() as? VideoTrack ?: return
459
+ if (target != epoch) return
460
+ videoTrack = track
461
+ Log.i(TAG, "remote video track; view sink registered=${frameSink != null}")
462
+ ui.post { if (target == epoch && !closed) frameSink?.invoke(track) }
463
+ emit("track", emptyMap())
464
+ }
465
+ }
466
+
467
+ private fun emit(name: String, payload: Map<String, Any?>) {
468
+ ui.post { onEvent(name, payload) }
469
+ }
470
+
471
+ private fun fail(code: String, message: String?) {
472
+ ui.post { onEvent("failure", mapOf("code" to code, "message" to (message ?: "unknown"))) }
473
+ }
474
+
475
+ companion object {
476
+ fun newRequestId(): String = UUID.randomUUID().toString()
477
+ }
478
+ }