From ac2b31406c114d952177e53cccf073b2252965cc Mon Sep 17 00:00:00 2001 From: daoleno Date: Tue, 8 Sep 2026 12:18:58 +0800 Subject: [PATCH 01/69] Checkpoint remote desktop WIP Preserve the partial X11 and shared native client implementation, harden native terminal-state cleanup, and document remaining input and runtime acceptance gaps. This is a local source checkpoint, not release acceptance. --- .gitignore | 1 + app/app/remote-desktop.tsx | 164 +++++++++ .../navigation/PrimaryDrawerPanel.tsx | 14 +- .../ZenRemoteDesktop.podspec | 18 + .../zen-remote-desktop/android/build.gradle | 18 + .../ZenRemoteDesktopModule.kt | 281 +++++++++++++++ .../modules/zenremotedesktop/AnnexBTest.kt | 26 ++ .../expo-module.config.json | 5 + .../ios/ZenRemoteDesktopModule.swift | 255 ++++++++++++++ app/modules/zen-remote-desktop/package.json | 1 + app/modules/zen-remote-desktop/src/index.ts | 23 ++ app/services/auth.ts | 1 + app/services/remoteDesktop.ts | 11 + app/services/remoteDesktopModel.test.ts | 26 ++ app/services/remoteDesktopModel.ts | 45 +++ .../remoteDesktopNativeContract.test.ts | 60 ++++ app/tests/remoteDesktopNativeHarness.tsx | 37 ++ daemon/desktop/desktop.go | 263 ++++++++++++++ daemon/desktop/desktop_test.go | 60 ++++ daemon/desktop/lifecycle_test.go | 118 +++++++ daemon/desktop/native/Makefile | 13 + daemon/desktop/native/linux.c | 223 ++++++++++++ daemon/desktop/native/portal-test.c | 249 ++++++++++++++ daemon/desktop/native/portal.c | 324 ++++++++++++++++++ daemon/desktop/native/portal.h | 32 ++ daemon/desktop/native/test-desktop.c | 88 +++++ daemon/server/remote_desktop.go | 26 ++ daemon/server/remote_desktop_native_test.go | 70 ++++ daemon/server/remote_desktop_test.go | 111 ++++++ daemon/server/server.go | 5 + docs/remote-desktop.md | 295 ++++++++++++++++ 31 files changed, 2861 insertions(+), 2 deletions(-) create mode 100644 app/app/remote-desktop.tsx create mode 100644 app/modules/zen-remote-desktop/ZenRemoteDesktop.podspec create mode 100644 app/modules/zen-remote-desktop/android/build.gradle create mode 100644 app/modules/zen-remote-desktop/android/src/main/java/expo/modules/zenremotedesktop/ZenRemoteDesktopModule.kt create mode 100644 app/modules/zen-remote-desktop/android/src/test/java/expo/modules/zenremotedesktop/AnnexBTest.kt create mode 100644 app/modules/zen-remote-desktop/expo-module.config.json create mode 100644 app/modules/zen-remote-desktop/ios/ZenRemoteDesktopModule.swift create mode 100644 app/modules/zen-remote-desktop/package.json create mode 100644 app/modules/zen-remote-desktop/src/index.ts create mode 100644 app/services/remoteDesktop.ts create mode 100644 app/services/remoteDesktopModel.test.ts create mode 100644 app/services/remoteDesktopModel.ts create mode 100644 app/services/remoteDesktopNativeContract.test.ts create mode 100644 app/tests/remoteDesktopNativeHarness.tsx create mode 100644 daemon/desktop/desktop.go create mode 100644 daemon/desktop/desktop_test.go create mode 100644 daemon/desktop/lifecycle_test.go create mode 100644 daemon/desktop/native/Makefile create mode 100644 daemon/desktop/native/linux.c create mode 100644 daemon/desktop/native/portal-test.c create mode 100644 daemon/desktop/native/portal.c create mode 100644 daemon/desktop/native/portal.h create mode 100644 daemon/desktop/native/test-desktop.c create mode 100644 daemon/server/remote_desktop.go create mode 100644 daemon/server/remote_desktop_native_test.go create mode 100644 daemon/server/remote_desktop_test.go create mode 100644 docs/remote-desktop.md diff --git a/.gitignore b/.gitignore index ea686978..01856f20 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ app/ios/ app/modules/zen-file-upload/android/build/ app/modules/zen-keyboard-lifecycle/android/build/ app/modules/zen-link-transport/android/build/ +app/modules/zen-remote-desktop/android/build/ # Expo .expo/ diff --git a/app/app/remote-desktop.tsx b/app/app/remote-desktop.tsx new file mode 100644 index 00000000..15e7d03f --- /dev/null +++ b/app/app/remote-desktop.tsx @@ -0,0 +1,164 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { AppState, PanResponder, Pressable, StyleSheet, Switch, Text, TextInput, View } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { Stack, useFocusEffect } from "expo-router"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { useCurrentServer } from "../store/currentServer"; +import { useAppColors } from "../constants/tokens"; +import { prepareDesktopConnection } from "../services/remoteDesktop"; +import { desktopKey, desktopPoint, desktopText, type DesktopInput } from "../services/remoteDesktopModel"; +import { NativeDesktopView, type DesktopState } from "../modules/zen-remote-desktop/src"; + +export default function RemoteDesktopScreen() { + const { currentServer } = useCurrentServer(); + return ; +} + +function DesktopSession() { + const { currentServer } = useCurrentServer(); + const colors = useAppColors(); + const [connection, setConnection] = useState(""); + const [command, setCommand] = useState(""); + const [status, setStatus] = useState({ state: "disconnected" }); + const [preparing, setPreparing] = useState(false); + const [control, setControl] = useState(false); + const [panMode, setPanMode] = useState(false); + const [dragMode, setDragMode] = useState(false); + const [keyboard, setKeyboard] = useState(false); + const [text, setText] = useState(""); + const [zoom, setZoom] = useState(1); + const [offset, setOffset] = useState({ x: 0, y: 0 }); + const [size, setSize] = useState({ width: 1, height: 1 }); + const generation = useRef(0); + const sequence = useRef(0); + const gestureStart = useRef({ x: 0, y: 0, time: 0, pinch: 0, zoom: 1, offset: { x: 0, y: 0 } }); + const connected = status.state === "connected"; + const send = (value: object) => setCommand(JSON.stringify({ ...value, sequence: ++sequence.current })); + const input = (events: DesktopInput[]) => { + if (connected && control && events.length) send({ type: "batch", events }); + }; + const stop = useCallback(() => { + generation.current++; + setConnection(""); setCommand(""); setPreparing(false); + setStatus({ state: "disconnected" }); setKeyboard(false); + setDragMode(false); setPanMode(false); setZoom(1); setOffset({ x: 0, y: 0 }); + }, []); + useFocusEffect(useCallback(() => stop, [stop])); + useEffect(() => { + const subscription = AppState.addEventListener("change", (state) => { if (state !== "active") stop(); }); + return () => { generation.current++; subscription.remove(); }; + }, [stop]); + const connect = async () => { + if (!currentServer) return; + const epoch = ++generation.current; + setConnection(""); setCommand(""); + setPreparing(true); setStatus({ state: "disconnected" }); + try { + const next = await prepareDesktopConnection(currentServer); + if (epoch === generation.current) setConnection(next); + } catch (error) { + if (epoch === generation.current) setStatus({ state: "disconnected", reason: error instanceof Error ? error.message : "Connection failed." }); + } finally { if (epoch === generation.current) setPreparing(false); } + }; + const pointer = (x: number, y: number) => desktopPoint( + (x - size.width / 2 - offset.x) / zoom + size.width / 2, + (y - size.height / 2 - offset.y) / zoom + size.height / 2, + size.width, size.height, status.width ?? 1280, status.height ?? 720, + ); + const responder = PanResponder.create({ + onStartShouldSetPanResponder: () => connected, + onMoveShouldSetPanResponder: () => connected, + onPanResponderGrant: (event) => { + const touches = event.nativeEvent.touches; + gestureStart.current = { x: event.nativeEvent.locationX, y: event.nativeEvent.locationY, time: Date.now(), + pinch: touches.length === 2 ? Math.hypot(touches[0].pageX - touches[1].pageX, touches[0].pageY - touches[1].pageY) : 0, + zoom, offset }; + if (dragMode && !panMode && touches.length === 1) { + const point = pointer(event.nativeEvent.locationX, event.nativeEvent.locationY); + if (point) input([{ type: "pointer", ...point }, { type: "button", code: 1, down: true }]); + } + }, + onPanResponderMove: (event, gesture) => { + const touches = event.nativeEvent.touches; + if (touches.length === 2) { + if (!gestureStart.current.pinch && dragMode) input([{ type: "release" }]); + const distance = Math.hypot(touches[0].pageX - touches[1].pageX, touches[0].pageY - touches[1].pageY); + if (gestureStart.current.pinch === 0) gestureStart.current.pinch = distance; + setZoom(Math.max(1, Math.min(4, gestureStart.current.zoom * distance / gestureStart.current.pinch))); + } else if (panMode) { + setOffset({ x: gestureStart.current.offset.x + gesture.dx, y: gestureStart.current.offset.y + gesture.dy }); + } else { + const point = pointer(event.nativeEvent.locationX, event.nativeEvent.locationY); + if (point) input([{ type: "pointer", ...point }]); + } + }, + onPanResponderRelease: (event, gesture) => { + if (dragMode) input([{ type: "release" }]); + if (!dragMode && !panMode && !gestureStart.current.pinch && Math.hypot(gesture.dx, gesture.dy) < 10) { + const point = pointer(event.nativeEvent.locationX, event.nativeEvent.locationY); + const code = Date.now() - gestureStart.current.time > 550 ? 3 : 1; + if (point) input([{ type: "pointer", ...point }, { type: "button", code, down: true }, { type: "button", code, down: false }]); + } + }, + onPanResponderTerminate: () => input([{ type: "release" }]), + }); + const tool = (icon: React.ComponentProps["name"], label: string, action: () => void, selected = false, disabled = false) => ( + + + + ); + return + + + {currentServer?.name ?? "No current server"} + {preparing ? "Connecting" : status.state === "streaming" ? "Waiting for video" : status.state === "requesting" ? "Awaiting permission" : connected ? "Connected" : ""} + + setSize(event.nativeEvent.layout)}> + {connection ? + setStatus(nativeEvent)} /> + : null} + {connected ? : + + {status.reason || (status.state === "sources" ? "Selected X11 desktop" : status.state === "requesting" ? "Awaiting host permission" : status.state === "streaming" ? "Waiting for video" : preparing ? "Connecting" : "Disconnected")} + {status.state === "sources" ? <> + Allow control + send({ type: "start", source: "x11", control })} style={styles.action}>Share desktop + : !preparing && !["requesting", "streaming"].includes(status.state) ? Connect : null} + } + + + {tool("stop-circle-outline", "Stop desktop", stop, false, !connection)} + {tool("hand-left-outline", "Pan desktop", () => { setPanMode(!panMode); setDragMode(false); }, panMode, !connected)} + {tool("move-outline", "Drag pointer", () => { setDragMode(!dragMode); setPanMode(false); }, dragMode, !connected || !control)} + {tool("contract-outline", "Reset zoom", () => { setZoom(1); setOffset({ x: 0, y: 0 }); }, false, !connected)} + {tool("keypad-outline", "Keyboard", () => setKeyboard(!keyboard), keyboard, !connected || !control)} + {tool("chevron-up-outline", "Scroll up", () => input([{ type: "scroll", delta: -3 }]), false, !connected || !control)} + {tool("chevron-down-outline", "Scroll down", () => input([{ type: "scroll", delta: 3 }]), false, !connected || !control)} + + {keyboard ? + { input(desktopText(value)); setText(""); }} + onKeyPress={({ nativeEvent }) => { if (nativeEvent.key === "Backspace") input(desktopKey(0xff08)); }} + onSubmitEditing={() => input(desktopKey(0xff0d))} autoCorrect={false} autoCapitalize="none" placeholder="Type" accessibilityLabel="Desktop keyboard" + style={[styles.textInput, { color: colors.textPrimary, borderColor: colors.borderSubtle }]} /> + {tool("return-down-back-outline", "Enter", () => input(desktopKey(0xff0d)))} + {tool("arrow-back-outline", "Backspace", () => input(desktopKey(0xff08)))} + : null} + ; +} + +const styles = StyleSheet.create({ + root: { flex: 1 }, + header: { paddingHorizontal: 16, paddingVertical: 10, gap: 4 }, + host: { fontSize: 14, fontWeight: "600" }, + viewport: { flex: 1, backgroundColor: "#000000", overflow: "hidden" }, + empty: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24, gap: 16 }, + message: { fontSize: 15, color: "#e3e7eb", textAlign: "center" }, + mode: { flexDirection: "row", alignItems: "center", gap: 16 }, + action: { backgroundColor: "#edf1f4", paddingHorizontal: 20, paddingVertical: 12, borderRadius: 6 }, + actionText: { color: "#182024", fontSize: 15, fontWeight: "600" }, + toolbar: { flexDirection: "row", flexWrap: "wrap", justifyContent: "space-evenly", borderTopWidth: StyleSheet.hairlineWidth, padding: 4 }, + tool: { width: 44, height: 44, alignItems: "center", justifyContent: "center", borderRadius: 6 }, + keyboard: { flexDirection: "row", padding: 8, alignItems: "center" }, + textInput: { flex: 1, minWidth: 0, height: 44, borderWidth: 1, borderRadius: 6, paddingHorizontal: 12 }, +}); diff --git a/app/components/navigation/PrimaryDrawerPanel.tsx b/app/components/navigation/PrimaryDrawerPanel.tsx index 7db08b68..2fd718ce 100644 --- a/app/components/navigation/PrimaryDrawerPanel.tsx +++ b/app/components/navigation/PrimaryDrawerPanel.tsx @@ -7,6 +7,7 @@ import { type View as ViewInstance, } from "react-native"; import { useRouter } from "expo-router"; +import { Ionicons } from "@expo/vector-icons"; import { SafeAreaView } from "react-native-safe-area-context"; import { Typography, useAppColors } from "../../constants/tokens"; import { appVersion } from "../../constants/appVersion"; @@ -29,7 +30,7 @@ interface PrimaryDrawerPanelProps { onNavigateAway(): void; } -type DrawerRowIcon = "settings" | "skills" | "stats"; +type DrawerRowIcon = "settings" | "skills" | "stats" | "desktop"; interface DrawerRowProps { drawerVisible: boolean; @@ -45,6 +46,9 @@ function DrawerRowIconView({ color: string; icon: DrawerRowIcon; }) { + if (icon === "desktop") { + return ; + } if (icon === "stats") { return ; } @@ -121,7 +125,7 @@ export function PrimaryDrawerPanel({ : "Offline"); const openRoute = useCallback( - (pathname: "/skills" | "/stats" | "/settings") => { + (pathname: "/skills" | "/stats" | "/settings" | "/remote-desktop") => { onNavigateAway(); router.push(pathname); }, @@ -221,6 +225,12 @@ export function PrimaryDrawerPanel({ label="Settings" onPress={() => openRoute("/settings")} /> + openRoute("/remote-desktop")} + /> '16.4' } + s.source = { :path => '.' } + s.static_framework = true + s.source_files = 'ios/**/*.swift' + s.frameworks = 'AVFoundation', 'CoreMedia', 'VideoToolbox' + s.dependency 'ExpoModulesCore' + s.dependency 'React-Core' +end diff --git a/app/modules/zen-remote-desktop/android/build.gradle b/app/modules/zen-remote-desktop/android/build.gradle new file mode 100644 index 00000000..44169f75 --- /dev/null +++ b/app/modules/zen-remote-desktop/android/build.gradle @@ -0,0 +1,18 @@ +apply plugin: 'com.android.library' +apply plugin: 'org.jetbrains.kotlin.android' + +group = 'expo.modules.zenremotedesktop' +version = '0.1.0' + +android { + namespace 'expo.modules.zenremotedesktop' + compileSdk rootProject.ext.has('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 35 + defaultConfig { minSdk rootProject.ext.has('minSdkVersion') ? rootProject.ext.minSdkVersion : 24 } +} + +dependencies { + implementation project(':expo-modules-core') + implementation 'com.facebook.react:react-android' + implementation 'com.squareup.okhttp3:okhttp:4.12.0' + testImplementation 'junit:junit:4.13.2' +} diff --git a/app/modules/zen-remote-desktop/android/src/main/java/expo/modules/zenremotedesktop/ZenRemoteDesktopModule.kt b/app/modules/zen-remote-desktop/android/src/main/java/expo/modules/zenremotedesktop/ZenRemoteDesktopModule.kt new file mode 100644 index 00000000..7fd7e852 --- /dev/null +++ b/app/modules/zen-remote-desktop/android/src/main/java/expo/modules/zenremotedesktop/ZenRemoteDesktopModule.kt @@ -0,0 +1,281 @@ +package expo.modules.zenremotedesktop + +import android.content.Context +import android.media.MediaCodec +import android.media.MediaFormat +import android.os.Handler +import android.os.HandlerThread +import android.view.SurfaceHolder +import android.view.SurfaceView +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import expo.modules.kotlin.viewevent.EventDispatcher +import expo.modules.kotlin.views.ExpoView +import okhttp3.* +import okio.ByteString +import org.json.JSONObject +import java.nio.ByteBuffer +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +class ZenRemoteDesktopModule : Module() { + override fun definition() = ModuleDefinition { + Name("ZenRemoteDesktop") + View(DesktopView::class) { + Events("onState") + Prop("connection") { view: DesktopView, value: String -> view.connect(value) } + Prop("command") { view: DesktopView, value: String -> view.send(value) } + OnViewDestroys { view: DesktopView -> view.destroy() } + } + } +} + +class DesktopView(context: Context, appContext: AppContext) : ExpoView(context, appContext), SurfaceHolder.Callback { + private val onState by EventDispatcher() + private val surface = SurfaceView(context) + private val cover = android.view.View(context).apply { setBackgroundColor(android.graphics.Color.BLACK) } + private val thread = HandlerThread("ZenDesktopDecoder").apply { start() } + private val decoder = Handler(thread.looper) + private val queued = AtomicInteger(0) + private val generation = AtomicInteger(0) + private val client = OkHttpClient.Builder().readTimeout(0, TimeUnit.MILLISECONDS) + .connectTimeout(10, TimeUnit.SECONDS).writeTimeout(2, TimeUnit.SECONDS).build() + private var socket: WebSocket? = null + private var pendingConnection = "" + private var codec: MediaCodec? = null + private var width = 1280 + private var height = 720 + private var needIDR = true + private var presented = 0 + private var dropped = 0 + private var destroyed = false + @Volatile private var lastFrame = 0L + private var terminalState = false + private val heartbeat = object : Runnable { + override fun run() { + send("{\"type\":\"ping\"}") + if (lastFrame > 0 && android.os.SystemClock.elapsedRealtime() - lastFrame > 10000) { + stop(); state("disconnected", "Desktop video timed out."); return + } + if (socket != null) postDelayed(this, 3000) + } + } + + init { + setBackgroundColor(android.graphics.Color.BLACK) + addView(surface, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + addView(cover, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + surface.holder.addCallback(this) + } + + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + val availableW = r - l + val availableH = b - t + val scale = minOf(availableW.toFloat() / width, availableH.toFloat() / height) + val w = (width * scale).toInt() + val h = (height * scale).toInt() + surface.layout((availableW - w) / 2, (availableH - h) / 2, (availableW + w) / 2, (availableH + h) / 2) + cover.layout(0, 0, availableW, availableH) + } + + private fun state(value: String, reason: String = "", epoch: Int = generation.get()) { + val event = mapOf("state" to value, "reason" to reason, "width" to width, "height" to height, "presented" to presented, "dropped" to dropped) + if (android.os.Looper.myLooper() == android.os.Looper.getMainLooper()) { + if (epoch == generation.get() && !destroyed) onState(event) + } else post { if (epoch == generation.get() && !destroyed) onState(event) } + } + + fun connect(value: String) { + if (destroyed || value == pendingConnection) return + stop() + terminalState = false + pendingConnection = value + if (value.isEmpty() || !surface.holder.surface.isValid) return + open(value) + } + + private fun open(value: String) { + val epoch = generation.get() + try { + val config = JSONObject(value) + val url = config.getString("url") + val uri = android.net.Uri.parse(url) + require(uri.scheme == "wss" || (uri.scheme == "ws" && uri.host == "127.0.0.1")) + require(uri.path == "/desktop" && uri.query == null && uri.encodedUserInfo == null) + val request = Request.Builder().url(url).header("Authorization", config.getString("authorization")).build() + socket = client.newWebSocket(request, object : WebSocketListener() { + override fun onOpen(ws: WebSocket, response: Response) { + if (epoch != generation.get()) { ws.cancel(); return } + post { if (epoch == generation.get()) { removeCallbacks(heartbeat); post(heartbeat) } } + } + override fun onMessage(ws: WebSocket, text: String) { + if (epoch != generation.get()) return + if (text.toByteArray(Charsets.UTF_8).size > 8192) { ws.cancel(); return } + try { + val status = JSONObject(text) + // Validate on the receiving thread before posting asynchronous work. + val value = status.getString("state") + require(value in listOf("sources", "requesting", "streaming", "denied", "unsupported", "disconnected")) + require(status.has("width") == status.has("height")) + val nextWidth = if (status.has("width")) status.getInt("width") else null + val nextHeight = if (status.has("height")) status.getInt("height") else null + if (nextWidth != null && nextHeight != null) { + require(nextWidth in 2..4096 && nextHeight in 2..4096) + } + if (queued.incrementAndGet() > 2) { + queued.decrementAndGet(); ws.cancel(); return + } + if (!decoder.post statusWork@{ + if (epoch != generation.get()) { + queued.decrementAndGet(); return@statusWork + } + if (nextWidth != null && nextHeight != null) { + width = nextWidth; height = nextHeight + } + // Retain the queue slot until the main-thread status is handled. + if (!post publishStatus@{ + try { + if (epoch != generation.get()) return@publishStatus + requestLayout() + terminalState = value in listOf("denied", "unsupported", "disconnected") + if (terminalState) { + stop(); state(value, status.optString("reason")); return@publishStatus + } + if (value == "streaming") lastFrame = android.os.SystemClock.elapsedRealtime() + state(value, status.optString("reason"), epoch) + } finally { queued.decrementAndGet() } + }) queued.decrementAndGet() + }) queued.decrementAndGet() + } catch (_: Exception) { ws.cancel() } + } + override fun onMessage(ws: WebSocket, bytes: ByteString) { + if (epoch != generation.get()) return + if (bytes.size > 4 * 1024 * 1024) { ws.cancel(); return } + if (queued.incrementAndGet() > 2) { + queued.decrementAndGet() + // End instead of silently dropping H.264 reference frames. + ws.cancel(); return + } + decoder.post { + try { if (epoch == generation.get()) decode(bytes.toByteArray(), epoch) } + catch (_: Exception) { ws.cancel() } + finally { queued.decrementAndGet() } + } + } + override fun onFailure(ws: WebSocket, error: Throwable, response: Response?) { + post { if (epoch == generation.get()) { stop(); if (!terminalState) state("disconnected", "Desktop connection ended.") } } + } + override fun onClosing(ws: WebSocket, code: Int, reason: String) { + post { if (epoch == generation.get()) { stop(); if (!terminalState) state("disconnected", reason) } } + } + }) + } catch (_: Exception) { state("disconnected", "Desktop requires a secure connection.") } + } + + private fun decode(data: ByteArray, epoch: Int) { + val units = AnnexB.units(data) + val idr = units.any { (it[0].toInt() and 31) == 5 } + if (needIDR && !idr) { dropped++; return } + if (codec == null) { + val sps = units.firstOrNull { (it[0].toInt() and 31) == 7 } ?: return + val pps = units.firstOrNull { (it[0].toInt() and 31) == 8 } ?: return + val format = MediaFormat.createVideoFormat("video/avc", width, height) + format.setByteBuffer("csd-0", ByteBuffer.wrap(byteArrayOf(0, 0, 0, 1) + sps)) + format.setByteBuffer("csd-1", ByteBuffer.wrap(byteArrayOf(0, 0, 0, 1) + pps)) + format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 4 * 1024 * 1024) + if (android.os.Build.VERSION.SDK_INT >= 30) format.setInteger(MediaFormat.KEY_LOW_LATENCY, 1) + val created = MediaCodec.createDecoderByType("video/avc") + codec = created + created.configure(format, surface.holder.surface, null, 0) + created.setOnFrameRenderedListener({ _, _, _ -> + if (epoch == generation.get()) { + presented++; lastFrame = android.os.SystemClock.elapsedRealtime() + post { if (epoch == generation.get()) cover.visibility = GONE } + if (presented == 1 || presented % 30 == 0) state("connected", epoch = epoch) + } + }, decoder) + created.start() + } + val active = codec ?: return + drain(active) + val index = active.dequeueInputBuffer(10000) + if (index < 0) throw IllegalStateException("decoder_backpressure") + val input = active.getInputBuffer(index) ?: throw IllegalStateException("missing_input") + if (input.capacity() < data.size) throw IllegalStateException("oversized_frame") + input.clear(); input.put(data) + active.queueInputBuffer(index, 0, data.size, System.nanoTime() / 1000, 0) + needIDR = false + drain(active) + } + + private fun drain(active: MediaCodec) { + val info = MediaCodec.BufferInfo() + repeat(8) { + val index = active.dequeueOutputBuffer(info, 0) + if (index >= 0) active.releaseOutputBuffer(index, true) + else if (index == MediaCodec.INFO_TRY_AGAIN_LATER) return + } + } + + fun send(value: String) { + val active = socket ?: return + val bytes = value.toByteArray(Charsets.UTF_8).size + if (value.isEmpty() || bytes > 8192) return + if (active.queueSize() + bytes > 32 * 1024 || !active.send(value)) { + stop(); state("disconnected", "Desktop input timed out.") + } + } + + private fun stop() { + generation.incrementAndGet() + pendingConnection = "" + removeCallbacks(heartbeat) + socket?.cancel() + socket = null + lastFrame = 0 + decoder.post { + try { codec?.stop() } catch (_: Exception) {} + codec?.release(); codec = null; needIDR = true; presented = 0; dropped = 0 + } + // Destroy the old surface's retained last frame on every ownership change. + cover.visibility = VISIBLE + } + + fun destroy() { + if (destroyed) return + destroyed = true; pendingConnection = ""; stop() + decoder.post { thread.quitSafely() } + client.dispatcher.executorService.shutdown() + client.connectionPool.evictAll() + } + + override fun surfaceCreated(holder: SurfaceHolder) { if (!destroyed && pendingConnection.isNotEmpty() && socket == null) open(pendingConnection) } + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {} + override fun surfaceDestroyed(holder: SurfaceHolder) { pendingConnection = ""; stop() } + override fun onWindowVisibilityChanged(visibility: Int) { + super.onWindowVisibilityChanged(visibility) + if (visibility != VISIBLE && socket != null) { pendingConnection = ""; stop() } + } +} + +internal object AnnexB { + fun units(data: ByteArray): List { + require(data.size <= 4 * 1024 * 1024) + val starts = mutableListOf>() + var i = 0 + while (i + 3 <= data.size) { + val prefix = if (data[i] == 0.toByte() && data[i + 1] == 0.toByte()) { + if (data[i + 2] == 1.toByte()) 3 + else if (i + 4 <= data.size && data[i + 2] == 0.toByte() && data[i + 3] == 1.toByte()) 4 + else 0 + } else 0 + if (prefix > 0) { starts.add(i to prefix); i += prefix } else i++ + require(starts.size <= 4096) + } + return starts.mapIndexedNotNull { index, (start, prefix) -> + val end = starts.getOrNull(index + 1)?.first ?: data.size + if (end > start + prefix) data.copyOfRange(start + prefix, end) else null + } + } +} diff --git a/app/modules/zen-remote-desktop/android/src/test/java/expo/modules/zenremotedesktop/AnnexBTest.kt b/app/modules/zen-remote-desktop/android/src/test/java/expo/modules/zenremotedesktop/AnnexBTest.kt new file mode 100644 index 00000000..c650fa48 --- /dev/null +++ b/app/modules/zen-remote-desktop/android/src/test/java/expo/modules/zenremotedesktop/AnnexBTest.kt @@ -0,0 +1,26 @@ +package expo.modules.zenremotedesktop + +import org.junit.Assert.* +import org.junit.Test + +class AnnexBTest { + @Test fun splitsMixedPrefixesWithoutIncludingStartCodes() { + val units = AnnexB.units(byteArrayOf(0, 0, 0, 1, 103, 42, 0, 0, 1, 104, 21, 0, 0, 1, 101, 7)) + assertEquals(3, units.size) + assertArrayEquals(byteArrayOf(103, 42), units[0]) + assertArrayEquals(byteArrayOf(104, 21), units[1]) + assertArrayEquals(byteArrayOf(101, 7), units[2]) + } + + @Test fun ignoresEmptyAndTruncatedPrefixes() { + for (data in listOf(byteArrayOf(), byteArrayOf(0, 0), byteArrayOf(0, 0, 1))) { + assertTrue(AnnexB.units(data).isEmpty()) + } + } + + @Test(expected = IllegalArgumentException::class) + fun rejectsOversizedAccessUnit() { AnnexB.units(ByteArray(4 * 1024 * 1024 + 1)) } + + @Test(expected = IllegalArgumentException::class) + fun boundsNalUnitCount() { AnnexB.units(ByteArray(4097 * 4) { if (it % 4 == 2) 1 else 0 }) } +} diff --git a/app/modules/zen-remote-desktop/expo-module.config.json b/app/modules/zen-remote-desktop/expo-module.config.json new file mode 100644 index 00000000..c8c3fa2f --- /dev/null +++ b/app/modules/zen-remote-desktop/expo-module.config.json @@ -0,0 +1,5 @@ +{ + "platforms": ["android", "apple"], + "apple": {"modules": ["ZenRemoteDesktopModule"], "podspecPath": ["ZenRemoteDesktop.podspec"]}, + "android": {"modules": ["expo.modules.zenremotedesktop.ZenRemoteDesktopModule"]} +} diff --git a/app/modules/zen-remote-desktop/ios/ZenRemoteDesktopModule.swift b/app/modules/zen-remote-desktop/ios/ZenRemoteDesktopModule.swift new file mode 100644 index 00000000..84c05a1d --- /dev/null +++ b/app/modules/zen-remote-desktop/ios/ZenRemoteDesktopModule.swift @@ -0,0 +1,255 @@ +import ExpoModulesCore +import AVFoundation +import CoreMedia +import UIKit + +public final class ZenRemoteDesktopModule: Module { + public func definition() -> ModuleDefinition { + Name("ZenRemoteDesktop") + View(DesktopView.self) { + Events("onState") + Prop("connection") { (view: DesktopView, value: String) in view.connect(value) } + Prop("command") { (view: DesktopView, value: String) in view.send(value) } + OnViewDestroys { (view: DesktopView) in view.stop() } + } + } +} + +final class DesktopView: ExpoView { + let onState = EventDispatcher() + private let video = AVSampleBufferDisplayLayer() + private var task: URLSessionWebSocketTask? + private var session: URLSession? + private var heartbeat: Timer? + private var epoch = 0 + private var connection = "" + private var format: CMVideoFormatDescription? + private var needIDR = true + private var submitted = 0 + private var dropped = 0 + private var pendingSends = 0 + private var lastVideo: TimeInterval? + private var terminalState = false + private var width = 1280 + private var height = 720 + private var observer: NSObjectProtocol? + private var displayObserver: NSKeyValueObservation? + + required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + backgroundColor = .black + video.videoGravity = .resizeAspect + layer.addSublayer(video) + observer = NotificationCenter.default.addObserver(forName: UIApplication.willResignActiveNotification, object: nil, queue: .main) { [weak self] _ in + self?.stop() + self?.state("disconnected") + } + displayObserver = video.observe(\.isReadyForDisplay, options: [.new]) { [weak self] layer, _ in + DispatchQueue.main.async { + guard let self, self.task != nil, layer.isReadyForDisplay else { return } + self.state("connected") + } + } + } + + deinit { + if let observer { NotificationCenter.default.removeObserver(observer) } + heartbeat?.invalidate() + task?.cancel(with: .goingAway, reason: nil) + session?.invalidateAndCancel() + } + + override func layoutSubviews() { + super.layoutSubviews() + CATransaction.begin() + CATransaction.setDisableActions(true) + video.frame = bounds + CATransaction.commit() + } + + private func state(_ value: String, _ reason: String = "") { + onState(["state": value, "reason": reason, "width": width, "height": height, + "submitted": submitted, "dropped": dropped]) + } + + func connect(_ value: String) { + guard value != connection else { return } + stop() + terminalState = false + connection = value + guard !value.isEmpty else { return } + guard let bytes = value.data(using: .utf8), + let config = try? JSONSerialization.jsonObject(with: bytes) as? [String: String], + let rawURL = config["url"], let url = URL(string: rawURL), + url.scheme == "wss" || (url.scheme == "ws" && url.host == "127.0.0.1"), + url.path == "/desktop", url.query == nil, url.user == nil, + let authorization = config["authorization"] else { + state("disconnected", "Desktop requires a secure connection."); return + } + var request = URLRequest(url: url) + request.setValue(authorization, forHTTPHeaderField: "Authorization") + request.timeoutInterval = 10 + let ownedSession = URLSession(configuration: .ephemeral) + session = ownedSession + let socket = ownedSession.webSocketTask(with: request) + socket.maximumMessageSize = 4 * 1024 * 1024 + task = socket + socket.resume() + let generation = epoch + receive(socket, generation) + heartbeat = Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { [weak self] _ in + guard let self else { return } + if let last = self.lastVideo, ProcessInfo.processInfo.systemUptime - last > 10 { + self.stop(); self.state("disconnected", "Desktop video timed out."); return + } + self.send("{\"type\":\"ping\"}") + } + } + + private func receive(_ socket: URLSessionWebSocketTask, _ generation: Int) { + socket.receive { [weak self] result in + DispatchQueue.main.async { + guard let self, generation == self.epoch else { return } + do { + switch try result.get() { + case .string(let text): + guard text.utf8.count <= 8192, let data = text.data(using: .utf8), + let status = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let state = status["state"] as? String, + ["sources", "requesting", "streaming", "denied", "unsupported", "disconnected"].contains(state) + else { throw DesktopError.invalidFrame } + guard (status["width"] == nil) == (status["height"] == nil) else { throw DesktopError.invalidFrame } + if status["width"] != nil { + guard let width = status["width"] as? Int, let height = status["height"] as? Int else { throw DesktopError.invalidFrame } + guard (2...4096).contains(width), (2...4096).contains(height) else { throw DesktopError.invalidFrame } + self.width = width; self.height = height + } + self.terminalState = ["denied", "unsupported", "disconnected"].contains(state) + if self.terminalState { + self.stop() + self.state(state, status["reason"] as? String ?? "") + return + } + if state == "streaming" { self.lastVideo = ProcessInfo.processInfo.systemUptime } + self.state(state, status["reason"] as? String ?? "") + case .data(let data): + try self.decode(data) + self.lastVideo = ProcessInfo.processInfo.systemUptime + @unknown default: throw DesktopError.invalidFrame + } + // No unbounded dispatch backlog: request the next AU after this one. + self.receive(socket, generation) + } catch { + self.stop() + if !self.terminalState { self.state("disconnected", "Desktop connection ended.") } + } + } + } + } + + private func decode(_ data: Data) throws { + let units = try annexB(data) + let idr = units.contains { ($0.first ?? 0) & 31 == 5 } + if needIDR && !idr { dropped += 1; return } + if format == nil { + guard let sps = units.first(where: { ($0.first ?? 0) & 31 == 7 }), + let pps = units.first(where: { ($0.first ?? 0) & 31 == 8 }) else { return } + let result = sps.withUnsafeBytes { s in + pps.withUnsafeBytes { p in + let pointers = [s.baseAddress!.assumingMemoryBound(to: UInt8.self), p.baseAddress!.assumingMemoryBound(to: UInt8.self)] + let sizes = [sps.count, pps.count] + return pointers.withUnsafeBufferPointer { ptr in + sizes.withUnsafeBufferPointer { lengths in + CMVideoFormatDescriptionCreateFromH264ParameterSets(allocator: kCFAllocatorDefault, + parameterSetCount: 2, parameterSetPointers: ptr.baseAddress!, parameterSetSizes: lengths.baseAddress!, + nalUnitHeaderLength: 4, formatDescriptionOut: &format) + } + } + } + } + guard result == noErr else { throw DesktopError.invalidFrame } + } + if !video.isReadyForMoreMediaData { throw DesktopError.backpressure } + var avcc = Data() + for unit in units { + var length = UInt32(unit.count).bigEndian + withUnsafeBytes(of: &length) { avcc.append(contentsOf: $0) } + avcc.append(unit) + } + var block: CMBlockBuffer? + guard CMBlockBufferCreateWithMemoryBlock(allocator: kCFAllocatorDefault, memoryBlock: nil, + blockLength: avcc.count, blockAllocator: kCFAllocatorDefault, customBlockSource: nil, + offsetToData: 0, dataLength: avcc.count, flags: 0, blockBufferOut: &block) == noErr, + let block else { throw DesktopError.invalidFrame } + let copied = avcc.withUnsafeBytes { bytes in + CMBlockBufferReplaceDataBytes(with: bytes.baseAddress!, blockBuffer: block, offsetIntoDestination: 0, dataLength: avcc.count) + } + guard copied == noErr else { throw DesktopError.invalidFrame } + var timing = CMSampleTimingInfo(duration: .invalid, presentationTimeStamp: .zero, decodeTimeStamp: .invalid) + var size = avcc.count + var sample: CMSampleBuffer? + guard CMSampleBufferCreateReady(allocator: kCFAllocatorDefault, dataBuffer: block, + formatDescription: format, sampleCount: 1, sampleTimingEntryCount: 1, sampleTimingArray: &timing, + sampleSizeEntryCount: 1, sampleSizeArray: &size, sampleBufferOut: &sample) == noErr, + let sample else { throw DesktopError.invalidFrame } + if let array = CMSampleBufferGetSampleAttachmentsArray(sample, createIfNecessary: true) { + let attachments = unsafeBitCast(CFArrayGetValueAtIndex(array, 0), to: CFMutableDictionary.self) + CFDictionarySetValue(attachments, Unmanaged.passUnretained(kCMSampleAttachmentKey_DisplayImmediately).toOpaque(), Unmanaged.passUnretained(kCFBooleanTrue).toOpaque()) + } + video.enqueue(sample) + guard video.status != .failed else { throw DesktopError.invalidFrame } + submitted += 1; needIDR = false + } + + func send(_ value: String) { + guard !value.isEmpty, value.utf8.count <= 8192, let task else { return } + guard pendingSends < 4 else { + stop(); state("disconnected", "Desktop input timed out."); return + } + pendingSends += 1 + let generation = epoch + task.send(.string(value)) { [weak self] error in + DispatchQueue.main.async { + guard let self, self.epoch == generation else { return } + self.pendingSends -= 1 + if error != nil { + self.stop(); self.state("disconnected") + } + } + } + } + + func stop() { + epoch += 1 + heartbeat?.invalidate(); heartbeat = nil + task?.cancel(with: .normalClosure, reason: nil); task = nil + session?.invalidateAndCancel(); session = nil + video.flushAndRemoveImage() + format = nil; needIDR = true; submitted = 0; dropped = 0 + pendingSends = 0; lastVideo = nil + connection = "" + } +} + +private enum DesktopError: Error { case invalidFrame, backpressure } + +private func annexB(_ data: Data) throws -> [Data] { + guard data.count <= 4 * 1024 * 1024 else { throw DesktopError.invalidFrame } + let bytes = [UInt8](data) + var starts: [(Int, Int)] = [] + var index = 0 + while index + 3 <= bytes.count { + var prefix = 0 + if bytes[index] == 0 && bytes[index + 1] == 0 { + if bytes[index + 2] == 1 { prefix = 3 } + else if index + 4 <= bytes.count && bytes[index + 2] == 0 && bytes[index + 3] == 1 { prefix = 4 } + } + if prefix > 0 { starts.append((index, prefix)); index += prefix } + else { index += 1 } + if starts.count > 4096 { throw DesktopError.invalidFrame } + } + return starts.enumerated().compactMap { index, start in + let end = index + 1 < starts.count ? starts[index + 1].0 : bytes.count + return end > start.0 + start.1 ? Data(bytes[(start.0 + start.1)..; + connection: string; + command: string; + onState: (event: { nativeEvent: DesktopState }) => void; +} + +export const NativeDesktopView: ComponentType = + requireNativeViewManager("ZenRemoteDesktop"); diff --git a/app/services/auth.ts b/app/services/auth.ts index 6c1023f9..7de337b8 100644 --- a/app/services/auth.ts +++ b/app/services/auth.ts @@ -36,6 +36,7 @@ export interface DaemonAssertionInput { } export type AuthPurpose = + | "zen-desktop" | "zen-connect" | "zen-upload" | "zen-probe" diff --git a/app/services/remoteDesktop.ts b/app/services/remoteDesktop.ts new file mode 100644 index 00000000..b13c7594 --- /dev/null +++ b/app/services/remoteDesktop.ts @@ -0,0 +1,11 @@ +import { buildAuthorizationHeader } from "./auth"; +import { resolveStoredServerURL } from "./pinnedTransport"; +import type { StoredServer } from "./storage"; +import { desktopURL } from "./remoteDesktopModel"; + +export async function prepareDesktopConnection(server: StoredServer): Promise { + const resolved = await resolveStoredServerURL(server); + const url = desktopURL(resolved, server.transportKind === "link"); + const authorization = await buildAuthorizationHeader({ daemonId: server.daemonId, purpose: "zen-desktop" }); + return JSON.stringify({ url, authorization }); +} diff --git a/app/services/remoteDesktopModel.test.ts b/app/services/remoteDesktopModel.test.ts new file mode 100644 index 00000000..57121e31 --- /dev/null +++ b/app/services/remoteDesktopModel.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test"; +import { desktopURL, desktopPoint, desktopKey, desktopText } from "./remoteDesktopModel"; + +describe("Remote desktop transport and input", () => { + test("requires TLS or the existing pinned Link loopback", () => { + expect(desktopURL("wss://host.test/ws?old=value", false)).toBe("wss://host.test/desktop"); + expect(desktopURL("ws://127.0.0.1:1234/ws", true)).toBe("ws://127.0.0.1:1234/desktop"); + for (const url of ["ws://192.0.2.1/ws", "ws://127.0.0.1/ws", "wss://user:secret@host/ws"]) { + expect(() => desktopURL(url, false)).toThrow(); + } + expect(() => desktopURL("ws://remote.test/ws", true)).toThrow(); + }); + test("maps the video rectangle and rejects letterboxing", () => { + expect(desktopPoint(200, 400, 400, 800, 1280, 720)).toEqual({ x: 0.5, y: 0.5 }); + expect(desktopPoint(200, 0, 400, 800, 1280, 720)).toBeNull(); + expect(desktopPoint(NaN, 0, 400, 800, 1280, 720)).toBeNull(); + expect(desktopPoint(0, 0, 0, 0, 1280, 720)).toBeNull(); + }); + test("keeps key down and up together, including shifted text", () => { + expect(desktopKey(97)).toEqual([{ type: "key", code: 97, down: true }, { type: "key", code: 97, down: false }]); + const events = desktopText("A!"); + expect(events).toHaveLength(8); + expect(events.at(-1)).toEqual({ type: "key", code: 0xffe1, down: false }); + expect(desktopText("a".repeat(100))).toHaveLength(32); + }); +}); diff --git a/app/services/remoteDesktopModel.ts b/app/services/remoteDesktopModel.ts new file mode 100644 index 00000000..9055b25d --- /dev/null +++ b/app/services/remoteDesktopModel.ts @@ -0,0 +1,45 @@ +export function desktopURL(resolved: string, pinnedLink: boolean): string { + const url = new URL(resolved); + if (url.protocol !== "wss:" && !(pinnedLink && url.protocol === "ws:" && url.hostname === "127.0.0.1")) { + throw new Error("Remote Desktop requires a secure server connection."); + } + if (url.username || url.password) throw new Error("Invalid desktop origin."); + url.pathname = "/desktop"; + url.search = ""; + url.hash = ""; + return url.toString(); +} + +export function desktopPoint(x: number, y: number, viewportWidth: number, viewportHeight: number, frameWidth: number, frameHeight: number) { + if (![x, y, viewportWidth, viewportHeight, frameWidth, frameHeight].every(Number.isFinite) || + Math.min(viewportWidth, viewportHeight, frameWidth, frameHeight) <= 0) return null; + const scale = Math.min(viewportWidth / frameWidth, viewportHeight / frameHeight); + const width = frameWidth * scale; + const height = frameHeight * scale; + const px = (x - (viewportWidth - width) / 2) / width; + const py = (y - (viewportHeight - height) / 2) / height; + return px < 0 || px > 1 || py < 0 || py > 1 ? null : { x: px, y: py }; +} + +export type DesktopInput = + | { type: "pointer"; x: number; y: number } + | { type: "button" | "key"; code: number; down: boolean } + | { type: "scroll"; delta: number } + | { type: "release" }; + +export function desktopKey(code: number): DesktopInput[] { + return [{ type: "key", code, down: true }, { type: "key", code, down: false }]; +} + +export function desktopText(text: string): DesktopInput[] { + const events: DesktopInput[] = []; + for (const char of text.slice(0, 16)) { + const code = char.codePointAt(0)!; + if (code < 32 || code > 126) continue; + const shifted = /[A-Z~!@#$%^&*()_+{}|:"<>?]/.test(char); + if (shifted) events.push({ type: "key", code: 0xffe1, down: true }); + events.push(...desktopKey(code)); + if (shifted) events.push({ type: "key", code: 0xffe1, down: false }); + } + return events; +} diff --git a/app/services/remoteDesktopNativeContract.test.ts b/app/services/remoteDesktopNativeContract.test.ts new file mode 100644 index 00000000..eaa0fb05 --- /dev/null +++ b/app/services/remoteDesktopNativeContract.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; + +const android = readFileSync(new URL("../modules/zen-remote-desktop/android/src/main/java/expo/modules/zenremotedesktop/ZenRemoteDesktopModule.kt", import.meta.url), "utf8"); +const ios = readFileSync(new URL("../modules/zen-remote-desktop/ios/ZenRemoteDesktopModule.swift", import.meta.url), "utf8"); + +// Source-contract checks only. Native toolchains and owned devices remain required. +describe("Remote desktop native lifecycle source contracts", () => { + test("Android validates metadata before asynchronous decoder work", () => { + const start = android.indexOf("override fun onMessage(ws: WebSocket, text: String)"); + const end = android.indexOf("override fun onMessage(ws: WebSocket, bytes: ByteString)"); + const metadata = android.slice(start, end); + const dispatch = metadata.indexOf("decoder.post statusWork@"); + expect(dispatch).toBeGreaterThan(0); + expect(metadata.indexOf('status.getString("state")')).toBeLessThan(dispatch); + expect(metadata.indexOf('status.getInt("width")')).toBeLessThan(dispatch); + expect(metadata.indexOf("require(nextWidth in 2..4096 && nextHeight in 2..4096)")).toBeLessThan(dispatch); + expect(metadata.slice(dispatch)).not.toContain("status.getInt"); + expect(metadata.slice(dispatch)).not.toContain("status.getString"); + expect(metadata).toContain("queued.incrementAndGet() > 2"); + expect(metadata).toContain("finally { queued.decrementAndGet() }"); + }); + + test("Android stop forgets reconnect credentials and covers retained frames", () => { + const stop = android.slice(android.indexOf("private fun stop()"), android.indexOf("fun destroy()")); + expect(stop).toContain('pendingConnection = ""'); + expect(stop).toContain("generation.incrementAndGet()"); + expect(stop).toContain("socket?.cancel()"); + expect(stop).toContain("cover.visibility = VISIBLE"); + }); + + test("terminal native status ends ownership without waiting for socket closure", () => { + expect(android).toContain('if (terminalState) {\n stop(); state(value, status.optString("reason")); return@publishStatus'); + expect(ios).toContain('if self.terminalState {\n self.stop()\n self.state(state, status["reason"] as? String ?? "")\n return'); + }); + + test("only native presentation can report connected", () => { + const allowed = '["sources", "requesting", "streaming", "denied", "unsupported", "disconnected"]'; + expect(ios).toContain(`${allowed}.contains(state)`); + expect(android).toContain('require(value in listOf("sources", "requesting", "streaming", "denied", "unsupported", "disconnected"))'); + expect(android).toContain("setOnFrameRenderedListener"); + expect(ios).toContain("layer.isReadyForDisplay"); + }); + + test("Android counts UTF-8 bytes and includes new input in the queue limit", () => { + expect(android).toContain("text.toByteArray(Charsets.UTF_8).size > 8192"); + expect(android).toContain("val bytes = value.toByteArray(Charsets.UTF_8).size"); + expect(android).toContain("value.isEmpty() || bytes > 8192"); + expect(android).toContain("active.queueSize() + bytes > 32 * 1024"); + expect(ios).toContain("pendingSends < 4"); + }); + + test("iOS rejects incomplete dimensions and invalidates callbacks on stop", () => { + expect(ios).toContain('(status["width"] == nil) == (status["height"] == nil)'); + expect(ios).toContain('guard let width = status["width"] as? Int, let height = status["height"] as? Int else'); + expect(ios).toContain("generation == self.epoch"); + expect(ios).toContain("epoch += 1"); + expect(ios).toContain("video.flushAndRemoveImage()"); + }); +}); diff --git a/app/tests/remoteDesktopNativeHarness.tsx b/app/tests/remoteDesktopNativeHarness.tsx new file mode 100644 index 00000000..7a314752 --- /dev/null +++ b/app/tests/remoteDesktopNativeHarness.tsx @@ -0,0 +1,37 @@ +// Explicit test entry only; never imported by runtime routes. +import React, { useState } from "react"; +import { registerRootComponent } from "expo"; +import { Button, Text, View } from "react-native"; +import { NativeDesktopView } from "../modules/zen-remote-desktop/src"; +import { startPinnedTunnel, stopPinnedTunnel } from "../modules/zen-link-transport/src"; + +function Harness() { + const [connection, setConnection] = useState(""); + const [command, setCommand] = useState(""); + const [status, setStatus] = useState("Disconnected"); + const send = (value: object) => setCommand(JSON.stringify({ ...value, sequence: Date.now() })); + const connect = async () => { + try { + await stopPinnedTunnel("desktop-owned-test").catch(() => undefined); + const config = await (await fetch("http://127.0.0.1:18089/config")).json(); + const tunnel = await startPinnedTunnel("desktop-owned-test", "10.0.2.2", Number(config.port), config.pin, "on-demand"); + setConnection(JSON.stringify({ url: `ws://127.0.0.1:${tunnel.port}/desktop`, authorization: config.authorization })); + } catch (error) { setStatus(String(error)); } + }; + return + Zen native desktop verification + {status} + { + setStatus(JSON.stringify(nativeEvent)); + }} /> +