ソースを参照

built-in VNC server for remote access

Milan Jurkulak 3 ヶ月 前
親
コミット
8987a63082

+ 55 - 2
shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/pages/remotes/RemotesSettingsPage.kt

@@ -1,25 +1,78 @@
 package org.mjdev.desktop.components.controlcenter.pages.remotes
 
 import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
 import org.mjdev.desktop.components.controlcenter.base.ControlCenterPage
+import org.mjdev.desktop.components.controlcenter.settings.SettingsSection
+import org.mjdev.desktop.components.controlcenter.settings.SettingsSwitchRow
+import org.mjdev.desktop.components.text.TextAny
 import org.mjdev.desktop.context.IDesktopContext
+import org.mjdev.desktop.extensions.Colors.alpha
 import org.mjdev.desktop.extensions.Compose.preview
 import org.mjdev.desktop.icons.custom.Mjdev
 
+/**
+ * Control-center page for the built-in remote-desktop (VNC/RFB) server: a switch to start/stop
+ * it and the list of `ip:port` addresses a viewer can connect to. The server lives in
+ * [org.mjdev.desktop.managers.remote.IRemoteDesktopManager] — its desktop implementation
+ * captures the screen and injects input via AWT Robot, so it works in plain runDesktop.
+ */
 @Suppress("FunctionName")
 fun RemotesSettingsPage(context: IDesktopContext) = ControlCenterPage(
     context = context,
-    icon = Mjdev, // todo
+    icon = Mjdev, // todo dedicated icon
     name = "Remote",
     condition = { true },
 ) {
+    val remote = context.remoteDesktop
+    val running by remote.runningState
+
     Box(
         modifier = Modifier.fillMaxSize(),
-    )
+    ) {
+        SettingsSection(title = "Remote desktop (VNC)") {
+            SettingsSwitchRow(
+                label = "Allow remote connections",
+                checked = running,
+                onCheckedChange = { runAsync { remote.toggle() } },
+            )
+            if (running) {
+                TextAny(
+                    modifier = Modifier.padding(top = 8.dp),
+                    text = "Connect a VNC viewer to:",
+                    color = textColor,
+                    fontSize = 13.sp,
+                )
+                val addresses = remote.addresses
+                if (addresses.isEmpty()) {
+                    TextAny(
+                        modifier = Modifier.padding(top = 4.dp),
+                        text = "port ${remote.port} (no network address found)",
+                        color = textColor.alpha(0.6f),
+                        fontSize = 13.sp,
+                    )
+                } else {
+                    Column(modifier = Modifier.padding(top = 4.dp)) {
+                        addresses.forEach { address ->
+                            TextAny(
+                                text = address,
+                                color = textColor.alpha(0.8f),
+                                fontSize = 14.sp,
+                            )
+                        }
+                    }
+                }
+            }
+        }
+    }
 }
 
 @Preview

+ 2 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/context/IDesktopContext.kt

@@ -36,6 +36,7 @@ import org.mjdev.desktop.managers.keys.IKeyManager
 import org.mjdev.desktop.managers.os.IOSManager
 import org.mjdev.desktop.managers.palette.IPalette
 import org.mjdev.desktop.managers.process.IProcessManager
+import org.mjdev.desktop.managers.remote.IRemoteDesktopManager
 import org.mjdev.desktop.managers.theme.IThemeManager
 import org.mjdev.desktop.managers.translations.ITranslator
 import kotlin.coroutines.CoroutineContext
