|
@@ -1,35 +1,46 @@
|
|
|
package org.mjdev.desktop.managers.remote
|
|
package org.mjdev.desktop.managers.remote
|
|
|
|
|
|
|
|
|
|
+import androidx.compose.remote.server.RemoteComposeServer
|
|
|
|
|
+import androidx.compose.remote.client.RemoteComposeClient
|
|
|
|
|
+import androidx.compose.remote.client.RemoteComposeView as LibraryRemoteComposeView
|
|
|
|
|
+import androidx.compose.runtime.Composable
|
|
|
import androidx.compose.runtime.State
|
|
import androidx.compose.runtime.State
|
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.mutableStateOf
|
|
|
|
|
+import androidx.compose.runtime.mutableStateListOf
|
|
|
|
|
+import kotlinx.coroutines.CoroutineScope
|
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.Dispatchers
|
|
|
|
|
+import kotlinx.coroutines.Job
|
|
|
|
|
+import kotlinx.coroutines.SupervisorJob
|
|
|
|
|
+import kotlinx.coroutines.launch
|
|
|
import kotlinx.coroutines.withContext
|
|
import kotlinx.coroutines.withContext
|
|
|
import org.mjdev.desktop.context.IDesktopContext
|
|
import org.mjdev.desktop.context.IDesktopContext
|
|
|
import org.mjdev.desktop.log.Log
|
|
import org.mjdev.desktop.log.Log
|
|
|
-import java.awt.Robot
|
|
|
|
|
import java.net.InetAddress
|
|
import java.net.InetAddress
|
|
|
import java.net.NetworkInterface
|
|
import java.net.NetworkInterface
|
|
|
-import java.net.ServerSocket
|
|
|
|
|
-import java.net.Socket
|
|
|
|
|
-import kotlin.concurrent.thread
|
|
|
|
|
|
|
+import java.net.URI
|
|
|
|
|
+import javax.sound.sampled.AudioFormat
|
|
|
|
|
+import javax.sound.sampled.AudioSystem
|
|
|
|
|
+import javax.sound.sampled.DataLine
|
|
|
|
|
+import javax.sound.sampled.SourceDataLine
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * 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].
|
|
|
|
|
|
|
+ * Implementace pro Compose Remote (WebSocket + Compose UI streaming + Audio).
|
|
|
|
|
+ * Server: [RemoteComposeServer] na portech 8080 (WS) + 8081 (HTTP pro resources).
|
|
|
|
|
+ * Klient: [RemoteComposeClient] připojující se na ws://host:port/compose.
|
|
|
|
|
+ * Audio: samostatný WebSocket endpoint na ws://host:port/audio pro PCM stream.
|
|
|
*/
|
|
*/
|
|
|
class RemoteDesktopManager(
|
|
class RemoteDesktopManager(
|
|
|
- @Suppress("unused") private val context: IDesktopContext,
|
|
|
|
|
|
|
+ private val context: IDesktopContext,
|
|
|
override val port: Int = DEFAULT_PORT,
|
|
override val port: Int = DEFAULT_PORT,
|
|
|
) : IRemoteDesktopManager {
|
|
) : IRemoteDesktopManager {
|
|
|
|
|
+
|
|
|
|
|
+ // ──────────────────────────────────────────────────────────────
|
|
|
|
|
+ // SERVER (sdílení lokálního Compose UI)
|
|
|
|
|
+ // ──────────────────────────────────────────────────────────────
|
|
|
private val running = mutableStateOf(false)
|
|
private val running = mutableStateOf(false)
|
|
|
override val runningState: State<Boolean> get() = running
|
|
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() }
|
|
|
|
|
|
|
+ private var server: RemoteComposeServer? = null
|
|
|
|
|
|
|
|
override val addresses: List<String>
|
|
override val addresses: List<String>
|
|
|
get() = localAddresses().map { "$it:$port" }
|
|
get() = localAddresses().map { "$it:$port" }
|
|
@@ -37,61 +48,73 @@ class RemoteDesktopManager(
|
|
|
override suspend fun start() = withContext(Dispatchers.IO) {
|
|
override suspend fun start() = withContext(Dispatchers.IO) {
|
|
|
if (running.value) return@withContext
|
|
if (running.value) return@withContext
|
|
|
runCatching {
|
|
runCatching {
|
|
|
- val server = ServerSocket(port)
|
|
|
|
|
- serverSocket = server
|
|
|
|
|
|
|
+ server = RemoteComposeServer(port = port, assetPort = port + 1)
|
|
|
|
|
+ server?.start()
|
|
|
running.value = true
|
|
running.value = true
|
|
|
- Log.i("RemoteDesktopManager: VNC server listening on ${addresses.joinToString()}")
|
|
|
|
|
- acceptThread =
|
|
|
|
|
- thread(name = "mjdev-vnc-accept", isDaemon = true) {
|
|
|
|
|
- acceptLoop(server)
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ Log.i("RemoteDesktopManager: Compose Remote server listening on ${addresses.joinToString()} (WS) / ${localAddresses().map { "$it:${port + 1}" }.joinToString()} (HTTP assets)")
|
|
|
}.onFailure { e ->
|
|
}.onFailure { e ->
|
|
|
running.value = false
|
|
running.value = false
|
|
|
- Log.e("RemoteDesktopManager: cannot start on port $port: ${e.message}")
|
|
|
|
|
|
|
+ Log.e("RemoteDesktopManager: cannot start server on port $port: ${e.message}")
|
|
|
}
|
|
}
|
|
|
Unit
|
|
Unit
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private fun acceptLoop(server: ServerSocket) {
|
|
|
|
|
- while (!server.isClosed) {
|
|
|
|
|
- val socket =
|
|
|
|
|
- runCatching { server.accept() }.getOrElse { break }
|
|
|
|
|
- handleClient(socket)
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ override suspend fun stop() = withContext(Dispatchers.IO) {
|
|
|
|
|
+ if (!running.value) return@withContext
|
|
|
|
|
+ running.value = false
|
|
|
|
|
+ server?.stop()
|
|
|
|
|
+ server = null
|
|
|
|
|
+ Log.i("RemoteDesktopManager: Compose Remote server stopped")
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- 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) }
|
|
|
|
|
|
|
+ // ──────────────────────────────────────────────────────────────
|
|
|
|
|
+ // CLIENT (zobrazení vzdálených Compose UI v tabech)
|
|
|
|
|
+ // ──────────────────────────────────────────────────────────────
|
|
|
|
|
+ private val clientScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
|
|
|
+ private val connections = mutableStateListOf<RemoteConnectionImpl>()
|
|
|
|
|
+ override val remoteConnections: State<List<RemoteConnection>> get() = connections
|
|
|
|
|
+
|
|
|
|
|
+ override suspend fun createConnection(
|
|
|
|
|
+ id: String,
|
|
|
|
|
+ host: String,
|
|
|
|
|
+ port: Int = 8080,
|
|
|
|
|
+ path: String = "/compose",
|
|
|
|
|
+ enableAudio: Boolean = true
|
|
|
|
|
+ ): RemoteConnection {
|
|
|
|
|
+ return withContext(Dispatchers.IO) {
|
|
|
|
|
+ val client = RemoteComposeClient()
|
|
|
|
|
+ val handle = RemoteClientHandleImpl(client, enableAudio, host, port, clientScope)
|
|
|
|
|
+ val conn = RemoteConnectionImpl(
|
|
|
|
|
+ id = id,
|
|
|
|
|
+ host = host,
|
|
|
|
|
+ port = port,
|
|
|
|
|
+ path = path,
|
|
|
|
|
+ enableAudio = enableAudio,
|
|
|
|
|
+ clientHandle = handle,
|
|
|
|
|
+ onClose = { connections.remove(it) }
|
|
|
|
|
+ )
|
|
|
|
|
+ connections.add(conn)
|
|
|
|
|
+ clientScope.launch { conn.connect() }
|
|
|
|
|
+ conn
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- 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")
|
|
|
|
|
|
|
+ override suspend fun closeConnection(connection: RemoteConnection) = withContext(Dispatchers.IO) {
|
|
|
|
|
+ (connection as? RemoteConnectionImpl)?.close()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override suspend fun closeAllConnections() = withContext(Dispatchers.IO) {
|
|
|
|
|
+ connections.forEach { it.close() }
|
|
|
|
|
+ connections.clear()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
fun dispose() {
|
|
fun dispose() {
|
|
|
running.value = false
|
|
running.value = false
|
|
|
- synchronized(clients) {
|
|
|
|
|
- clients.forEach { it.close() }
|
|
|
|
|
- clients.clear()
|
|
|
|
|
- }
|
|
|
|
|
- runCatching { serverSocket?.close() }
|
|
|
|
|
|
|
+ clientScope.cancel()
|
|
|
|
|
+ server?.stop()
|
|
|
|
|
+ server = null
|
|
|
|
|
+ connections.forEach { it.close() }
|
|
|
|
|
+ connections.clear()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private fun localAddresses(): List<String> = runCatching {
|
|
private fun localAddresses(): List<String> = runCatching {
|
|
@@ -106,7 +129,260 @@ class RemoteDesktopManager(
|
|
|
}.getOrDefault(emptyList())
|
|
}.getOrDefault(emptyList())
|
|
|
|
|
|
|
|
companion object {
|
|
companion object {
|
|
|
- // 5900 = VNC display :0, the port every viewer defaults to
|
|
|
|
|
- private const val DEFAULT_PORT = 5900
|
|
|
|
|
|
|
+ private const val DEFAULT_PORT = 8080
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** Desktop implementace opaque handle s audio podporou. */
|
|
|
|
|
+actual class RemoteClientHandle internal constructor(
|
|
|
|
|
+ private val client: RemoteComposeClient,
|
|
|
|
|
+ private val enableAudio: Boolean,
|
|
|
|
|
+ private val host: String,
|
|
|
|
|
+ private val port: Int,
|
|
|
|
|
+ private val clientScope: CoroutineScope
|
|
|
|
|
+) {
|
|
|
|
|
+ actual val connectionState: State<RemoteClientConnectionState> = client.state.map { mapClientState(it) }
|
|
|
|
|
+ actual val lastError: State<String?> = client.lastError
|
|
|
|
|
+
|
|
|
|
|
+ // ──────────────────────────────────────────────────────────────
|
|
|
|
|
+ // AUDIO IMPLEMENTATION
|
|
|
|
|
+ // ──────────────────────────────────────────────────────────────
|
|
|
|
|
+ private val _volume = mutableStateOf(1.0f)
|
|
|
|
|
+ private val _isMuted = mutableStateOf(false)
|
|
|
|
|
+ private val _audioState = mutableStateOf(AudioStreamState.Disconnected)
|
|
|
|
|
+ private var audioPlayer: AudioPlayer? = null
|
|
|
|
|
+ private var audioJob: Job? = null
|
|
|
|
|
+
|
|
|
|
|
+ actual val isAudioAvailable: Boolean = enableAudio
|
|
|
|
|
+ actual val volume: State<Float> = _volume
|
|
|
|
|
+ actual val isMuted: State<Boolean> = _isMuted
|
|
|
|
|
+ actual val audioState: State<AudioStreamState> = _audioState
|
|
|
|
|
+
|
|
|
|
|
+ actual fun setVolume(volume: Float) {
|
|
|
|
|
+ _volume.value = volume.coerceIn(0.0f, 1.0f)
|
|
|
|
|
+ audioPlayer?.setVolume(_volume.value * if (_isMuted.value) 0.0f else 1.0f)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ actual fun setMuted(muted: Boolean) {
|
|
|
|
|
+ _isMuted.value = muted
|
|
|
|
|
+ audioPlayer?.setVolume(_volume.value * if (muted) 0.0f else 1.0f)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ actual suspend fun connect(uri: String) {
|
|
|
|
|
+ client.connect(uri)
|
|
|
|
|
+ if (enableAudio) {
|
|
|
|
|
+ startAudioStream()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private fun startAudioStream() {
|
|
|
|
|
+ _audioState.value = AudioStreamState.Connecting
|
|
|
|
|
+ audioJob = clientScope.launch {
|
|
|
|
|
+ try {
|
|
|
|
|
+ val audioUri = "ws://$host:$port/audio"
|
|
|
|
|
+ Log.i("RemoteDesktopManager: Connecting to audio stream: $audioUri")
|
|
|
|
|
+
|
|
|
|
|
+ // WebSocket audio client (simplified - uses Java WebSocket API)
|
|
|
|
|
+ val webSocket = connectAudioWebSocket(audioUri)
|
|
|
|
|
+ if (webSocket != null) {
|
|
|
|
|
+ val format = AudioFormat(44100.0f, 16, 2, true, false)
|
|
|
|
|
+ val info = DataLine.Info(SourceDataLine::class.java, format)
|
|
|
|
|
+ val line = AudioSystem.getLine(info) as SourceDataLine
|
|
|
|
|
+ line.open(format)
|
|
|
|
|
+ line.start()
|
|
|
|
|
+
|
|
|
|
|
+ audioPlayer = AudioPlayer(line)
|
|
|
|
|
+ _audioState.value = AudioStreamState.Streaming
|
|
|
|
|
+
|
|
|
|
|
+ // Receive loop
|
|
|
|
|
+ while (_audioState.value == AudioStreamState.Streaming) {
|
|
|
|
|
+ val data = webSocket.receiveBinary()
|
|
|
|
|
+ if (data != null) {
|
|
|
|
|
+ audioPlayer?.write(data)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ break
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e: Exception) {
|
|
|
|
|
+ Log.e("RemoteDesktopManager: Audio stream error: ${e.message}")
|
|
|
|
|
+ _audioState.value = AudioStreamState.Error
|
|
|
|
|
+ lastError.value = "Audio: ${e.message}"
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ stopAudioStream()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private fun stopAudioStream() {
|
|
|
|
|
+ _audioState.value = AudioStreamState.Closed
|
|
|
|
|
+ audioJob?.cancel()
|
|
|
|
|
+ audioPlayer?.close()
|
|
|
|
|
+ audioPlayer = null
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private fun connectAudioWebSocket(uri: String): AudioWebSocket? {
|
|
|
|
|
+ // Simplified WebSocket client for audio
|
|
|
|
|
+ // In production, use a proper WebSocket library like OkHttp or Java 11+ HttpClient
|
|
|
|
|
+ try {
|
|
|
|
|
+ val httpClient = java.net.http.HttpClient.newBuilder().build()
|
|
|
|
|
+ val webSocket = httpClient.newWebSocketBuilder()
|
|
|
|
|
+ .buildAsync(URI(uri), object : java.net.http.WebSocket.Listener() {
|
|
|
|
|
+ private val buffer = java.util.ArrayDeque<ByteArray>()
|
|
|
|
|
+
|
|
|
|
|
+ override fun onBinary(webSocket: java.net.http.WebSocket, data: java.nio.ByteBuffer, last: Boolean) {
|
|
|
|
|
+ val bytes = ByteArray(data.remaining())
|
|
|
|
|
+ data.get(bytes)
|
|
|
|
|
+ buffer.add(bytes)
|
|
|
|
|
+ webSocket.request(1)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override fun onError(webSocket: java.net.http.WebSocket, error: Throwable) {
|
|
|
|
|
+ Log.e("Audio WebSocket error: ${error.message}")
|
|
|
|
|
+ }
|
|
|
|
|
+ }).join()
|
|
|
|
|
+
|
|
|
|
|
+ return object : AudioWebSocket {
|
|
|
|
|
+ override fun receiveBinary(): ByteArray? = buffer.poll()
|
|
|
|
|
+ override fun close() { webSocket.sendClose(java.net.http.WebSocket.NORMAL_CLOSURE, "").join() }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e: Exception) {
|
|
|
|
|
+ Log.e("Failed to connect audio WebSocket: ${e.message}")
|
|
|
|
|
+ return null
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ actual fun disconnect() {
|
|
|
|
|
+ client.disconnect()
|
|
|
|
|
+ stopAudioStream()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Composable
|
|
|
|
|
+ actual fun RemoteComposeView(
|
|
|
|
|
+ modifier: androidx.compose.ui.Modifier = androidx.compose.ui.Modifier,
|
|
|
|
|
+ placeholder: @Composable () -> Unit = { },
|
|
|
|
|
+ errorContent: @Composable (Throwable) -> Unit = { }
|
|
|
|
|
+ ) {
|
|
|
|
|
+ LibraryRemoteComposeView(
|
|
|
|
|
+ client = client,
|
|
|
|
|
+ modifier = modifier,
|
|
|
|
|
+ placeholder = placeholder,
|
|
|
|
|
+ errorContent = errorContent
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private fun mapClientState(state: RemoteComposeClient.ConnectionState): RemoteClientConnectionState = when (state) {
|
|
|
|
|
+ RemoteComposeClient.ConnectionState.Disconnected -> RemoteClientConnectionState.Disconnected
|
|
|
|
|
+ RemoteComposeClient.ConnectionState.Connecting -> RemoteClientConnectionState.Connecting
|
|
|
|
|
+ RemoteComposeClient.ConnectionState.Connected -> RemoteClientConnectionState.Connected
|
|
|
|
|
+ RemoteComposeClient.ConnectionState.Error -> RemoteClientConnectionState.Error
|
|
|
|
|
+ RemoteComposeClient.ConnectionState.Closed -> RemoteClientConnectionState.Closed
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private interface AudioWebSocket {
|
|
|
|
|
+ fun receiveBinary(): ByteArray?
|
|
|
|
|
+ fun close()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private class AudioPlayer(private val line: SourceDataLine) {
|
|
|
|
|
+ private var currentVolume = 1.0f
|
|
|
|
|
+
|
|
|
|
|
+ fun write(data: ByteArray) {
|
|
|
|
|
+ if (currentVolume != 1.0f) {
|
|
|
|
|
+ // Apply volume to PCM data (16-bit stereo)
|
|
|
|
|
+ val adjusted = ByteArray(data.size)
|
|
|
|
|
+ for (i in 0 until data.size step 2) {
|
|
|
|
|
+ val sample = (data[i + 1].toInt() shl 8) or (data[i].toInt() and 0xFF)
|
|
|
|
|
+ val adjustedSample = (sample * currentVolume).toInt().coerceIn(-32768, 32767)
|
|
|
|
|
+ adjusted[i] = (adjustedSample and 0xFF).toByte()
|
|
|
|
|
+ adjusted[i + 1] = (adjustedSample ushr 8).toByte()
|
|
|
|
|
+ }
|
|
|
|
|
+ line.write(adjusted, 0, adjusted.size)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ line.write(data, 0, data.size)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fun setVolume(volume: Float) {
|
|
|
|
|
+ currentVolume = volume.coerceIn(0.0f, 1.0f)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fun close() {
|
|
|
|
|
+ line.drain()
|
|
|
|
|
+ line.stop()
|
|
|
|
|
+ line.close()
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** Interní implementace klientského připojení. */
|
|
|
|
|
+private class RemoteConnectionImpl(
|
|
|
|
|
+ override val id: String,
|
|
|
|
|
+ override val host: String,
|
|
|
|
|
+ override val port: Int,
|
|
|
|
|
+ override val path: String,
|
|
|
|
|
+ override val enableAudio: Boolean,
|
|
|
|
|
+ override val clientHandle: RemoteClientHandle,
|
|
|
|
|
+ private val onClose: (RemoteConnectionImpl) -> Unit
|
|
|
|
|
+) : RemoteConnection.Connected(
|
|
|
|
|
+ id = id,
|
|
|
|
|
+ host = host,
|
|
|
|
|
+ port = port,
|
|
|
|
|
+ path = path,
|
|
|
|
|
+ enableAudio = enableAudio,
|
|
|
|
|
+ clientHandle = clientHandle,
|
|
|
|
|
+ state = mutableStateOf(ConnectionState.Connecting),
|
|
|
|
|
+ lastError = mutableStateOf(null)
|
|
|
|
|
+) {
|
|
|
|
|
+
|
|
|
|
|
+ private var connectJob: Job? = null
|
|
|
|
|
+
|
|
|
|
|
+ init {
|
|
|
|
|
+ state.value = ConnectionState.Connecting
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ suspend fun connect() {
|
|
|
|
|
+ val uri = "ws://$host:$port$path"
|
|
|
|
|
+ Log.i("RemoteDesktopManager: Connecting to Compose Remote server: $uri")
|
|
|
|
|
+ connectJob = clientScope.launch {
|
|
|
|
|
+ try {
|
|
|
|
|
+ clientHandle.connect(uri)
|
|
|
|
|
+ while (clientHandle.connectionState.value == RemoteClientConnectionState.Connecting) {
|
|
|
|
|
+ kotlinx.coroutines.delay(100)
|
|
|
|
|
+ }
|
|
|
|
|
+ when (clientHandle.connectionState.value) {
|
|
|
|
|
+ RemoteClientConnectionState.Connected -> {
|
|
|
|
|
+ state.value = ConnectionState.Connected
|
|
|
|
|
+ lastError.value = null
|
|
|
|
|
+ Log.i("RemoteDesktopManager: Connected to $uri")
|
|
|
|
|
+ }
|
|
|
|
|
+ RemoteClientConnectionState.Error -> {
|
|
|
|
|
+ state.value = ConnectionState.Error
|
|
|
|
|
+ lastError.value = clientHandle.lastError.value ?: "Unknown connection error"
|
|
|
|
|
+ Log.e("RemoteDesktopManager: Connection error: ${lastError.value}")
|
|
|
|
|
+ }
|
|
|
|
|
+ RemoteClientConnectionState.Closed -> {
|
|
|
|
|
+ state.value = ConnectionState.Closed
|
|
|
|
|
+ }
|
|
|
|
|
+ else -> {}
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e: Exception) {
|
|
|
|
|
+ state.value = ConnectionState.Error
|
|
|
|
|
+ lastError.value = e.message
|
|
|
|
|
+ Log.e("RemoteDesktopManager: Connection exception: ${e.message}")
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override fun close() {
|
|
|
|
|
+ state.value = ConnectionState.Closed
|
|
|
|
|
+ connectJob?.cancel()
|
|
|
|
|
+ clientHandle.disconnect()
|
|
|
|
|
+ onClose(this)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override fun setViewOnly(viewOnly: Boolean) {
|
|
|
|
|
+ // TODO: Implement view-only mode if needed by the remote compose library
|
|
|
|
|
+ // This would typically involve setting a flag on the client or server
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|