@@ -74,6 +75,7 @@ abstract class IDesktopContext : IDisposable {
     open val palette: IPalette by this
     open val translator: ITranslator by this
     open val keysManager: IKeyManager by this
+    open val remoteDesktop: IRemoteDesktopManager by this
 
     abstract var isFirstStart: Boolean
     abstract var isInstalled: Boolean

+ 44 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/managers/remote/IRemoteDesktopManager.kt

@@ -0,0 +1,44 @@
+package org.mjdev.desktop.managers.remote
+
+import androidx.compose.runtime.State
+import androidx.compose.runtime.mutableStateOf
+import org.mjdev.desktop.managers.base.IDelegate
+
+/**
+ * Serves the running desktop over a standardized remote-desktop protocol (RFB / VNC) so any
+ * VNC viewer can connect by IP address and see + control it. Platform-agnostic contract; the
+ * actual framebuffer capture + input injection live in the platform implementation
+ * (desktopMain uses java.awt.Robot, so it works in plain `runDesktop` with no compositor).
+ */
+interface IRemoteDesktopManager : IDelegate {
+    /** Reactive running flag so the control-center toggle reflects the true server state. */
+    val runningState: State<Boolean>
+    val isRunning: Boolean get() = runningState.value
+
+    /** TCP port the RFB server listens on (5900 = VNC display :0). */
+    val port: Int
+
+    /** LAN IPv4 addresses a viewer can connect to, as `ip:port` strings. */
+    val addresses: List<String>
+
+    suspend fun start()
+
+    suspend fun stop()
+
+    suspend fun toggle() {
+        if (isRunning) stop() else start()
+    }
+
+    companion object {
+        val EMPTY =
+            object : IRemoteDesktopManager {
+                override val runningState = mutableStateOf(false)
+                override val port = 5900
+                override val addresses = emptyList<String>()
+
+                override suspend fun start() = Unit
+
+                override suspend fun stop() = Unit
+            }
+    }
+}

+ 5 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/components/main/MainWindow.kt

@@ -198,6 +198,11 @@ fun MainWindow() = withDesktopContext {
         Log.i("App started with args: $appArgs")
         Log.i("First start : $isFirstStart")
         Log.i("Debug mode : $isDebug")
+        // Headless/kiosk convenience: start the remote-desktop (VNC) server at boot when
+        // MJDEV_VNC_AUTOSTART is set, so a machine with no local input can be driven remotely.
+        if (System.getenv("MJDEV_VNC_AUTOSTART")?.toBooleanStrictOrNull() == true) {
+            runAsync { context.remoteDesktop.start() }
+        }
         Shell {
             if (!isDebug) {
                 Log.i("Starting autostart apps")

+ 3 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/context/DeskptopContext.kt

@@ -44,6 +44,8 @@ import org.mjdev.desktop.managers.palette.IPalette
 import org.mjdev.desktop.managers.palette.Palette
 import org.mjdev.desktop.managers.process.IProcessManager
 import org.mjdev.desktop.managers.processes.ProcessManager
+import org.mjdev.desktop.managers.remote.IRemoteDesktopManager
+import org.mjdev.desktop.managers.remote.RemoteDesktopManager
 import org.mjdev.desktop.managers.theme.IThemeManager
 import org.mjdev.desktop.managers.theme.ThemeManager
 import org.mjdev.desktop.managers.translations.ITranslator
@@ -260,6 +262,7 @@ class DesktopContext(
         IThemeManager::class -> ThemeManager(this)
         IProcessManager::class -> ProcessManager(this)
         IKeyManager::class -> KeysManager(this)
+        IRemoteDesktopManager::class -> RemoteDesktopManager(this)
         else ->
             cls.companionObject
                 ?.members

+ 112 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/managers/remote/RemoteDesktopManager.kt

@@ -0,0 +1,112 @@
+package org.mjdev.desktop.managers.remote
+
+import androidx.compose.runtime.State
+import androidx.compose.runtime.mutableStateOf
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.mjdev.desktop.context.IDesktopContext
+import org.mjdev.desktop.log.Log
+import java.awt.Robot
+import java.net.InetAddress
+import java.net.NetworkInterface
+import java.net.ServerSocket
+import java.net.Socket
+import kotlin.concurrent.thread
+
+/**
+ * RFB (VNC) server for the desktop shell. Accepts viewers on [port] and hands each connection
+ * to an [RfbClient] on its own thread. Screen capture + input injection go through a single
+ * [Robot], so this works in plain `runDesktop` (JVM/AWT) with no compositor — the Kotlin-first
+ * source of truth. Connect from any VNC viewer at one of [addresses].
+ */
+class RemoteDesktopManager(
+    @Suppress("unused") private val context: IDesktopContext,
+    override val port: Int = DEFAULT_PORT,
+) : IRemoteDesktopManager {
+    private val running = mutableStateOf(false)
+    override val runningState: State<Boolean> get() = running
+
+    private var serverSocket: ServerSocket? = null
+    private var acceptThread: Thread? = null
+    private val clients = mutableListOf<RfbClient>()
+    private val robot: Robot by lazy { Robot() }
+
+    override val addresses: List<String>
+        get() = localAddresses().map { "$it:$port" }
+
+    override suspend fun start() = withContext(Dispatchers.IO) {
+        if (running.value) return@withContext
+        runCatching {
+            val server = ServerSocket(port)
+            serverSocket = server
+            running.value = true
+            Log.i("RemoteDesktopManager: VNC server listening on ${addresses.joinToString()}")
+            acceptThread =
+                thread(name = "mjdev-vnc-accept", isDaemon = true) {
+                    acceptLoop(server)
+                }
+        }.onFailure { e ->
+            running.value = false
+            Log.e("RemoteDesktopManager: cannot start on port $port: ${e.message}")
+        }
+        Unit
+    }
+
+    private fun acceptLoop(server: ServerSocket) {
+        while (!server.isClosed) {
+            val socket =
+                runCatching { server.accept() }.getOrElse { break }
+            handleClient(socket)
+        }
+    }
+
+    private fun handleClient(socket: Socket) {
+        thread(name = "mjdev-vnc-client", isDaemon = true) {
+            val client = RfbClient(socket, robot)
+            synchronized(clients) { clients.add(client) }
+            Log.i("RemoteDesktopManager: client connected ${socket.remoteSocketAddress}")
+            runCatching { client.serve() }
+                .onFailure { e -> Log.d("RemoteDesktopManager: client ended: ${e.message}") }
+            client.close()
+            synchronized(clients) { clients.remove(client) }
+        }
+    }
+
+    override suspend fun stop() = withContext(Dispatchers.IO) {
+        if (!running.value) return@withContext
+        running.value = false
+        synchronized(clients) {
+            clients.forEach { it.close() }
+            clients.clear()
+        }
+        runCatching { serverSocket?.close() }
+        serverSocket = null
+        acceptThread = null
+        Log.i("RemoteDesktopManager: VNC server stopped")
+    }
+
+    fun dispose() {
+        running.value = false
+        synchronized(clients) {
+            clients.forEach { it.close() }
+            clients.clear()
+        }
+        runCatching { serverSocket?.close() }
+    }
+
+    private fun localAddresses(): List<String> = runCatching {
+        NetworkInterface
+            .getNetworkInterfaces()
+            .toList()
+            .filter { it.isUp && !it.isLoopback }
+            .flatMap { it.inetAddresses.toList() }
+            .filterIsInstance<InetAddress>()
+            .filter { !it.isLoopbackAddress && it.address.size == 4 }
+            .map { it.hostAddress }
+    }.getOrDefault(emptyList())
+
+    companion object {
+        // 5900 = VNC display :0, the port every viewer defaults to
+        private const val DEFAULT_PORT = 5900
+    }
+}

+ 231 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/managers/remote/RfbClient.kt

@@ -0,0 +1,231 @@
+package org.mjdev.desktop.managers.remote
+
+import org.mjdev.desktop.log.Log
+import java.awt.Rectangle
+import java.awt.Robot
+import java.awt.Toolkit
+import java.awt.event.InputEvent
+import java.io.BufferedInputStream
+import java.io.DataInputStream
+import java.io.DataOutputStream
+import java.net.Socket
+
+/**
+ * One connected RFB (VNC) client. Speaks RFB 3.8 with "None" security and serves the whole
+ * screen as Raw-encoded 32bpp rectangles captured through [Robot]; pointer and key events from
+ * the viewer are injected back through the same [Robot]. Raw encoding only — universally
+ * supported by every viewer, at the cost of bandwidth (fine on a LAN).
+ *
+ * Blocking sockets on a dedicated thread (owned by [RemoteDesktopManager]); the run loop exits
+ * when the socket closes or the server stops.
+ */
+class RfbClient(
+    private val socket: Socket,
+    private val robot: Robot,
+) {
+    private val input = DataInputStream(BufferedInputStream(socket.getInputStream()))
+    private val output = DataOutputStream(socket.getOutputStream())
+
+    private val screen = Toolkit.getDefaultToolkit().screenSize
+    private val width = screen.width
+    private val height = screen.height
+
+    fun serve() {
+        handshake()
+        messageLoop()
+    }
+
+    fun close() = runCatching { socket.close() }
+
+    // ---- RFB 3.8 handshake ------------------------------------------------
+    private fun handshake() {
+        output.writeBytes(PROTOCOL_VERSION)
+        output.flush()
+        val clientVersion = ByteArray(12).also { input.readFully(it) }
+        Log.d("RfbClient: client version ${String(clientVersion).trim()}")
+
+        // security: advertise only "None" (1)
+        output.writeByte(1)
+        output.writeByte(SECURITY_NONE)
+        output.flush()
+        val chosen = input.readUnsignedByte()
+        if (chosen != SECURITY_NONE) throw IllegalStateException("unsupported security $chosen")
+        output.writeInt(0) // SecurityResult: OK
+
+        input.readUnsignedByte() // ClientInit shared-flag (ignored — we always allow sharing)
+        writeServerInit()
+    }
+
+    private fun writeServerInit() {
+        output.writeShort(width)
+        output.writeShort(height)
+        writePixelFormat()
+        val name = SERVER_NAME.toByteArray()
+        output.writeInt(name.size)
+        output.write(name)
+        output.flush()
+    }
+
+    // 32bpp true-colour BGRX (the most compatible fixed format)
+    private fun writePixelFormat() {
+        output.writeByte(32) // bits-per-pixel
+        output.writeByte(24) // depth
+        output.writeByte(0) // big-endian-flag
+        output.writeByte(1) // true-colour-flag
+        output.writeShort(255) // red-max
+        output.writeShort(255) // green-max
+        output.writeShort(255) // blue-max
+        output.writeByte(16) // red-shift
+        output.writeByte(8) // green-shift
+        output.writeByte(0) // blue-shift
+        output.write(ByteArray(3)) // padding
+    }
+
+    // ---- client -> server message loop ------------------------------------
+    private fun messageLoop() {
+        while (!socket.isClosed) {
+            when (input.readUnsignedByte()) {
+                MSG_SET_PIXEL_FORMAT -> skip(3 + 16)
+                MSG_SET_ENCODINGS -> {
+                    skip(1)
+                    val count = input.readUnsignedShort()
+                    skip(count * 4)
+                }
+                MSG_FRAMEBUFFER_UPDATE_REQUEST -> {
+                    skip(1) // incremental flag — we always send the full rect (Raw, correct either way)
+                    val x = input.readUnsignedShort()
+                    val y = input.readUnsignedShort()
+                    val w = input.readUnsignedShort()
+                    val h = input.readUnsignedShort()
+                    sendFramebuffer(x, y, w, h)
+                }
+                MSG_KEY_EVENT -> {
+                    val down = input.readUnsignedByte() != 0
+                    skip(2)
+                    val keysym = input.readInt().toLong() and 0xFFFFFFFFL
+                    injectKey(keysym, down)
+                }
+                MSG_POINTER_EVENT -> {
+                    val buttonMask = input.readUnsignedByte()
+                    val x = input.readUnsignedShort()
+                    val y = input.readUnsignedShort()
+                    injectPointer(buttonMask, x, y)
+                }
+                MSG_CLIENT_CUT_TEXT -> {
+                    skip(3)
+                    val len = input.readInt()
+                    skip(len)
+                }
+                else -> return // unknown message: drop the client rather than desync the stream
+            }
+        }
+    }
+
+    private fun sendFramebuffer(
+        x: Int,
+        y: Int,
+        w: Int,
+        h: Int,
+    ) {
+        val rw = w.coerceAtMost(width - x).coerceAtLeast(0)
+        val rh = h.coerceAtMost(height - y).coerceAtLeast(0)
+        output.writeByte(MSG_FRAMEBUFFER_UPDATE)
+        output.writeByte(0) // padding
+        if (rw == 0 || rh == 0) {
+            output.writeShort(0)
+            output.flush()
+            return
+        }
+        output.writeShort(1) // one rectangle
+        output.writeShort(x)
+        output.writeShort(y)
+        output.writeShort(rw)
+        output.writeShort(rh)
+        output.writeInt(ENCODING_RAW)
+        // Capture on THIS client thread, never the EDT: on a Wayland host Robot capture goes
+        // through the (blocking) XDG screencast portal, and doing that on the EDT freezes the
+        // whole Compose UI. On the client thread only this one viewer stalls until the portal
+        // grants access (X11 sessions capture directly with no portal).
+        val image = robot.createScreenCapture(Rectangle(x, y, rw, rh))
+        val pixels = image.getRGB(0, 0, rw, rh, null, 0, rw)
+        val bytes = ByteArray(rw * rh * 4)
+        var b = 0
+        for (argb in pixels) {
+            bytes[b++] = (argb and 0xFF).toByte() // blue
+            bytes[b++] = (argb ushr 8 and 0xFF).toByte() // green
+            bytes[b++] = (argb ushr 16 and 0xFF).toByte() // red
+            bytes[b++] = 0 // padding (x)
+        }
+        output.write(bytes)
+        output.flush()
+    }
+
+    private fun injectKey(
+        keysym: Long,
+        down: Boolean,
+    ) {
+        val code = RfbKeymap.keyCode(keysym) ?: return
+        runCatching {
+            if (down) robot.keyPress(code) else robot.keyRelease(code)
+        }.onFailure { e -> Log.w("RfbClient: key $keysym failed: ${e.message}") }
+    }
+
+    private var lastButtons = 0
+
+    private fun injectPointer(
+        buttonMask: Int,
+        x: Int,
+        y: Int,
+    ) {
+        robot.mouseMove(x, y)
+        // bit0=left, bit1=middle, bit2=right; bit3/4 = wheel up/down
+        updateButton(buttonMask, 0x1, InputEvent.BUTTON1_DOWN_MASK)
+        updateButton(buttonMask, 0x2, InputEvent.BUTTON2_DOWN_MASK)
+        updateButton(buttonMask, 0x4, InputEvent.BUTTON3_DOWN_MASK)
+        if (buttonMask and 0x8 != 0) robot.mouseWheel(-1)
+        if (buttonMask and 0x10 != 0) robot.mouseWheel(1)
+        lastButtons = buttonMask
+    }
+
+    private fun updateButton(
+        mask: Int,
+        bit: Int,
+        awtMask: Int,
+    ) {
+        val nowDown = mask and bit != 0
+        val wasDown = lastButtons and bit != 0
+        if (nowDown && !wasDown) robot.mousePress(awtMask)
+        if (!nowDown && wasDown) robot.mouseRelease(awtMask)
+    }
+
+    private fun skip(n: Int) {
+        var remaining = n
+        while (remaining > 0) {
+            val skipped = input.skip(remaining.toLong()).toInt()
+            if (skipped <= 0) {
+                input.readByte()
+                remaining--
+            } else {
+                remaining -= skipped
+            }
+        }
+    }
+
+    companion object {
+        private const val PROTOCOL_VERSION = "RFB 003.008\n"
+        private const val SERVER_NAME = "mjdev-desktop"
+        private const val SECURITY_NONE = 1
+        private const val ENCODING_RAW = 0
+
+        // client -> server message types
+        private const val MSG_SET_PIXEL_FORMAT = 0
+        private const val MSG_SET_ENCODINGS = 2
+        private const val MSG_FRAMEBUFFER_UPDATE_REQUEST = 3
+        private const val MSG_KEY_EVENT = 4
+        private const val MSG_POINTER_EVENT = 5
+        private const val MSG_CLIENT_CUT_TEXT = 6
+
+        // server -> client message type
+        private const val MSG_FRAMEBUFFER_UPDATE = 0
+    }
+}

+ 60 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/managers/remote/RfbKeymap.kt

@@ -0,0 +1,60 @@
+package org.mjdev.desktop.managers.remote
+
+import java.awt.event.KeyEvent
+
+/**
+ * Translates X11 keysyms (what an RFB/VNC client sends in a KeyEvent) into AWT virtual key
+ * codes for java.awt.Robot. Printable Latin-1 keysyms equal their Unicode code point and are
+ * handled generically; only the non-printable/function keys need this explicit table.
+ */
+object RfbKeymap {
+    private val special: Map<Long, Int> =
+        mapOf(
+            0xFF08L to KeyEvent.VK_BACK_SPACE,
+            0xFF09L to KeyEvent.VK_TAB,
+            0xFF0DL to KeyEvent.VK_ENTER,
+            0xFF1BL to KeyEvent.VK_ESCAPE,
+            0xFF63L to KeyEvent.VK_INSERT,
+            0xFFFFL to KeyEvent.VK_DELETE,
+            0xFF50L to KeyEvent.VK_HOME,
+            0xFF57L to KeyEvent.VK_END,
+            0xFF55L to KeyEvent.VK_PAGE_UP,
+            0xFF56L to KeyEvent.VK_PAGE_DOWN,
+            0xFF51L to KeyEvent.VK_LEFT,
+            0xFF52L to KeyEvent.VK_UP,
+            0xFF53L to KeyEvent.VK_RIGHT,
+            0xFF54L to KeyEvent.VK_DOWN,
+            0xFFE1L to KeyEvent.VK_SHIFT,
+            0xFFE2L to KeyEvent.VK_SHIFT,
+            0xFFE3L to KeyEvent.VK_CONTROL,
+            0xFFE4L to KeyEvent.VK_CONTROL,
+            0xFFE9L to KeyEvent.VK_ALT,
+            0xFFEAL to KeyEvent.VK_ALT,
+            0xFFEBL to KeyEvent.VK_META,
+            0xFFECL to KeyEvent.VK_META,
+            0xFF20L to KeyEvent.VK_CAPS_LOCK,
+            0xFFBEL to KeyEvent.VK_F1,
+            0xFFBFL to KeyEvent.VK_F2,
+            0xFFC0L to KeyEvent.VK_F3,
+            0xFFC1L to KeyEvent.VK_F4,
+            0xFFC2L to KeyEvent.VK_F5,
+            0xFFC3L to KeyEvent.VK_F6,
+            0xFFC4L to KeyEvent.VK_F7,
+            0xFFC5L to KeyEvent.VK_F8,
+            0xFFC6L to KeyEvent.VK_F9,
+            0xFFC7L to KeyEvent.VK_F10,
+            0xFFC8L to KeyEvent.VK_F11,
+            0xFFC9L to KeyEvent.VK_F12,
+        )
+
+    /** The AWT key code for a keysym, or null if it cannot be typed via a virtual key. */
+    fun keyCode(keysym: Long): Int? {
+        special[keysym]?.let { return it }
+        // printable Latin-1 (0x20..0xFE) == the Unicode char; let AWT resolve its VK
+        if (keysym in 0x20..0xFF) {
+            val code = KeyEvent.getExtendedKeyCodeForChar(keysym.toInt())
+            if (code != KeyEvent.VK_UNDEFINED) return code
+        }
+        return null
+    }
+}