From ab74acd996a66caa259f2b25c100ea9ef7714b2d Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Mon, 20 Apr 2026 15:43:32 +0800 Subject: [PATCH 01/38] :sparkles: add MouseIpcProtocol types for crosspaste-mouse plugin IPC --- .../com/crosspaste/mouse/MouseIpcProtocol.kt | 178 ++++++++++++++++++ .../crosspaste/mouse/MouseIpcProtocolTest.kt | 75 ++++++++ 2 files changed, 253 insertions(+) create mode 100644 app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseIpcProtocol.kt create mode 100644 app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseIpcProtocolTest.kt diff --git a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseIpcProtocol.kt b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseIpcProtocol.kt new file mode 100644 index 000000000..1b7659b89 --- /dev/null +++ b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseIpcProtocol.kt @@ -0,0 +1,178 @@ +package com.crosspaste.mouse + +import kotlinx.serialization.EncodeDefault +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonClassDiscriminator + +object MouseIpcProtocol { + val json: Json = + Json { + ignoreUnknownKeys = true + encodeDefaults = true + classDiscriminator = "__unused__" // overridden per hierarchy via @JsonClassDiscriminator + } +} + +@Serializable +data class Position( + val x: Int, + val y: Int, +) + +@Serializable +data class ScreenInfo( + val id: Int, + val width: Int, + val height: Int, + val x: Int, + val y: Int, + @SerialName("scale_factor") val scaleFactor: Double, + @SerialName("is_primary") val isPrimary: Boolean, +) + +@Serializable +data class IpcPeer( + val name: String, + val address: String, + val position: Position, + @SerialName("device_id") val deviceId: String? = null, +) + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +@JsonClassDiscriminator("cmd") +sealed class IpcCommand { + + @Serializable + @SerialName("start") + data class Start( + val port: Int, + val peers: List, + ) : IpcCommand() + + @Serializable + @SerialName("stop") + object Stop : IpcCommand() + + @Serializable + @SerialName("update_layout") + data class UpdateLayout( + val peers: List, + ) : IpcCommand() + + @Serializable + @SerialName("get_status") + object GetStatus : IpcCommand() + + @Serializable + @SerialName("enumerate_local_screens") + object EnumerateLocalScreens : IpcCommand() + + @Serializable + @SerialName("get_capabilities") + object GetCapabilities : IpcCommand() +} + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +@JsonClassDiscriminator("event") +sealed class IpcEvent { + + @Serializable + @SerialName("initialized") + data class Initialized( + val screens: List, + @SerialName("protocol_version") @EncodeDefault val protocolVersion: Int, + ) : IpcEvent() + + @Serializable + @SerialName("ready") + data class Ready( + val screens: List, + val port: Int, + ) : IpcEvent() + + @Serializable + @SerialName("session_started") + data class SessionStarted( + val port: Int, + @SerialName("local_device_id") val localDeviceId: String, + ) : IpcEvent() + + @Serializable + @SerialName("peer_connected") + data class PeerConnected( + val name: String, + @SerialName("device_id") val deviceId: String, + ) : IpcEvent() + + @Serializable + @SerialName("peer_screens_learned") + data class PeerScreensLearned( + @SerialName("device_id") val deviceId: String, + val screens: List, + ) : IpcEvent() + + @Serializable + @SerialName("peer_disconnected") + data class PeerDisconnected( + val name: String, + val reason: String, + ) : IpcEvent() + + @Serializable + @SerialName("mode_changed") + data class ModeChanged( + val mode: String, + val target: String? = null, + ) : IpcEvent() + + @Serializable + @SerialName("status") + data class Status( + val running: Boolean, + val mode: String, + @SerialName("connected_peers") val connectedPeers: List, + ) : IpcEvent() + + @Serializable + @SerialName("local_screens") + data class LocalScreens( + val screens: List, + ) : IpcEvent() + + @Serializable + @SerialName("capabilities") + data class Capabilities( + @SerialName("protocol_version") val protocolVersion: Int, + val features: List, + ) : IpcEvent() + + @Serializable + @SerialName("warning") + data class Warning( + val code: String, + val message: String, + ) : IpcEvent() + + @Serializable + @SerialName("license_status") + data class LicenseStatus( + val status: String, + val message: String, + @SerialName("trial_remaining_secs") val trialRemainingSecs: Long? = null, + ) : IpcEvent() + + @Serializable + @SerialName("error") + data class Error( + val message: String, + ) : IpcEvent() + + @Serializable + @SerialName("stopped") + object Stopped : IpcEvent() +} diff --git a/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseIpcProtocolTest.kt b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseIpcProtocolTest.kt new file mode 100644 index 000000000..1d22d1ae6 --- /dev/null +++ b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseIpcProtocolTest.kt @@ -0,0 +1,75 @@ +package com.crosspaste.mouse + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class MouseIpcProtocolTest { + private val json = MouseIpcProtocol.json + + @Test + fun `encode Start command with v2 peer`() { + val cmd: IpcCommand = + IpcCommand.Start( + port = 4243, + peers = + listOf( + IpcPeer( + name = "Desktop", + address = "192.168.1.10:4243", + position = Position(1920, 0), + deviceId = "uuid-a", + // fingerprint INTENTIONALLY omitted — product decision + ), + ), + ) + val out = json.encodeToString(IpcCommand.serializer(), cmd) + assertTrue(out.contains(""""cmd":"start"""")) + assertTrue(out.contains(""""port":4243""")) + assertTrue(out.contains(""""device_id":"uuid-a"""")) + assertTrue(!out.contains("fingerprint")) + } + + @Test + fun `decode Ready event from v1 daemon`() { + val line = """{"event":"ready","screens":[],"port":4243}""" + val ev = json.decodeFromString(IpcEvent.serializer(), line) + assertTrue(ev is IpcEvent.Ready) + assertEquals(4243, ev.port) + } + + @Test + fun `decode Initialized event from v2 daemon`() { + val line = """{"event":"initialized","screens":[],"protocol_version":2}""" + val ev = json.decodeFromString(IpcEvent.serializer(), line) + assertTrue(ev is IpcEvent.Initialized) + assertEquals(2, ev.protocolVersion) + } + + @Test + fun `decode PeerScreensLearned with screens`() { + val line = + """{"event":"peer_screens_learned","device_id":"d1","screens":[""" + + """{"id":1,"width":2560,"height":1440,"x":0,"y":0,"scale_factor":2.0,"is_primary":true}]}""" + val ev = json.decodeFromString(IpcEvent.serializer(), line) + assertTrue(ev is IpcEvent.PeerScreensLearned) + assertEquals("d1", ev.deviceId) + assertEquals(1, ev.screens.size) + assertEquals(2560, ev.screens[0].width) + } + + @Test + fun `decode Warning event preserves code`() { + val line = """{"event":"warning","code":"macos_accessibility","message":"grant access"}""" + val ev = json.decodeFromString(IpcEvent.serializer(), line) + assertTrue(ev is IpcEvent.Warning) + assertEquals("macos_accessibility", ev.code) + } + + @Test + fun `unknown event variant fails loudly`() { + val line = """{"event":"made_up_event"}""" + runCatching { json.decodeFromString(IpcEvent.serializer(), line) } + .onSuccess { error("should have failed, got $it") } + } +} From 53ee6226dd0510aca3fb1725ceaa756234770a31 Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Mon, 20 Apr 2026 15:48:24 +0800 Subject: [PATCH 02/38] :bug: drop misleading @EncodeDefault on IpcEvent.Initialized.protocolVersion --- .../kotlin/com/crosspaste/mouse/MouseIpcProtocol.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseIpcProtocol.kt b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseIpcProtocol.kt index 1b7659b89..103c72cfb 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseIpcProtocol.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseIpcProtocol.kt @@ -1,6 +1,5 @@ package com.crosspaste.mouse -import kotlinx.serialization.EncodeDefault import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -85,7 +84,7 @@ sealed class IpcEvent { @SerialName("initialized") data class Initialized( val screens: List, - @SerialName("protocol_version") @EncodeDefault val protocolVersion: Int, + @SerialName("protocol_version") val protocolVersion: Int, ) : IpcEvent() @Serializable From 71f49f1672459cde076c58b50ceb93e7ee98b921 Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Mon, 20 Apr 2026 15:50:52 +0800 Subject: [PATCH 03/38] :sparkles: add MouseDaemonBinary resolver with env/sysprop/bundled fallback --- .../com/crosspaste/mouse/MouseDaemonBinary.kt | 34 ++++++++++++ .../crosspaste/mouse/MouseDaemonBinaryTest.kt | 53 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonBinary.kt create mode 100644 app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonBinaryTest.kt diff --git a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonBinary.kt b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonBinary.kt new file mode 100644 index 000000000..cf4486ff6 --- /dev/null +++ b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonBinary.kt @@ -0,0 +1,34 @@ +package com.crosspaste.mouse + +import java.io.File + +object MouseDaemonBinary { + private const val SYSTEM_PROPERTY = "crosspaste.mouse.binary" + private const val ENV_VAR = "CROSSPASTE_MOUSE_BIN" + + /** + * Resolution order: + * 1. -Dcrosspaste.mouse.binary= + * 2. $CROSSPASTE_MOUSE_BIN + * 3. Any path in `candidatePaths` (e.g. bundled in resources/bin/...) + * Returns null if nothing points at an existing regular file. + */ + fun resolve( + envLookup: (String) -> String? = System::getenv, + candidatePaths: List = defaultCandidatePaths(), + ): File? { + System.getProperty(SYSTEM_PROPERTY)?.let { File(it).takeIf { f -> f.isFile } }?.let { return it } + envLookup(ENV_VAR)?.let { File(it).takeIf { f -> f.isFile } }?.let { return it } + return candidatePaths + .asSequence() + .map(::File) + .firstOrNull { it.isFile } + } + + private fun defaultCandidatePaths(): List { + // Production bundling is a follow-up plan; for now the only + // candidate is a dev-mode symlink next to the app jar. + val home = System.getProperty("user.home") ?: return emptyList() + return listOf("$home/crosspaste-mouse/target/release/crosspaste-mouse") + } +} diff --git a/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonBinaryTest.kt b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonBinaryTest.kt new file mode 100644 index 000000000..5160a28f0 --- /dev/null +++ b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonBinaryTest.kt @@ -0,0 +1,53 @@ +package com.crosspaste.mouse + +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class MouseDaemonBinaryTest { + private val propKey = "crosspaste.mouse.binary" + private val savedProp = System.getProperty(propKey) + + @AfterTest + fun restore() { + if (savedProp != null) System.setProperty(propKey, savedProp) else System.clearProperty(propKey) + } + + @Test + fun `system property wins when set and file exists`() { + val file = Files.createTempFile("fake-daemon", "").toFile().apply { deleteOnExit() } + System.setProperty(propKey, file.absolutePath) + assertEquals(file.absolutePath, MouseDaemonBinary.resolve(envLookup = { null })?.absolutePath) + } + + @Test + fun `env var used when system property absent`() { + System.clearProperty(propKey) + val file = Files.createTempFile("fake-daemon", "").toFile().apply { deleteOnExit() } + val path = + MouseDaemonBinary.resolve(envLookup = { + if (it == + "CROSSPASTE_MOUSE_BIN" + ) { + file.absolutePath + } else { + null + } + }) + assertEquals(file.absolutePath, path?.absolutePath) + } + + @Test + fun `returns null when nothing resolves`() { + System.clearProperty(propKey) + assertNull(MouseDaemonBinary.resolve(envLookup = { null }, candidatePaths = emptyList())) + } + + @Test + fun `system property pointing at missing file is ignored`() { + System.setProperty(propKey, "/definitely/does/not/exist/mouse") + assertNull(MouseDaemonBinary.resolve(envLookup = { null }, candidatePaths = emptyList())) + } +} From a0f994263827a209fc7fba30f30b722266e89884 Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Mon, 20 Apr 2026 15:57:53 +0800 Subject: [PATCH 04/38] :sparkles: add MouseDaemonProcess wrapper with stdin/stdout JSON Lines --- .../crosspaste/mouse/MouseDaemonProcess.kt | 101 ++++++++++++++++++ .../mouse/MouseDaemonProcessTest.kt | 99 +++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt create mode 100644 app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonProcessTest.kt diff --git a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt new file mode 100644 index 000000000..d8f61417a --- /dev/null +++ b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt @@ -0,0 +1,101 @@ +package com.crosspaste.mouse + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.BufferedReader +import java.io.BufferedWriter +import java.io.File +import java.io.InputStream +import java.io.InputStreamReader +import java.io.OutputStream +import java.io.OutputStreamWriter +import java.nio.charset.StandardCharsets + +class MouseDaemonProcess internal constructor( + stdout: InputStream, + stdin: OutputStream, + private val onClose: () -> Unit, +) : AutoCloseable { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val writer: BufferedWriter = + BufferedWriter(OutputStreamWriter(stdin, StandardCharsets.UTF_8)) + private val writeLock = Mutex() + + private val _events = + MutableSharedFlow( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val events: SharedFlow = _events.asSharedFlow() + + private val readerJob: Job = + scope.launch { + BufferedReader(InputStreamReader(stdout, StandardCharsets.UTF_8)).use { reader -> + while (true) { + val line = reader.readLine() ?: break + if (line.isBlank()) continue + val event = + runCatching { + MouseIpcProtocol.json.decodeFromString(IpcEvent.serializer(), line) + }.getOrElse { + IpcEvent.Error("malformed daemon output: ${it.message}") + } + _events.emit(event) + if (event is IpcEvent.Stopped) { + // keep reading — daemon may restart session; only exit on EOF. + } + } + } + } + + suspend fun send(command: IpcCommand) { + val line = MouseIpcProtocol.json.encodeToString(IpcCommand.serializer(), command) + writeLock.withLock { + withContext(Dispatchers.IO) { + writer.write(line) + writer.write("\n") + writer.flush() + } + } + } + + override fun close() { + scope.cancel() + runCatching { writer.close() } + onClose() + } + + companion object { + /** + * Spawns the daemon binary in plugin mode. Throws [IllegalStateException] + * if the binary cannot be located. + */ + fun spawn(binary: File): MouseDaemonProcess { + val process = + ProcessBuilder(binary.absolutePath, "plugin") + .redirectErrorStream(false) // keep stderr separate (daemon tracing logs go there) + .start() + return MouseDaemonProcess( + stdout = process.inputStream, + stdin = process.outputStream, + onClose = { + runCatching { process.destroy() } + runCatching { process.waitFor() } + }, + ) + } + } +} diff --git a/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonProcessTest.kt b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonProcessTest.kt new file mode 100644 index 000000000..be6464447 --- /dev/null +++ b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonProcessTest.kt @@ -0,0 +1,99 @@ +package com.crosspaste.mouse + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.takeWhile +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import java.io.PipedInputStream +import java.io.PipedOutputStream +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +// Uses `runBlocking` (not `runTest`) because the reader job runs on real +// `Dispatchers.IO` threads against real `PipedInputStream`s — `runTest`'s +// virtual-time dispatcher would cause `withTimeout` to fire before any +// real I/O progress happens. See Task 3 Gotcha #1 in the plan. +class MouseDaemonProcessTest { + + @Test + fun `reads one event per stdout line and parses it`() = + runBlocking { + val daemonStdoutSink = PipedOutputStream() + val daemonStdoutSource = PipedInputStream(daemonStdoutSink) + val daemonStdinSink = PipedOutputStream() + // Host never reads stdin (the daemon does); we only need to see what was written. + + val process = + MouseDaemonProcess( + stdout = daemonStdoutSource, + stdin = daemonStdinSink, + onClose = {}, + ) + + // Write one line as if daemon emitted it + daemonStdoutSink.write("""{"event":"ready","screens":[],"port":4243}""".toByteArray()) + daemonStdoutSink.write('\n'.code) + daemonStdoutSink.flush() + + val ev = withTimeout(2000) { process.events.first() } + assertTrue(ev is IpcEvent.Ready) + assertEquals(4243, ev.port) + + process.close() + } + + @Test + fun `send writes a line-terminated JSON command to stdin`() = + runBlocking { + val daemonStdinSink = java.io.ByteArrayOutputStream() + val process = + MouseDaemonProcess( + stdout = java.io.ByteArrayInputStream(ByteArray(0)), + stdin = daemonStdinSink, + onClose = {}, + ) + + process.send(IpcCommand.GetStatus) + val written = daemonStdinSink.toString(Charsets.UTF_8) + assertTrue(written.endsWith("\n"), "expected newline terminator, got: $written") + assertTrue(written.contains(""""cmd":"get_status"""")) + + process.close() + } + + @Test + fun `malformed line emits Error event but keeps stream alive`() = + runBlocking { + val sink = PipedOutputStream() + val source = PipedInputStream(sink) + val process = + MouseDaemonProcess( + stdout = source, + stdin = java.io.ByteArrayOutputStream(), + onClose = {}, + ) + sink.write("not json\n".toByteArray()) + sink.write("""{"event":"stopped"}""".toByteArray()) + sink.write('\n'.code) + sink.flush() + + // NOTE: deviation from plan — the plan's `return@collect` does not + // terminate a SharedFlow collection (it only returns from the lambda + // iteration). Use `takeWhile` (inclusive of Stopped) + `toList` instead. + val collected = + withTimeout(2000) { + val events = mutableListOf() + process.events + .takeWhile { ev -> + events.add(ev) + ev !is IpcEvent.Stopped + }.toList() + events + } + assertTrue(collected.any { it is IpcEvent.Error }) + assertTrue(collected.any { it is IpcEvent.Stopped }) + process.close() + } +} From 508cf5881299c4f519d8783fcf73cbebc68a3e68 Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Mon, 20 Apr 2026 16:47:45 +0800 Subject: [PATCH 05/38] :bug: tighten MouseDaemonProcess shutdown, write atomicity, and docs --- .../crosspaste/mouse/MouseDaemonProcess.kt | 43 +++++++++++++++---- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt index d8f61417a..53f731f51 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt @@ -3,13 +3,15 @@ package com.crosspaste.mouse import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -22,6 +24,12 @@ import java.io.OutputStream import java.io.OutputStreamWriter import java.nio.charset.StandardCharsets +/** + * Wraps a running daemon subprocess for JSON Lines IPC. + * + * The constructor takes ownership of [stdout] and [stdin]: [close] will + * close both. Tests injecting pipes should not reuse them after [close]. + */ class MouseDaemonProcess internal constructor( stdout: InputStream, stdin: OutputStream, @@ -36,14 +44,25 @@ class MouseDaemonProcess internal constructor( private val _events = MutableSharedFlow( replay = 0, - extraBufferCapacity = 64, + extraBufferCapacity = 256, onBufferOverflow = BufferOverflow.DROP_OLDEST, ) + + /** + * Events from the daemon (one per stdout line). + * + * Buffer is 256 events with [BufferOverflow.DROP_OLDEST]. Bursts beyond that + * size — e.g. a rapid peer-churn storm — silently drop the oldest events. + * Callers that need request/response correlation (e.g. [MouseDaemonClient]) + * must not assume every event is delivered; they should treat `getStatus` / + * `getCapabilities` responses as best-effort and reissue on timeout. + */ val events: SharedFlow = _events.asSharedFlow() private val readerJob: Job = scope.launch { BufferedReader(InputStreamReader(stdout, StandardCharsets.UTF_8)).use { reader -> + // EOF is the only way out — daemon restarts the session in-place via `stopped` events. while (true) { val line = reader.readLine() ?: break if (line.isBlank()) continue @@ -54,9 +73,6 @@ class MouseDaemonProcess internal constructor( IpcEvent.Error("malformed daemon output: ${it.message}") } _events.emit(event) - if (event is IpcEvent.Stopped) { - // keep reading — daemon may restart session; only exit on EOF. - } } } } @@ -64,7 +80,7 @@ class MouseDaemonProcess internal constructor( suspend fun send(command: IpcCommand) { val line = MouseIpcProtocol.json.encodeToString(IpcCommand.serializer(), command) writeLock.withLock { - withContext(Dispatchers.IO) { + withContext(NonCancellable + Dispatchers.IO) { writer.write(line) writer.write("\n") writer.flush() @@ -73,15 +89,24 @@ class MouseDaemonProcess internal constructor( } override fun close() { - scope.cancel() + // Cancel the scope and wait for the reader to fully unwind, so the + // AutoCloseable contract is honored: no worker thread outlives close(). + runCatching { + runBlocking { + scope.coroutineContext[Job]?.cancelAndJoin() + } + } runCatching { writer.close() } onClose() } companion object { /** - * Spawns the daemon binary in plugin mode. Throws [IllegalStateException] - * if the binary cannot be located. + * Spawn the daemon binary in plugin mode. Caller must verify the binary + * exists first (e.g. via [MouseDaemonBinary.resolve]). + * + * @throws IOException if the binary cannot be executed (not found, not + * executable, etc.) — propagated from [ProcessBuilder.start]. */ fun spawn(binary: File): MouseDaemonProcess { val process = From 38e3e4c0f6bf27f4d7a3176c8aa2caaf4ff195ab Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Mon, 20 Apr 2026 17:02:11 +0800 Subject: [PATCH 06/38] :bug: tighten MouseDaemonProcess shutdown, write atomicity, and docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Raise events SharedFlow buffer to 256 and document DROP_OLDEST policy so callers cannot assume every event is delivered. - close() now interrupts the blocking readLine() via runInterruptible, closes stdout, and joins the reader job — so AutoCloseable returns only after the reader coroutine is done (PipedInputStream.close() alone does not wake a reader parked in read()). - send() wraps the write+flush in NonCancellable so a mid-send cancel cannot leave a half-written JSON line on the daemon's stdin. - Drop the dead 'if Stopped { }' branch; keep the EOF-only contract as a loop comment. - Fix spawn() KDoc to describe what it actually throws (IOException from ProcessBuilder.start, not IllegalStateException). - Document constructor stream ownership. --- .../crosspaste/mouse/MouseDaemonProcess.kt | 41 ++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt index 53f731f51..f9140da5f 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -36,6 +37,7 @@ class MouseDaemonProcess internal constructor( private val onClose: () -> Unit, ) : AutoCloseable { + private val stdoutStream: InputStream = stdout private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val writer: BufferedWriter = BufferedWriter(OutputStreamWriter(stdin, StandardCharsets.UTF_8)) @@ -61,18 +63,25 @@ class MouseDaemonProcess internal constructor( private val readerJob: Job = scope.launch { - BufferedReader(InputStreamReader(stdout, StandardCharsets.UTF_8)).use { reader -> - // EOF is the only way out — daemon restarts the session in-place via `stopped` events. - while (true) { - val line = reader.readLine() ?: break - if (line.isBlank()) continue - val event = - runCatching { - MouseIpcProtocol.json.decodeFromString(IpcEvent.serializer(), line) - }.getOrElse { - IpcEvent.Error("malformed daemon output: ${it.message}") - } - _events.emit(event) + // `runInterruptible` translates coroutine cancellation into + // `Thread.interrupt()`, which is the only way to unblock a JVM + // classic-IO `readLine()` in progress — neither PipedInputStream.close() + // nor coroutine cancel alone wake a reader parked in read(). + runInterruptible(Dispatchers.IO) { + BufferedReader(InputStreamReader(stdout, StandardCharsets.UTF_8)).use { reader -> + // EOF is the only way out under normal flow; daemon restarts + // the session in-place via `stopped` events. + while (true) { + val line = reader.readLine() ?: break + if (line.isBlank()) continue + val event = + runCatching { + MouseIpcProtocol.json.decodeFromString(IpcEvent.serializer(), line) + }.getOrElse { + IpcEvent.Error("malformed daemon output: ${it.message}") + } + _events.tryEmit(event) + } } } } @@ -89,8 +98,12 @@ class MouseDaemonProcess internal constructor( } override fun close() { - // Cancel the scope and wait for the reader to fully unwind, so the - // AutoCloseable contract is honored: no worker thread outlives close(). + // Close the stdout stream first to unblock any reader thread parked in + // `readLine()` (coroutine cancellation alone can't interrupt a blocking + // JVM I/O call). Then cancel the scope and wait for the reader to fully + // unwind, so the AutoCloseable contract is honored: no worker thread + // outlives close(). + runCatching { stdoutStream.close() } runCatching { runBlocking { scope.coroutineContext[Job]?.cancelAndJoin() From 01e6f626f17bac3225f53ab7be37f0f803d6db76 Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Mon, 20 Apr 2026 17:09:31 +0800 Subject: [PATCH 07/38] :sparkles: map paired SyncRuntimeInfo rows to mouse daemon peers --- .../com/crosspaste/mouse/MousePeerMapper.kt | 29 +++++++ .../crosspaste/mouse/MousePeerMapperTest.kt | 80 +++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 app/src/desktopMain/kotlin/com/crosspaste/mouse/MousePeerMapper.kt create mode 100644 app/src/desktopTest/kotlin/com/crosspaste/mouse/MousePeerMapperTest.kt diff --git a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MousePeerMapper.kt b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MousePeerMapper.kt new file mode 100644 index 000000000..ca6b61933 --- /dev/null +++ b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MousePeerMapper.kt @@ -0,0 +1,29 @@ +package com.crosspaste.mouse + +import com.crosspaste.db.sync.SyncRuntimeInfo + +object MousePeerMapper { + + /** + * Convert paired-device rows into daemon peers. + * - Drops devices with no reachable host address (still sync-negotiating). + * - Drops devices the user has not yet placed in the arrangement canvas. + * - Uses [SyncRuntimeInfo.appInstanceId] as the peer key (daemon's `device_id`), + * matching what MouseLayoutStore uses. + * - Never sets fingerprint (product decision: desktop is the trust authority). + */ + fun map( + syncs: List, + layout: Map, + ): List = + syncs.mapNotNull { sri -> + val host = sri.connectHostAddress ?: return@mapNotNull null + val position = layout[sri.appInstanceId] ?: return@mapNotNull null + IpcPeer( + name = sri.deviceName.ifBlank { sri.appInstanceId }, + address = "$host:${sri.port}", + position = position, + deviceId = sri.appInstanceId, + ) + } +} diff --git a/app/src/desktopTest/kotlin/com/crosspaste/mouse/MousePeerMapperTest.kt b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MousePeerMapperTest.kt new file mode 100644 index 000000000..573d8ab44 --- /dev/null +++ b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MousePeerMapperTest.kt @@ -0,0 +1,80 @@ +package com.crosspaste.mouse + +import com.crosspaste.db.sync.HostInfo +import com.crosspaste.db.sync.SyncRuntimeInfo +import com.crosspaste.platform.Platform +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class MousePeerMapperTest { + private fun sri( + appInstanceId: String, + deviceId: String, + deviceName: String, + host: String?, + port: Int = 4243, + ) = SyncRuntimeInfo( + appInstanceId = appInstanceId, + appVersion = "x", + userName = "u", + deviceId = deviceId, + deviceName = deviceName, + platform = Platform(name = "Mac", arch = "arm64", bitMode = 64, version = "14"), + hostInfoList = + host?.let { + listOf(HostInfo(hostAddress = it, networkPrefixLength = 24.toShort())) + } ?: emptyList(), + port = port, + connectHostAddress = host, + ) + + @Test + fun `maps paired device with known address to IpcPeer without fingerprint`() { + val layout = mapOf("inst-1" to Position(1920, 0)) + val peers = + MousePeerMapper.map( + syncs = listOf(sri("inst-1", "dev-1", "Desktop", "192.168.1.10", 4243)), + layout = layout, + ) + assertEquals(1, peers.size) + val p = peers[0] + assertEquals("Desktop", p.name) + assertEquals("192.168.1.10:4243", p.address) + assertEquals(Position(1920, 0), p.position) + assertEquals("inst-1", p.deviceId) + // Fingerprint must stay null per product decision. + assertNull(runCatching { IpcPeer::class.java.getDeclaredField("fingerprint") }.getOrNull()) + } + + @Test + fun `drops devices without a connect host address`() { + val peers = + MousePeerMapper.map( + syncs = listOf(sri("inst-1", "dev-1", "Desktop", host = null)), + layout = mapOf("inst-1" to Position(0, 0)), + ) + assertTrue(peers.isEmpty()) + } + + @Test + fun `drops devices missing layout entry (not configured yet)`() { + val peers = + MousePeerMapper.map( + syncs = listOf(sri("inst-1", "dev-1", "Desktop", "192.168.1.10")), + layout = emptyMap(), + ) + assertTrue(peers.isEmpty()) + } + + @Test + fun `key is appInstanceId — matches what desktop uses elsewhere`() { + val peers = + MousePeerMapper.map( + syncs = listOf(sri("inst-abc", "dev-xyz", "Desktop", "192.168.1.10")), + layout = mapOf("inst-abc" to Position(0, 1080)), + ) + assertEquals("inst-abc", peers.single().deviceId) + } +} From 53c848248d98a231d80478d6677381015b03890f Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Mon, 20 Apr 2026 17:13:42 +0800 Subject: [PATCH 08/38] :sparkles: persist mouse arrangement layout in DesktopAppConfig --- .../com/crosspaste/config/DesktopAppConfig.kt | 11 ++++ .../com/crosspaste/mouse/MouseLayoutStore.kt | 50 ++++++++++++++ .../crosspaste/mouse/MouseLayoutStoreTest.kt | 65 +++++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseLayoutStore.kt create mode 100644 app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseLayoutStoreTest.kt diff --git a/app/src/desktopMain/kotlin/com/crosspaste/config/DesktopAppConfig.kt b/app/src/desktopMain/kotlin/com/crosspaste/config/DesktopAppConfig.kt index eeb3e4e70..54db9cd29 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/config/DesktopAppConfig.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/config/DesktopAppConfig.kt @@ -4,6 +4,7 @@ import com.crosspaste.config.AppConfig.Companion.toBoolean import com.crosspaste.config.AppConfig.Companion.toInt import com.crosspaste.config.AppConfig.Companion.toLong import com.crosspaste.config.AppConfig.Companion.toString +import com.crosspaste.mouse.Position import com.crosspaste.ui.extension.ProxyType import kotlinx.serialization.Serializable @@ -66,6 +67,10 @@ data class DesktopAppConfig( // Highest app version whose changelog the user has already seen. Empty until seeded on // first launch; drives the highlight badge on the changelog menu entry after an upgrade. val lastSeenChangelogVersion: String = "", + // Mouse daemon (crosspaste-mouse plugin) + val mouseEnabled: Boolean = false, + val mouseListenPort: Int = 4243, + val mouseLayout: Map = emptyMap(), ) : AppConfig { override fun copy( key: String, @@ -180,5 +185,11 @@ data class DesktopAppConfig( } else { lastSeenChangelogVersion }, + mouseEnabled = if (key == "mouseEnabled") toBoolean(value) else mouseEnabled, + mouseListenPort = if (key == "mouseListenPort") toInt(value) else mouseListenPort, + // mouseLayout is Map and is not mutated through the + // generic scalar-based copy(key, value) path. It is updated via typed + // data-class copy() by the MouseLayoutStore backing adapter (Task 8). + mouseLayout = mouseLayout, ) } diff --git a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseLayoutStore.kt b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseLayoutStore.kt new file mode 100644 index 000000000..b26305ec1 --- /dev/null +++ b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseLayoutStore.kt @@ -0,0 +1,50 @@ +package com.crosspaste.mouse + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Store for the per-device virtual-screen layout driving the mouse daemon. + * + * The store is a thin wrapper over a [Backing] adapter. The real production + * [Backing] is implemented against [com.crosspaste.config.DesktopConfigManager] + * in the Koin module; tests substitute a fake. + */ +class MouseLayoutStore( + private val backing: Backing, +) { + + /** + * Adapter interface between the store and the persistence layer. + * + * The store treats the layout as a `Map`; the adapter + * decides how to serialize it into whatever config representation the + * platform uses (plain map, JSON-encoded strings, wrapper class, etc). + */ + interface Backing { + fun snapshot(): Map + + fun set(newMap: Map) + + fun flow(): MutableStateFlow> + } + + fun all(): Map = backing.snapshot() + + fun get(deviceId: String): Position? = backing.snapshot()[deviceId] + + fun upsert( + deviceId: String, + position: Position, + ) { + val next = backing.snapshot().toMutableMap().apply { put(deviceId, position) } + backing.set(next) + } + + fun remove(deviceId: String) { + val next = backing.snapshot().toMutableMap().apply { remove(deviceId) } + backing.set(next) + } + + fun flow(): Flow> = backing.flow() +} diff --git a/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseLayoutStoreTest.kt b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseLayoutStoreTest.kt new file mode 100644 index 000000000..355c7cc61 --- /dev/null +++ b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseLayoutStoreTest.kt @@ -0,0 +1,65 @@ +package com.crosspaste.mouse + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import kotlin.test.Test +import kotlin.test.assertEquals + +class MouseLayoutStoreTest { + + @Test + fun `returns empty map when config has no entries`() { + val cfg = FakeConfig(mutableMapOf()) + val store = MouseLayoutStore(cfg) + assertEquals(emptyMap(), store.all()) + } + + @Test + fun `upsert writes a single device position`() { + val cfg = FakeConfig(mutableMapOf()) + val store = MouseLayoutStore(cfg) + store.upsert("inst-1", Position(1920, 0)) + assertEquals(Position(1920, 0), store.get("inst-1")) + } + + @Test + fun `remove deletes an entry`() { + val cfg = FakeConfig(mutableMapOf("inst-1" to Position(0, 0))) + val store = MouseLayoutStore(cfg) + store.remove("inst-1") + assertEquals(emptyMap(), store.all()) + } + + @Test + fun `flow emits after upsert`() = + runTest { + val cfg = FakeConfig(mutableMapOf()) + val store = MouseLayoutStore(cfg) + val updates = mutableListOf>() + val job = launch { store.flow().collect { updates.add(it) } } + yield() + store.upsert("inst-1", Position(1920, 0)) + yield() + job.cancel() + assertEquals(Position(1920, 0), updates.last()["inst-1"]) + } + + // Fake config stand-in that matches the store's expected interface. + private class FakeConfig( + val backing: MutableMap, + ) : MouseLayoutStore.Backing { + private val flow = MutableStateFlow(backing.toMap()) + + override fun snapshot() = backing.toMap() + + override fun set(newMap: Map) { + backing.clear() + backing.putAll(newMap) + flow.value = snapshot() + } + + override fun flow() = flow + } +} From 6e606e5aa2f2f382bdf2b5c50631816208db4117 Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Mon, 20 Apr 2026 17:19:02 +0800 Subject: [PATCH 09/38] :sparkles: add MouseDaemonClient with capability negotiation and stop+start fallback --- .../com/crosspaste/mouse/MouseDaemonClient.kt | 90 +++++++++++++++ .../crosspaste/mouse/MouseDaemonProcess.kt | 9 ++ .../crosspaste/mouse/MouseDaemonClientTest.kt | 107 ++++++++++++++++++ 3 files changed, 206 insertions(+) create mode 100644 app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonClient.kt create mode 100644 app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonClientTest.kt diff --git a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonClient.kt b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonClient.kt new file mode 100644 index 000000000..b080f584e --- /dev/null +++ b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonClient.kt @@ -0,0 +1,90 @@ +package com.crosspaste.mouse + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class MouseDaemonClient( + private val handle: DaemonHandle, +) { + interface DaemonHandle { + val events: SharedFlow + + suspend fun send(command: IpcCommand) + + fun close() + } + + data class CapabilitySnapshot( + val protocolVersion: Int = 1, + val features: List = emptyList(), + ) + + private val _capabilities = MutableStateFlow(CapabilitySnapshot()) + val capabilities: StateFlow = _capabilities.asStateFlow() + + val events: SharedFlow get() = handle.events + + /** + * Drives the daemon: asks for capabilities once Initialized/Ready lands, + * forwards caps into [capabilities]. Suspends forever; cancel to stop. + */ + suspend fun run() { + handle.events.collect { ev -> + when (ev) { + is IpcEvent.Initialized, is IpcEvent.Ready -> { + handle.send(IpcCommand.GetCapabilities) + } + is IpcEvent.Capabilities -> { + _capabilities.value = + CapabilitySnapshot( + protocolVersion = ev.protocolVersion, + features = ev.features, + ) + } + else -> Unit + } + } + } + + suspend fun start( + port: Int, + peers: List, + ) { + handle.send(IpcCommand.Start(port, peers)) + } + + suspend fun stop() { + handle.send(IpcCommand.Stop) + } + + /** + * Applies a new layout. If the daemon advertises `update_layout`, sends it + * natively; otherwise falls back to stop + start (current reality — daemon + * ticket #9 still pending). + */ + suspend fun updateLayout( + port: Int, + peers: List, + ) { + if ("update_layout" in _capabilities.value.features) { + handle.send(IpcCommand.UpdateLayout(peers)) + } else { + handle.send(IpcCommand.Stop) + handle.send(IpcCommand.Start(port, peers)) + } + } + + suspend fun getStatus() { + handle.send(IpcCommand.GetStatus) + } + + suspend fun enumerateLocalScreens() { + handle.send(IpcCommand.EnumerateLocalScreens) + } + + fun close() { + handle.close() + } +} diff --git a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt index f9140da5f..f122d9432 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt @@ -137,3 +137,12 @@ class MouseDaemonProcess internal constructor( } } } + +fun MouseDaemonProcess.asDaemonHandle(): MouseDaemonClient.DaemonHandle = + object : MouseDaemonClient.DaemonHandle { + override val events = this@asDaemonHandle.events + + override suspend fun send(command: IpcCommand) = this@asDaemonHandle.send(command) + + override fun close() = this@asDaemonHandle.close() + } diff --git a/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonClientTest.kt b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonClientTest.kt new file mode 100644 index 000000000..8d3144b6b --- /dev/null +++ b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonClientTest.kt @@ -0,0 +1,107 @@ +package com.crosspaste.mouse + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class MouseDaemonClientTest { + + private class FakeProcess : MouseDaemonClient.DaemonHandle { + val sent = mutableListOf() + private val _events = MutableSharedFlow(extraBufferCapacity = 32) + override val events: SharedFlow = _events.asSharedFlow() + + override suspend fun send(command: IpcCommand) { + sent.add(command) + } + + override fun close() {} + + suspend fun emit(ev: IpcEvent) { + _events.emit(ev) + } + } + + @Test + fun `negotiates capabilities on startup and records features`() = + runTest { + val proc = FakeProcess() + val client = MouseDaemonClient(proc) + val job = launch { client.run() } + yield() + proc.emit(IpcEvent.Initialized(screens = emptyList(), protocolVersion = 2)) + yield() + // client should have asked for capabilities + assertTrue(proc.sent.any { it is IpcCommand.GetCapabilities }) + proc.emit( + IpcEvent.Capabilities( + protocolVersion = 2, + features = listOf("get_capabilities", "enumerate_local_screens"), + ), + ) + yield() + assertTrue(client.capabilities.value.protocolVersion == 2) + assertTrue("enumerate_local_screens" in client.capabilities.value.features) + job.cancel() + } + + @Test + fun `updateLayout falls back to stop+start when feature unsupported`() = + runTest { + val proc = FakeProcess() + val client = MouseDaemonClient(proc) + val job = launch { client.run() } + yield() + proc.emit(IpcEvent.Initialized(screens = emptyList(), protocolVersion = 2)) + proc.emit(IpcEvent.Capabilities(protocolVersion = 2, features = emptyList())) // no update_layout + yield() + + client.updateLayout(port = 4243, peers = emptyList()) + yield() + + val sentNames = proc.sent.map { it::class.simpleName } + assertEquals(listOf("GetCapabilities", "Stop", "Start"), sentNames) + job.cancel() + } + + @Test + fun `updateLayout uses native command when feature present`() = + runTest { + val proc = FakeProcess() + val client = MouseDaemonClient(proc) + val job = launch { client.run() } + yield() + proc.emit(IpcEvent.Initialized(screens = emptyList(), protocolVersion = 2)) + proc.emit( + IpcEvent.Capabilities(protocolVersion = 2, features = listOf("update_layout")), + ) + yield() + + client.updateLayout(port = 4243, peers = emptyList()) + yield() + + assertTrue(proc.sent.last() is IpcCommand.UpdateLayout) + job.cancel() + } + + @Test + fun `events reach consumers as a SharedFlow`() = + runTest { + val proc = FakeProcess() + val client = MouseDaemonClient(proc) + val job = launch { client.run() } + yield() + val got = launch { client.events.first { it is IpcEvent.PeerConnected } } + yield() + proc.emit(IpcEvent.PeerConnected(name = "Desktop", deviceId = "d1")) + got.join() + job.cancel() + } +} From 23797248288e60fd0a5eca868bc079da56c04309 Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Mon, 20 Apr 2026 17:24:39 +0800 Subject: [PATCH 10/38] =?UTF-8?q?:sparkles:=20add=20MouseDaemonManager=20l?= =?UTF-8?q?ifecycle=20=E2=80=94=20enables=20daemon=20driven=20by=20config?= =?UTF-8?q?=20and=20paired=20devices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../crosspaste/mouse/MouseDaemonManager.kt | 140 +++++++++++++++ .../mouse/MouseDaemonManagerTest.kt | 169 ++++++++++++++++++ 2 files changed, 309 insertions(+) create mode 100644 app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonManager.kt create mode 100644 app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonManagerTest.kt diff --git a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonManager.kt b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonManager.kt new file mode 100644 index 000000000..7b4c1b9eb --- /dev/null +++ b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonManager.kt @@ -0,0 +1,140 @@ +package com.crosspaste.mouse + +import com.crosspaste.db.sync.SyncRuntimeInfoDao +import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch + +sealed interface MouseState { + object Disabled : MouseState + + object Starting : MouseState + + data class Running( + val connectedPeers: List, + ) : MouseState + + data class Warning( + val code: String, + val message: String, + ) : MouseState + + data class Error( + val message: String, + ) : MouseState +} + +/** + * Runtime orchestrator for the crosspaste-mouse daemon. + * + * Observes the `mouseEnabled` flag, `mouseListenPort`, the layout store, and the + * paired-device list. When enabled, spawns a daemon client (via [clientFactory]), + * sends `Start { port, peers }`, and translates further input changes into + * `updateLayout` calls. When disabled, stops the client and tears it down. + * + * `run()` is expected to be launched once at app startup and live for the app + * lifetime — cancel the launched job to shut the manager down. + */ +class MouseDaemonManager( + private val enabledFlow: Flow, + private val portFlow: Flow, + private val layoutStore: MouseLayoutStore, + private val syncRuntimeInfoDao: SyncRuntimeInfoDao, + private val clientFactory: () -> MouseDaemonClient, +) { + private val _state = MutableStateFlow(MouseState.Disabled) + val state: StateFlow = _state.asStateFlow() + + suspend fun run() = + coroutineScope { + var activeClient: MouseDaemonClient? = null + var activeClientJob: Job? = null + var eventJob: Job? = null + var lastPeers: List? = null + var lastPort = -1 + + combine( + enabledFlow, + portFlow, + layoutStore.flow(), + syncRuntimeInfoDao.getAllSyncRuntimeInfosFlow(), + ) { enabled, port, layout, syncs -> + MergedInputs(enabled, port, MousePeerMapper.map(syncs, layout)) + }.distinctUntilChanged().collect { inputs -> + if (!inputs.enabled) { + activeClient?.let { + runCatching { it.stop() } + it.close() + } + eventJob?.cancel() + activeClientJob?.cancel() + activeClient = null + activeClientJob = null + eventJob = null + lastPeers = null + lastPort = -1 + _state.value = MouseState.Disabled + return@collect + } + + if (activeClient == null) { + _state.value = MouseState.Starting + val client = clientFactory() + activeClient = client + activeClientJob = launch { client.run() } + + // Route events into state. + eventJob = + launch { + client.events.collect { ev -> + when (ev) { + is IpcEvent.PeerConnected -> + _state.value = + MouseState.Running( + connectedPeers = + (state.value as? MouseState.Running) + ?.connectedPeers + .orEmpty() + ev.name, + ) + is IpcEvent.PeerDisconnected -> + _state.value = + MouseState.Running( + connectedPeers = + (state.value as? MouseState.Running) + ?.connectedPeers + .orEmpty() - ev.name, + ) + is IpcEvent.Warning -> + _state.value = MouseState.Warning(ev.code, ev.message) + is IpcEvent.Error -> + _state.value = MouseState.Error(ev.message) + else -> Unit + } + } + } + client.start(inputs.port, inputs.peers) + lastPort = inputs.port + lastPeers = inputs.peers + return@collect + } + + if (lastPort != inputs.port || lastPeers != inputs.peers) { + activeClient?.updateLayout(inputs.port, inputs.peers) + lastPort = inputs.port + lastPeers = inputs.peers + } + } + } + + private data class MergedInputs( + val enabled: Boolean, + val port: Int, + val peers: List, + ) +} diff --git a/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonManagerTest.kt b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonManagerTest.kt new file mode 100644 index 000000000..2802e5241 --- /dev/null +++ b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonManagerTest.kt @@ -0,0 +1,169 @@ +// Uses real-dispatcher runBlocking + tiny delays rather than kotlinx-coroutines-test's +// virtual-time runTest because combine(...)+collect against a mix of StateFlow and a +// SharedFlow-backed DaemonHandle doesn't drain deterministically with a single yield() +// under StandardTestDispatcher — the manager's combine collector and the FakeHandle's +// SharedFlow emissions race across continuations. runBlocking with short delays is the +// escalation path the plan allows. +package com.crosspaste.mouse + +import com.crosspaste.db.sync.SyncRuntimeInfo +import com.crosspaste.db.sync.SyncRuntimeInfoDao +import com.crosspaste.platform.Platform +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class MouseDaemonManagerTest { + + private class FakeHandle : MouseDaemonClient.DaemonHandle { + val sent = mutableListOf() + private val _events = MutableSharedFlow(extraBufferCapacity = 32) + override val events: SharedFlow = _events.asSharedFlow() + + override suspend fun send(command: IpcCommand) { + sent.add(command) + } + + override fun close() {} + + suspend fun emit(ev: IpcEvent) { + _events.emit(ev) + } + } + + private fun sri( + inst: String, + host: String?, + ) = SyncRuntimeInfo( + appInstanceId = inst, + appVersion = "x", + userName = "u", + deviceId = "d-$inst", + deviceName = "Dev-$inst", + platform = Platform(name = "Mac", arch = "arm64", bitMode = 64, version = "14"), + port = 4243, + connectHostAddress = host, + ) + + @Test + fun `does nothing when disabled`() = + runBlocking { + val handle = FakeHandle() + val store = inMemoryStore() + val mgr = newManager(handle, store, enabled = MutableStateFlow(false)) + val job = launch { mgr.run() } + delay(50) + assertTrue(handle.sent.isEmpty()) + job.cancel() + } + + @Test + fun `sends Start with peers when enabled`() = + runBlocking { + val handle = FakeHandle() + val store = inMemoryStore().apply { upsert("inst-1", Position(1920, 0)) } + val syncs = MutableStateFlow(listOf(sri("inst-1", "192.168.1.10"))) + val mgr = newManager(handle, store, syncs = syncs, enabled = MutableStateFlow(true)) + val job = launch { mgr.run() } + delay(50) + handle.emit(IpcEvent.Initialized(emptyList(), 2)) + handle.emit(IpcEvent.Capabilities(2, emptyList())) + delay(50) + val start = handle.sent.filterIsInstance().singleOrNull() + assertTrue(start != null, "expected exactly one Start, got ${handle.sent}") + assertEquals("192.168.1.10:4243", start.peers.single().address) + job.cancel() + } + + @Test + fun `layout change triggers updateLayout`() = + runBlocking { + val handle = FakeHandle() + val store = inMemoryStore().apply { upsert("inst-1", Position(1920, 0)) } + val syncs = MutableStateFlow(listOf(sri("inst-1", "192.168.1.10"))) + val mgr = newManager(handle, store, syncs = syncs, enabled = MutableStateFlow(true)) + val job = launch { mgr.run() } + delay(50) + handle.emit(IpcEvent.Initialized(emptyList(), 2)) + handle.emit(IpcEvent.Capabilities(2, emptyList())) // no update_layout → stop+start + delay(50) + store.upsert("inst-1", Position(-1920, 0)) // user dragged peer to the left + delay(50) + // Stop+start fallback expected: [Start, Stop, Start] + val starts = handle.sent.filterIsInstance() + assertEquals(2, starts.size) + assertEquals( + Position(-1920, 0), + starts + .last() + .peers + .single() + .position, + ) + job.cancel() + } + + @Test + fun `disable sends Stop`() = + runBlocking { + val handle = FakeHandle() + val store = inMemoryStore().apply { upsert("inst-1", Position(1920, 0)) } + val syncs = MutableStateFlow(listOf(sri("inst-1", "192.168.1.10"))) + val enabled = MutableStateFlow(true) + val mgr = newManager(handle, store, syncs = syncs, enabled = enabled) + val job = launch { mgr.run() } + delay(50) + handle.emit(IpcEvent.Initialized(emptyList(), 2)) + handle.emit(IpcEvent.Capabilities(2, emptyList())) + delay(50) + enabled.value = false + delay(50) + assertTrue(handle.sent.any { it is IpcCommand.Stop }) + job.cancel() + } + + // --- test helpers --- + private fun inMemoryStore(): MouseLayoutStore { + val backing = + object : MouseLayoutStore.Backing { + private val flow = MutableStateFlow>(emptyMap()) + + override fun snapshot() = flow.value + + override fun set(newMap: Map) { + flow.value = newMap + } + + override fun flow() = flow + } + return MouseLayoutStore(backing) + } + + private fun newManager( + handle: FakeHandle, + store: MouseLayoutStore, + syncs: MutableStateFlow> = MutableStateFlow(emptyList()), + enabled: MutableStateFlow, + port: MutableStateFlow = MutableStateFlow(4243), + ): MouseDaemonManager { + val dao = mockk() + every { dao.getAllSyncRuntimeInfosFlow() } returns syncs + val client = MouseDaemonClient(handle) + return MouseDaemonManager( + enabledFlow = enabled, + portFlow = port, + layoutStore = store, + syncRuntimeInfoDao = dao, + clientFactory = { client }, + ) + } +} From 1715d8299a14f850308ae609ddc020c3944e3166 Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Mon, 20 Apr 2026 17:32:07 +0800 Subject: [PATCH 11/38] :sparkles: wire MouseDaemonManager into Koin and start on app launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce desktopMouseModule() that registers MouseLayoutStore (backed by DesktopConfigManager via a new typed updateConfig(updater) overload for Map/complex fields) and MouseDaemonManager (with flows derived from DesktopAppConfig.mouseEnabled/mouseListenPort). Launch MouseDaemonManager.run() from CrossPaste startup in non-headless mode only — the daemon is a GUI-oriented feature and serves no purpose in headless/server environments, so desktopMouseModule() is also only added to the non-headless Koin modules list. --- .../kotlin/com/crosspaste/CrossPaste.kt | 6 ++ .../kotlin/com/crosspaste/DesktopModule.kt | 1 + .../com/crosspaste/DesktopMouseModule.kt | 70 +++++++++++++++++++ .../crosspaste/config/DesktopConfigManager.kt | 25 +++++++ 4 files changed, 102 insertions(+) create mode 100644 app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt diff --git a/app/src/desktopMain/kotlin/com/crosspaste/CrossPaste.kt b/app/src/desktopMain/kotlin/com/crosspaste/CrossPaste.kt index a5252fabb..8e168d30a 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/CrossPaste.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/CrossPaste.kt @@ -31,6 +31,7 @@ import com.crosspaste.db.DriverFactory import com.crosspaste.listener.GlobalListener import com.crosspaste.log.DesktopCrossPasteLogger import com.crosspaste.mcp.McpServer +import com.crosspaste.mouse.MouseDaemonManager import com.crosspaste.net.PasteBonjourService import com.crosspaste.net.PasteClient import com.crosspaste.net.ResourcesClient @@ -174,6 +175,11 @@ class CrossPaste { if (configManager.getCurrentConfig().enableMcpServer) { ioCoroutineDispatcher.launch { koin.get().start() } } + if (!headless) { + ioCoroutineDispatcher.launch { + koin.get().run() + } + } koin.get() koin.get() koin.get().start() diff --git a/app/src/desktopMain/kotlin/com/crosspaste/DesktopModule.kt b/app/src/desktopMain/kotlin/com/crosspaste/DesktopModule.kt index 106690668..ccd6ea75b 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/DesktopModule.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/DesktopModule.kt @@ -184,6 +184,7 @@ class DesktopModule( pasteComponentModule(), uiModule(), viewModelModule(), + desktopMouseModule(), ) } } diff --git a/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt b/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt new file mode 100644 index 000000000..958144ddf --- /dev/null +++ b/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt @@ -0,0 +1,70 @@ +package com.crosspaste + +import com.crosspaste.config.DesktopConfigManager +import com.crosspaste.mouse.MouseDaemonBinary +import com.crosspaste.mouse.MouseDaemonClient +import com.crosspaste.mouse.MouseDaemonManager +import com.crosspaste.mouse.MouseDaemonProcess +import com.crosspaste.mouse.MouseLayoutStore +import com.crosspaste.mouse.Position +import com.crosspaste.mouse.asDaemonHandle +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import org.koin.core.module.Module +import org.koin.dsl.module + +/** + * Bridges [MouseLayoutStore] to [DesktopConfigManager]-backed persistence. + * + * Reads are served off the current config snapshot. Writes go through + * [DesktopConfigManager.updateConfig] (the typed-updater overload) so the + * layout persists to `appConfig.json` and the config StateFlow stays in + * sync. The store's own [flow] is the authoritative observable surface — + * it's updated in lockstep with `set()` so observers see new layouts + * immediately, independent of any config-save latency. + */ +class DesktopAppConfigMouseLayoutBacking( + private val configManager: DesktopConfigManager, +) : MouseLayoutStore.Backing { + + private val _flow: MutableStateFlow> = + MutableStateFlow(configManager.config.value.mouseLayout) + + override fun snapshot(): Map = configManager.config.value.mouseLayout + + override fun set(newMap: Map) { + configManager.updateConfig { it.copy(mouseLayout = newMap) } + _flow.value = newMap + } + + override fun flow(): MutableStateFlow> = _flow +} + +fun desktopMouseModule(): Module = + module { + single { + MouseLayoutStore(DesktopAppConfigMouseLayoutBacking(get())) + } + single { + val configManager = get() + MouseDaemonManager( + enabledFlow = + configManager.config + .map { it.mouseEnabled } + .distinctUntilChanged(), + portFlow = + configManager.config + .map { it.mouseListenPort } + .distinctUntilChanged(), + layoutStore = get(), + syncRuntimeInfoDao = get(), + clientFactory = { + val binary = + MouseDaemonBinary.resolve() + ?: throw IllegalStateException("crosspaste-mouse binary not found") + MouseDaemonClient(MouseDaemonProcess.spawn(binary).asDaemonHandle()) + }, + ) + } + } diff --git a/app/src/desktopMain/kotlin/com/crosspaste/config/DesktopConfigManager.kt b/app/src/desktopMain/kotlin/com/crosspaste/config/DesktopConfigManager.kt index e41a21c99..340c15872 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/config/DesktopConfigManager.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/config/DesktopConfigManager.kt @@ -82,6 +82,31 @@ class DesktopConfigManager( } } + /** + * Typed, lambda-based updater for fields that cannot flow through the + * scalar `copy(key, value)` path (e.g. `Map` / complex types). On save + * failure, reverts the in-memory state and surfaces a notification, + * matching the scalar [updateConfig] behavior. + */ + @Synchronized + fun updateConfig(updater: (DesktopAppConfig) -> DesktopAppConfig) { + val oldConfig = _config.value + val newConfig = updater(oldConfig) + _config.value = newConfig + runCatching { + saveConfig(newConfig) + }.onFailure { e -> + logger.error(e) { "Failed to save config" } + notificationManager?.let { manager -> + manager.sendNotification( + title = { it.getText("failed_to_save_config") }, + messageType = MessageType.Error, + ) + } + _config.value = oldConfig + } + } + fun saveConfig(config: DesktopAppConfig) { configFilePersist.save(config) } From a4316ed8474b39b32707707eef7df59ba0bb34dd Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Mon, 20 Apr 2026 21:26:39 +0800 Subject: [PATCH 12/38] :sparkles: add ScreenArrangementViewModel for mouse layout UI --- .../ui/mouse/ScreenArrangementViewModel.kt | 89 +++++++++++++++++++ .../mouse/ScreenArrangementViewModelTest.kt | 84 +++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModel.kt create mode 100644 app/src/desktopTest/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModelTest.kt diff --git a/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModel.kt b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModel.kt new file mode 100644 index 000000000..5bf5c96ee --- /dev/null +++ b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModel.kt @@ -0,0 +1,89 @@ +package com.crosspaste.ui.mouse + +import com.crosspaste.mouse.IpcEvent +import com.crosspaste.mouse.MouseLayoutStore +import com.crosspaste.mouse.Position +import com.crosspaste.mouse.ScreenInfo +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +data class RemoteDeviceInfo( + val name: String, + val screens: List, + val position: Position, +) + +class ScreenArrangementViewModel( + private val events: SharedFlow, + private val layoutStore: MouseLayoutStore, +) { + private val _localScreens = MutableStateFlow>(emptyList()) + val localScreens: StateFlow> = _localScreens.asStateFlow() + + private val _remoteDevices = MutableStateFlow>(emptyMap()) + val remoteDevices: StateFlow> = _remoteDevices.asStateFlow() + + /** For tests: inject a remote without emitting an event. */ + fun seedRemote( + deviceId: String, + name: String, + screens: List, + ) { + val existing = _remoteDevices.value[deviceId] + val position = existing?.position ?: layoutStore.get(deviceId) ?: Position(0, 0) + _remoteDevices.value = + _remoteDevices.value + (deviceId to RemoteDeviceInfo(name, screens, position)) + } + + suspend fun observe() { + events.collect { ev -> + when (ev) { + is IpcEvent.Initialized -> _localScreens.value = ev.screens + is IpcEvent.LocalScreens -> _localScreens.value = ev.screens + is IpcEvent.PeerScreensLearned -> { + val existing = _remoteDevices.value[ev.deviceId] + _remoteDevices.value = + _remoteDevices.value + ( + ev.deviceId to + RemoteDeviceInfo( + name = existing?.name ?: ev.deviceId, + screens = ev.screens, + position = + existing?.position + ?: layoutStore.get(ev.deviceId) + ?: Position(0, 0), + ) + ) + } + is IpcEvent.PeerConnected -> { + val existing = _remoteDevices.value[ev.deviceId] + if (existing != null && existing.name != ev.name) { + _remoteDevices.value = + _remoteDevices.value + (ev.deviceId to existing.copy(name = ev.name)) + } + } + else -> Unit + } + } + } + + fun onDragDevice( + deviceId: String, + dx: Int, + dy: Int, + ) { + val existing = _remoteDevices.value[deviceId] ?: return + val next = + existing.copy( + position = Position(existing.position.x + dx, existing.position.y + dy), + ) + _remoteDevices.value = _remoteDevices.value + (deviceId to next) + } + + fun onDragEnd(deviceId: String) { + val dev = _remoteDevices.value[deviceId] ?: return + layoutStore.upsert(deviceId, dev.position) + } +} diff --git a/app/src/desktopTest/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModelTest.kt b/app/src/desktopTest/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModelTest.kt new file mode 100644 index 000000000..9fc186b4f --- /dev/null +++ b/app/src/desktopTest/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModelTest.kt @@ -0,0 +1,84 @@ +package com.crosspaste.ui.mouse + +import com.crosspaste.mouse.IpcEvent +import com.crosspaste.mouse.MouseLayoutStore +import com.crosspaste.mouse.Position +import com.crosspaste.mouse.ScreenInfo +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import kotlin.test.Test +import kotlin.test.assertEquals + +class ScreenArrangementViewModelTest { + + private fun store(initial: Map = emptyMap()): MouseLayoutStore { + val backing = + object : MouseLayoutStore.Backing { + val flow = MutableStateFlow(initial) + + override fun snapshot() = flow.value + + override fun set(newMap: Map) { + flow.value = newMap + } + + override fun flow() = flow + } + return MouseLayoutStore(backing) + } + + @Test + fun `learns local and remote screens from events`() = + runTest { + val events = MutableSharedFlow(replay = 0, extraBufferCapacity = 16) + val vm = ScreenArrangementViewModel(events.asSharedFlow(), store()) + val job = launch { vm.observe() } + yield() + events.emit( + IpcEvent.Initialized( + screens = listOf(ScreenInfo(0, 1920, 1080, 0, 0, 1.0, true)), + protocolVersion = 2, + ), + ) + events.emit( + IpcEvent.PeerScreensLearned( + deviceId = "inst-1", + screens = listOf(ScreenInfo(0, 2560, 1440, 0, 0, 2.0, true)), + ), + ) + yield() + assertEquals(1, vm.localScreens.value.size) + assertEquals( + 2560, + vm.remoteDevices.value["inst-1"]!! + .screens + .single() + .width, + ) + job.cancel() + } + + @Test + fun `drag updates in-memory position without committing`() { + val s = store(mapOf("inst-1" to Position(1920, 0))) + val vm = ScreenArrangementViewModel(MutableSharedFlow(), s) + vm.seedRemote("inst-1", "Desktop", listOf(ScreenInfo(0, 2560, 1440, 0, 0, 1.0, true))) + vm.onDragDevice("inst-1", dx = 100, dy = 0) + assertEquals(Position(2020, 0), vm.remoteDevices.value["inst-1"]!!.position) + assertEquals(Position(1920, 0), s.get("inst-1")) // not committed + } + + @Test + fun `onDragEnd commits to store`() { + val s = store(mapOf("inst-1" to Position(1920, 0))) + val vm = ScreenArrangementViewModel(MutableSharedFlow(), s) + vm.seedRemote("inst-1", "Desktop", listOf(ScreenInfo(0, 2560, 1440, 0, 0, 1.0, true))) + vm.onDragDevice("inst-1", dx = -3840, dy = 0) // user put peer on far left + vm.onDragEnd("inst-1") + assertEquals(Position(-1920, 0), s.get("inst-1")) + } +} From 248aff312d3fa6fb9d160f270bd9c6eb9b8c7479 Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Mon, 20 Apr 2026 21:40:49 +0800 Subject: [PATCH 13/38] :sparkles: add drag-to-arrange ScreenCanvas for mouse layout --- .../com/crosspaste/ui/mouse/ScreenCanvas.kt | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenCanvas.kt diff --git a/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenCanvas.kt b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenCanvas.kt new file mode 100644 index 000000000..eb6606f4f --- /dev/null +++ b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenCanvas.kt @@ -0,0 +1,182 @@ +package com.crosspaste.ui.mouse + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import com.crosspaste.mouse.Position +import com.crosspaste.mouse.ScreenInfo +import com.crosspaste.ui.theme.AppUISize + +/** + * Canvas that lets the user drag remote devices around a virtual desktop. + * Local screens are locked at (0, 0). Each remote device is a rigid group + * of its own screens; dragging any screen of the group shifts the group's + * [Position] offset. + */ +@Composable +fun ScreenCanvas( + viewModel: ScreenArrangementViewModel, + modifier: Modifier = Modifier, +) { + val locals by viewModel.localScreens.collectAsState() + val remotes by viewModel.remoteDevices.collectAsState() + val measurer: TextMeasurer = rememberTextMeasurer() + + BoxWithConstraints(modifier = modifier.fillMaxWidth().height(AppUISize.xxxxLarge * 6)) { + val viewportPx = maxWidth.value.coerceAtLeast(400f) + val (bounds, scale) = + remember(locals, remotes, viewportPx) { + computeBoundsAndScale(locals, remotes, viewportPx) + } + + Canvas( + modifier = + Modifier + .fillMaxWidth() + .height(AppUISize.xxxxLarge * 6) + .background(Color(0xFFF2F2F2)) + .pointerInput(remotes.keys) { + detectDragGestures( + onDrag = { change, drag -> + change.consume() + val hitId = + remotes.entries + .firstOrNull { (_, info) -> + info.screens.any { screen -> + val rect = toRect(info.position, screen, bounds, scale) + rect.contains(change.position) + } + }?.key ?: return@detectDragGestures + viewModel.onDragDevice( + deviceId = hitId, + dx = (drag.x / scale).toInt(), + dy = (drag.y / scale).toInt(), + ) + }, + onDragEnd = { + remotes.keys.forEach { viewModel.onDragEnd(it) } + }, + ) + }, + ) { + // Draw local screens (blue), origin-locked + locals.forEach { screen -> + val rect = toRect(Position(0, 0), screen, bounds, scale) + drawRect( + color = Color(0xFF1E88E5).copy(alpha = 0.35f), + topLeft = rect.topLeft, + size = rect.size, + ) + drawRect( + color = Color(0xFF1E88E5), + topLeft = rect.topLeft, + size = rect.size, + style = Stroke(width = 2f), + ) + } + // Draw each remote device's screens (orange), as a group + remotes.forEach { (_, info) -> + info.screens.forEach { screen -> + val rect = toRect(info.position, screen, bounds, scale) + drawRect( + color = Color(0xFFFB8C00).copy(alpha = 0.35f), + topLeft = rect.topLeft, + size = rect.size, + ) + drawRect( + color = Color(0xFFFB8C00), + topLeft = rect.topLeft, + size = rect.size, + style = Stroke(width = 2f), + ) + drawText( + textMeasurer = measurer, + text = AnnotatedString(info.name), + topLeft = rect.topLeft + Offset(4f, 4f), + ) + } + } + } + } +} + +private data class WorldBounds( + val minX: Int, + val minY: Int, +) + +private data class RectFloats( + val topLeft: Offset, + val size: Size, +) { + fun contains(p: Offset) = + p.x in topLeft.x..(topLeft.x + size.width) && + p.y in topLeft.y..(topLeft.y + size.height) +} + +private fun computeBoundsAndScale( + locals: List, + remotes: Map, + viewportPx: Float, +): Pair { + val allRects = + buildList { + locals.forEach { add(IntRect(0, 0, it.width, it.height)) } + remotes.values.forEach { info -> + info.screens.forEach { + add( + IntRect( + info.position.x, + info.position.y, + info.position.x + it.width, + info.position.y + it.height, + ), + ) + } + } + } + val minX = allRects.minOfOrNull { it.left } ?: 0 + val minY = allRects.minOfOrNull { it.top } ?: 0 + val maxX = allRects.maxOfOrNull { it.right } ?: 1920 + val worldWidth = (maxX - minX).coerceAtLeast(1920) + val scale = ((viewportPx - 40f) / worldWidth).coerceAtLeast(0.01f) + return WorldBounds(minX, minY) to scale +} + +private fun toRect( + position: Position, + screen: ScreenInfo, + bounds: WorldBounds, + scale: Float, +): RectFloats { + val x = (position.x - bounds.minX) * scale + 20f + val y = (position.y - bounds.minY) * scale + 20f + return RectFloats( + topLeft = Offset(x, y), + size = Size(screen.width * scale, screen.height * scale), + ) +} + +private data class IntRect( + val left: Int, + val top: Int, + val right: Int, + val bottom: Int, +) From f4e9f94f54e76db0adaa569b5ee10d123c63a723 Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Mon, 20 Apr 2026 21:57:14 +0800 Subject: [PATCH 14/38] :sparkles: add MouseSettings screen with arrangement canvas and permission dialog --- .../kotlin/com/crosspaste/ui/Route.kt | 6 ++ .../com/crosspaste/DesktopMouseModule.kt | 7 ++ .../crosspaste/mouse/MouseDaemonManager.kt | 21 +++++ .../kotlin/com/crosspaste/ui/DesktopRoute.kt | 2 + .../crosspaste/ui/DesktopScreenProvider.kt | 7 ++ .../ui/mouse/MousePermissionDialog.kt | 27 ++++++ .../ui/mouse/MouseSettingsScreen.kt | 85 +++++++++++++++++++ .../settings/AdvancedSettingsContentView.kt | 10 +++ .../mouse/MouseDaemonManagerTest.kt | 29 +++++++ 9 files changed, 194 insertions(+) create mode 100644 app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MousePermissionDialog.kt create mode 100644 app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MouseSettingsScreen.kt diff --git a/app/src/commonMain/kotlin/com/crosspaste/ui/Route.kt b/app/src/commonMain/kotlin/com/crosspaste/ui/Route.kt index 4d5073f21..64d21fc63 100644 --- a/app/src/commonMain/kotlin/com/crosspaste/ui/Route.kt +++ b/app/src/commonMain/kotlin/com/crosspaste/ui/Route.kt @@ -133,6 +133,12 @@ object NetworkSettings : Route { override val name: String = NAME } +@Serializable +object MouseSettings : Route { + const val NAME: String = "mouse_settings" + override val name: String = NAME +} + @Serializable object StorageSettings : Route { const val NAME: String = "storage_settings" diff --git a/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt b/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt index 958144ddf..ad91d40cc 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt @@ -8,6 +8,7 @@ import com.crosspaste.mouse.MouseDaemonProcess import com.crosspaste.mouse.MouseLayoutStore import com.crosspaste.mouse.Position import com.crosspaste.mouse.asDaemonHandle +import com.crosspaste.ui.mouse.ScreenArrangementViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map @@ -67,4 +68,10 @@ fun desktopMouseModule(): Module = }, ) } + factory { + ScreenArrangementViewModel( + events = get().events, + layoutStore = get(), + ) + } } diff --git a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonManager.kt b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonManager.kt index 7b4c1b9eb..41a5163c7 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonManager.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonManager.kt @@ -2,10 +2,14 @@ package com.crosspaste.mouse import com.crosspaste.db.sync.SyncRuntimeInfoDao import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -51,6 +55,21 @@ class MouseDaemonManager( private val _state = MutableStateFlow(MouseState.Disabled) val state: StateFlow = _state.asStateFlow() + /** + * Aggregated event stream multiplexed across the manager's active clients. + * UI components (e.g. [com.crosspaste.ui.mouse.ScreenArrangementViewModel]) + * can subscribe here to receive `IpcEvent`s without having to plumb a + * specific client session through DI — the manager always forwards events + * from whichever client is currently active. + */ + private val _events = + MutableSharedFlow( + replay = 0, + extraBufferCapacity = 256, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val events: SharedFlow = _events.asSharedFlow() + suspend fun run() = coroutineScope { var activeClient: MouseDaemonClient? = null @@ -93,6 +112,8 @@ class MouseDaemonManager( eventJob = launch { client.events.collect { ev -> + // Re-broadcast every event for UI subscribers. + _events.tryEmit(ev) when (ev) { is IpcEvent.PeerConnected -> _state.value = diff --git a/app/src/desktopMain/kotlin/com/crosspaste/ui/DesktopRoute.kt b/app/src/desktopMain/kotlin/com/crosspaste/ui/DesktopRoute.kt index bcd117fce..ab890db35 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/ui/DesktopRoute.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/ui/DesktopRoute.kt @@ -22,6 +22,7 @@ fun getRouteName(dest: NavDestination): String? = dest.hasRoute() -> Settings.NAME dest.hasRoute() -> PasteboardSettings.NAME dest.hasRoute() -> NetworkSettings.NAME + dest.hasRoute() -> MouseSettings.NAME dest.hasRoute() -> StorageSettings.NAME dest.hasRoute() -> ShortcutKeys.NAME else -> null @@ -46,6 +47,7 @@ fun getRootRouteName(dest: NavDestination): String? = dest.hasRoute() -> Settings.NAME dest.hasRoute() -> Settings.NAME dest.hasRoute() -> Settings.NAME + dest.hasRoute() -> Settings.NAME dest.hasRoute() -> Settings.NAME dest.hasRoute() -> ShortcutKeys.NAME else -> null diff --git a/app/src/desktopMain/kotlin/com/crosspaste/ui/DesktopScreenProvider.kt b/app/src/desktopMain/kotlin/com/crosspaste/ui/DesktopScreenProvider.kt index 6bc0e0c9e..f8cb3570c 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/ui/DesktopScreenProvider.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/ui/DesktopScreenProvider.kt @@ -42,6 +42,7 @@ import com.crosspaste.ui.extension.ExtensionContentView import com.crosspaste.ui.extension.mcp.McpContentView import com.crosspaste.ui.extension.ocr.OCRContentView import com.crosspaste.ui.extension.sourcecontrol.SourceControlContentView +import com.crosspaste.ui.mouse.MouseSettingsScreen import com.crosspaste.ui.paste.PasteExportContentView import com.crosspaste.ui.paste.PasteImportContentView import com.crosspaste.ui.settings.DesktopNetworkSettingsContentView @@ -193,6 +194,12 @@ class DesktopScreenProvider( ) { NetworkSettingsScreen() } + composable( + exitTransition = { slideOutRight() }, + enterTransition = { slideInLeft() }, + ) { + MouseSettingsScreen() + } composable( exitTransition = { slideOutRight() }, enterTransition = { slideInLeft() }, diff --git a/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MousePermissionDialog.kt b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MousePermissionDialog.kt new file mode 100644 index 000000000..163b5e5d2 --- /dev/null +++ b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MousePermissionDialog.kt @@ -0,0 +1,27 @@ +package com.crosspaste.ui.mouse + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import com.crosspaste.mouse.IpcEvent + +/** + * Surfaces the most recent daemon [IpcEvent.Warning] (e.g. missing input + * monitoring / accessibility permission) as a modal dialog. When [warning] + * is `null` the dialog is hidden — callers pass a derived value from the + * manager's state so the dialog auto-dismisses once the warning clears. + */ +@Composable +fun MousePermissionDialog( + warning: IpcEvent.Warning?, + onDismiss: () -> Unit, +) { + if (warning == null) return + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(warning.code) }, + text = { Text(warning.message) }, + confirmButton = { TextButton(onClick = onDismiss) { Text("OK") } }, + ) +} diff --git a/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MouseSettingsScreen.kt b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MouseSettingsScreen.kt new file mode 100644 index 000000000..1158360eb --- /dev/null +++ b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MouseSettingsScreen.kt @@ -0,0 +1,85 @@ +package com.crosspaste.ui.mouse + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.crosspaste.config.DesktopConfigManager +import com.crosspaste.mouse.IpcEvent +import com.crosspaste.mouse.MouseDaemonManager +import com.crosspaste.mouse.MouseState +import com.crosspaste.ui.theme.AppUISize +import org.koin.compose.koinInject + +/** + * Settings screen for the mouse-sharing feature: exposes the on/off switch, + * a one-line status summary driven by [MouseDaemonManager.state], the drag-to- + * arrange [ScreenCanvas], and a permission dialog that surfaces any current + * warning emitted by the daemon. + */ +@Composable +fun MouseSettingsScreen() { + val manager: MouseDaemonManager = koinInject() + val viewModel: ScreenArrangementViewModel = koinInject() + val configManager: DesktopConfigManager = koinInject() + + LaunchedEffect(viewModel) { viewModel.observe() } + + val state by manager.state.collectAsState() + val config by configManager.config.collectAsState() + + val currentWarning = + (state as? MouseState.Warning)?.let { IpcEvent.Warning(it.code, it.message) } + + // Allow the user to acknowledge-and-hide a warning even while the manager + // still carries it on `state` (e.g. daemon hasn't yet re-emitted a cleared + // status). The flag auto-resets whenever the warning identity changes. + var dismissed by remember(currentWarning) { mutableStateOf(false) } + + MousePermissionDialog( + warning = currentWarning?.takeUnless { dismissed }, + onDismiss = { dismissed = true }, + ) + + Column( + modifier = Modifier.padding(AppUISize.medium), + verticalArrangement = Arrangement.spacedBy(AppUISize.medium), + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text("Share keyboard/mouse", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.weight(1f)) + Switch( + checked = config.mouseEnabled, + onCheckedChange = { configManager.updateConfig("mouseEnabled", it) }, + ) + } + Text( + text = + when (val s = state) { + MouseState.Disabled -> "Off" + MouseState.Starting -> "Starting…" + is MouseState.Running -> "Running — ${s.connectedPeers.size} peer(s)" + is MouseState.Warning -> "Warning: ${s.code}" + is MouseState.Error -> "Error: ${s.message}" + }, + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(AppUISize.small)) + ScreenCanvas(viewModel = viewModel, modifier = Modifier.fillMaxWidth()) + } +} diff --git a/app/src/desktopMain/kotlin/com/crosspaste/ui/settings/AdvancedSettingsContentView.kt b/app/src/desktopMain/kotlin/com/crosspaste/ui/settings/AdvancedSettingsContentView.kt index bb3121be0..9944362cc 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/ui/settings/AdvancedSettingsContentView.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/ui/settings/AdvancedSettingsContentView.kt @@ -6,9 +6,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.composables.icons.materialsymbols.MaterialSymbols import com.composables.icons.materialsymbols.rounded.Content_paste +import com.composables.icons.materialsymbols.rounded.Mouse import com.composables.icons.materialsymbols.rounded.Storage import com.composables.icons.materialsymbols.rounded.Wifi import com.crosspaste.ui.LocalThemeExtState +import com.crosspaste.ui.MouseSettings import com.crosspaste.ui.NavigationManager import com.crosspaste.ui.NetworkSettings import com.crosspaste.ui.PasteboardSettings @@ -38,6 +40,14 @@ fun AdvancedSettingsContentView() { navigationManager.navigate(NetworkSettings) } HorizontalDivider(modifier = Modifier.padding(start = xxxxLarge)) + SettingListItem( + title = "mouse_settings", + subtitle = "mouse_settings_desc", + icon = IconData(MaterialSymbols.Rounded.Mouse, themeExt.purpleIconColor), + ) { + navigationManager.navigate(MouseSettings) + } + HorizontalDivider(modifier = Modifier.padding(start = xxxxLarge)) SettingListItem( title = "storage_settings", subtitle = "storage_settings_desc", diff --git a/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonManagerTest.kt b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonManagerTest.kt index 2802e5241..d212efba6 100644 --- a/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonManagerTest.kt +++ b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonManagerTest.kt @@ -112,6 +112,35 @@ class MouseDaemonManagerTest { job.cancel() } + @Test + fun `events flow re-emits client events for UI subscribers`() = + runBlocking { + val handle = FakeHandle() + val store = inMemoryStore().apply { upsert("inst-1", Position(1920, 0)) } + val syncs = MutableStateFlow(listOf(sri("inst-1", "192.168.1.10"))) + val mgr = newManager(handle, store, syncs = syncs, enabled = MutableStateFlow(true)) + val received = mutableListOf() + val collector = + launch { + mgr.events.collect { received.add(it) } + } + val job = launch { mgr.run() } + delay(50) + val learned = + IpcEvent.PeerScreensLearned( + deviceId = "peer-1", + screens = listOf(ScreenInfo(0, 1920, 1080, 0, 0, 1.0, true)), + ) + handle.emit(learned) + delay(50) + assertTrue( + received.any { it is IpcEvent.PeerScreensLearned }, + "expected PeerScreensLearned on manager.events, got $received", + ) + collector.cancel() + job.cancel() + } + @Test fun `disable sends Stop`() = runBlocking { From ea5dee7fa41172baa3281b8192f500f264cc5713 Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Mon, 20 Apr 2026 22:01:44 +0800 Subject: [PATCH 15/38] :memo: add i18n strings for mouse settings screen Adds en/zh translations for the mouse sharing settings screen and permission dialog, and replaces hardcoded literals in MouseSettingsScreen and MousePermissionDialog with GlobalCopywriter lookups. The nav list entry in AdvancedSettingsContentView already references mouse_settings / mouse_settings_desc, which are now defined. Other locales (de/es/fa/fr/ja/ko/pt/zh_hant) still need translations; i18n_batch_update.sh expects an input file with per-locale values and was not run here because only en/zh copy is available. --- .../ui/mouse/MousePermissionDialog.kt | 9 +++++++- .../ui/mouse/MouseSettingsScreen.kt | 23 ++++++++++++++----- .../desktopMain/resources/i18n/en.properties | 10 ++++++++ .../desktopMain/resources/i18n/zh.properties | 10 ++++++++ 4 files changed, 45 insertions(+), 7 deletions(-) diff --git a/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MousePermissionDialog.kt b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MousePermissionDialog.kt index 163b5e5d2..eb433c8e7 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MousePermissionDialog.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MousePermissionDialog.kt @@ -4,7 +4,9 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import com.crosspaste.i18n.GlobalCopywriter import com.crosspaste.mouse.IpcEvent +import org.koin.compose.koinInject /** * Surfaces the most recent daemon [IpcEvent.Warning] (e.g. missing input @@ -18,10 +20,15 @@ fun MousePermissionDialog( onDismiss: () -> Unit, ) { if (warning == null) return + val copywriter = koinInject() AlertDialog( onDismissRequest = onDismiss, title = { Text(warning.code) }, text = { Text(warning.message) }, - confirmButton = { TextButton(onClick = onDismiss) { Text("OK") } }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(copywriter.getText("mouse_settings.warning_dialog.ok")) + } + }, ) } diff --git a/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MouseSettingsScreen.kt b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MouseSettingsScreen.kt index 1158360eb..b9fce66a7 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MouseSettingsScreen.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MouseSettingsScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.crosspaste.config.DesktopConfigManager +import com.crosspaste.i18n.GlobalCopywriter import com.crosspaste.mouse.IpcEvent import com.crosspaste.mouse.MouseDaemonManager import com.crosspaste.mouse.MouseState @@ -37,6 +38,7 @@ fun MouseSettingsScreen() { val manager: MouseDaemonManager = koinInject() val viewModel: ScreenArrangementViewModel = koinInject() val configManager: DesktopConfigManager = koinInject() + val copywriter = koinInject() LaunchedEffect(viewModel) { viewModel.observe() } @@ -61,7 +63,10 @@ fun MouseSettingsScreen() { verticalArrangement = Arrangement.spacedBy(AppUISize.medium), ) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - Text("Share keyboard/mouse", style = MaterialTheme.typography.titleMedium) + Text( + copywriter.getText("mouse_settings.switch"), + style = MaterialTheme.typography.titleMedium, + ) Spacer(Modifier.weight(1f)) Switch( checked = config.mouseEnabled, @@ -71,11 +76,17 @@ fun MouseSettingsScreen() { Text( text = when (val s = state) { - MouseState.Disabled -> "Off" - MouseState.Starting -> "Starting…" - is MouseState.Running -> "Running — ${s.connectedPeers.size} peer(s)" - is MouseState.Warning -> "Warning: ${s.code}" - is MouseState.Error -> "Error: ${s.message}" + MouseState.Disabled -> copywriter.getText("mouse_settings.state.disabled") + MouseState.Starting -> copywriter.getText("mouse_settings.state.starting") + is MouseState.Running -> + copywriter.getText( + "mouse_settings.state.running_n_peers", + s.connectedPeers.size, + ) + is MouseState.Warning -> + copywriter.getText("mouse_settings.state.warning_prefix", s.code) + is MouseState.Error -> + copywriter.getText("mouse_settings.state.error_prefix", s.message) }, style = MaterialTheme.typography.bodyMedium, ) diff --git a/app/src/desktopMain/resources/i18n/en.properties b/app/src/desktopMain/resources/i18n/en.properties index 0a295d01e..c889ff8eb 100644 --- a/app/src/desktopMain/resources/i18n/en.properties +++ b/app/src/desktopMain/resources/i18n/en.properties @@ -147,6 +147,16 @@ mcp_settings_desc=Model Context Protocol server settings menu=Menu missing_file=Missing File month=Month +mouse_settings=Mouse +mouse_settings.canvas.help=Drag a device to position its screens relative to yours. +mouse_settings.state.disabled=Off +mouse_settings.state.error_prefix=Error: %s +mouse_settings.state.running_n_peers=Connected: %s peer(s) +mouse_settings.state.starting=Starting… +mouse_settings.state.warning_prefix=Warning: %s +mouse_settings.switch=Share keyboard/mouse +mouse_settings.warning_dialog.ok=OK +mouse_settings_desc=Share keyboard and mouse across paired devices my_devices=My Devices nearby_device_detail=Nearby device details nearby_devices=Nearby Devices diff --git a/app/src/desktopMain/resources/i18n/zh.properties b/app/src/desktopMain/resources/i18n/zh.properties index ee4e06e8d..4ad9b464c 100644 --- a/app/src/desktopMain/resources/i18n/zh.properties +++ b/app/src/desktopMain/resources/i18n/zh.properties @@ -147,6 +147,16 @@ mcp_settings_desc=Model Context Protocol 服务器设置 menu=菜单 missing_file=丢失文件 month=月 +mouse_settings=鼠标共享 +mouse_settings.canvas.help=拖拽设备以调整它相对于本机的屏幕位置。 +mouse_settings.state.disabled=已关闭 +mouse_settings.state.error_prefix=错误:%s +mouse_settings.state.running_n_peers=已连接 %s 台设备 +mouse_settings.state.starting=启动中… +mouse_settings.state.warning_prefix=警告:%s +mouse_settings.switch=共享键盘与鼠标 +mouse_settings.warning_dialog.ok=确定 +mouse_settings_desc=在已配对设备间共享键盘与鼠标 my_devices=我的设备 nearby_device_detail=附近设备详情 nearby_devices=附近的设备 From b597d435ad386bba504f36fa9f3b88dcba293e4f Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Mon, 20 Apr 2026 22:03:49 +0800 Subject: [PATCH 16/38] :bug: swallow expected InterruptedIOException in MouseDaemonProcess reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coroutine cancellation via runInterruptible interrupts readLine(), which throws InterruptedIOException. Without catching it, the exception leaks onto the JVM's uncaught-exception queue and breaks adjacent runTest tests with UncaughtExceptionsBeforeTest — surfaced as a flake when MouseLayoutStoreTest or ScreenArrangementViewModelTest ran after MouseDaemonProcessTest in the same JVM fork. --- .../crosspaste/mouse/MouseDaemonProcess.kt | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt index f122d9432..107d200b2 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt @@ -21,6 +21,7 @@ import java.io.BufferedWriter import java.io.File import java.io.InputStream import java.io.InputStreamReader +import java.io.InterruptedIOException import java.io.OutputStream import java.io.OutputStreamWriter import java.nio.charset.StandardCharsets @@ -70,17 +71,24 @@ class MouseDaemonProcess internal constructor( runInterruptible(Dispatchers.IO) { BufferedReader(InputStreamReader(stdout, StandardCharsets.UTF_8)).use { reader -> // EOF is the only way out under normal flow; daemon restarts - // the session in-place via `stopped` events. - while (true) { - val line = reader.readLine() ?: break - if (line.isBlank()) continue - val event = - runCatching { - MouseIpcProtocol.json.decodeFromString(IpcEvent.serializer(), line) - }.getOrElse { - IpcEvent.Error("malformed daemon output: ${it.message}") - } - _events.tryEmit(event) + // the session in-place via `stopped` events. close() interrupts + // readLine() via Thread.interrupt(); swallow the resulting + // InterruptedIOException so it doesn't leak onto the JVM's + // uncaught-exception queue (which confuses adjacent tests). + try { + while (true) { + val line = reader.readLine() ?: break + if (line.isBlank()) continue + val event = + runCatching { + MouseIpcProtocol.json.decodeFromString(IpcEvent.serializer(), line) + }.getOrElse { + IpcEvent.Error("malformed daemon output: ${it.message}") + } + _events.tryEmit(event) + } + } catch (_: InterruptedIOException) { + // expected on close() } } } From 4d86e457a00fcec4fce87781a130b208612b6433 Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Mon, 20 Apr 2026 22:11:56 +0800 Subject: [PATCH 17/38] :memo: add implementation plan for crosspaste-mouse plugin integration --- ...2026-04-20-crosspaste-mouse-integration.md | 2184 +++++++++++++++++ 1 file changed, 2184 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-20-crosspaste-mouse-integration.md diff --git a/docs/superpowers/plans/2026-04-20-crosspaste-mouse-integration.md b/docs/superpowers/plans/2026-04-20-crosspaste-mouse-integration.md new file mode 100644 index 000000000..d07041a57 --- /dev/null +++ b/docs/superpowers/plans/2026-04-20-crosspaste-mouse-integration.md @@ -0,0 +1,2184 @@ +# crosspaste-mouse Plugin Integration — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Integrate the `crosspaste-mouse` Rust daemon as a plugin subprocess of crosspaste-desktop, so users can share one keyboard/mouse across paired devices with a visual drag-to-arrange UI. + +**Architecture:** crosspaste-desktop spawns `crosspaste-mouse plugin` as a child process, speaks the documented JSON Lines IPC protocol (see `~/crosspaste-mouse/ai/docs/PLUGIN_PROTOCOL.md`) over stdin/stdout. Peers are derived from `SyncRuntimeInfo` (already-paired devices) — no cert fingerprint is sent; trust boundary is "desktop told us this peer address is valid." A Compose Canvas lets the user drag remote device screens in a virtual layout, producing per-device `Position(x, y)` offsets that are pushed down via `update_layout` (or `stop`+`start` fallback while daemon ticket #9 is pending). + +**Tech Stack:** +- **Desktop side:** Kotlin (`desktopMain`), `kotlinx.serialization` for JSON Lines, Compose Multiplatform, Koin DI, kotlinx.coroutines, MockK for tests. +- **Daemon side:** existing Rust binary at `~/crosspaste-mouse/target/release/crosspaste-mouse` (built externally for MVP; bundling is out of scope). +- **Test runner:** `./gradlew app:desktopTest` (NOT `app:test` — see `CLAUDE.md`/memory). + +**Scope:** +- In: spawning daemon, command/event plumbing, peer mapping from `SyncRuntimeInfo`, settings UI with arrangement canvas, permission-warning UX, persisting per-device `Position`. +- Out: bundling/building the Rust binary into the installer (dev-mode users point at their cargo build via env var; production bundling is a follow-up plan), daemon-side tickets #8/#9/#10-#13 (we work around them), mobile platforms (desktop-only feature). + +**Non-negotiable decisions (from product owner):** +- **No cert fingerprint.** `IpcPeer.fingerprint` is never set. The `SkipServerVerification` that the daemon already does is acceptable because crosspaste-desktop has already vetted the peer address via its own pairing flow. +- Code lives in `desktopMain`, not `commonMain` — mobile will never run this daemon. +- Conform to `AppUISize` (no inline dp literals) and prefer `io.ktor.util.collections` over JDK concurrent collections (`CLAUDE.md`). + +--- + +## File Structure + +**New files (desktopMain):** +``` +app/src/desktopMain/kotlin/com/crosspaste/mouse/ + MouseDaemonBinary.kt — binary discovery (system prop → env → bundled → PATH) + MouseIpcProtocol.kt — sealed IpcCommand / IpcEvent + data classes + MouseDaemonProcess.kt — Process wrapper: stdin writes, stdout line → event Flow + MouseDaemonClient.kt — high-level API: start/stop/updateLayout/getStatus + capabilities + MouseLayoutStore.kt — persist Map in DesktopAppConfig + MousePeerMapper.kt — SyncRuntimeInfo → IpcPeer (no fingerprint) + MouseDaemonManager.kt — Koin lifecycle owner; watches config + paired devices + +app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ + MouseSettingsScreen.kt — top-level Compose screen (toggle + peer list + canvas) + ScreenArrangementViewModel.kt — VM backing the canvas + ScreenCanvas.kt — drag-to-arrange Compose Canvas + MousePermissionDialog.kt — reacts to IpcEvent.Warning +``` + +**New tests (desktopTest):** +``` +app/src/desktopTest/kotlin/com/crosspaste/mouse/ + MouseIpcProtocolTest.kt + MouseDaemonProcessTest.kt — uses injected streams (no real process) + MouseDaemonClientTest.kt — uses fake MouseDaemonProcess + MousePeerMapperTest.kt + MouseLayoutStoreTest.kt + MouseDaemonManagerTest.kt +``` + +**Modified files:** +- `app/src/desktopMain/kotlin/com/crosspaste/DesktopAppModule.kt` — register `mouseModule()` +- `app/src/desktopMain/kotlin/com/crosspaste/config/DesktopAppConfig.kt` — add `mouseEnabled: Boolean` + `mouseListenPort: Int` +- `app/src/commonMain/kotlin/com/crosspaste/ui/Route.kt` — add `MouseSettings` route +- `app/src/commonMain/kotlin/com/crosspaste/ui/settings/Settings.kt` — add nav entry +- `app/src/desktopMain/resources/i18n/en.properties` and `zh.properties` (others via `i18n_batch_update.sh`) + +--- + +## Phase 1 — IPC Protocol Types + +Goal: pure data layer — command/event sealed hierarchies + JSON Lines round-trip. No process, no IO. + +### Task 1: Command / event / shared types + +**Files:** +- Create: `app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseIpcProtocol.kt` +- Test: `app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseIpcProtocolTest.kt` + +- [ ] **Step 1: Write failing serialization tests** + +```kotlin +// MouseIpcProtocolTest.kt +package com.crosspaste.mouse + +import kotlinx.serialization.encodeToString +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class MouseIpcProtocolTest { + private val json = MouseIpcProtocol.json + + @Test + fun `encode Start command with v2 peer`() { + val cmd: IpcCommand = IpcCommand.Start( + port = 4243, + peers = listOf( + IpcPeer( + name = "Desktop", + address = "192.168.1.10:4243", + position = Position(1920, 0), + deviceId = "uuid-a", + // fingerprint INTENTIONALLY omitted — product decision + ), + ), + ) + val out = json.encodeToString(IpcCommand.serializer(), cmd) + assertTrue(out.contains(""""cmd":"start"""")) + assertTrue(out.contains(""""port":4243""")) + assertTrue(out.contains(""""device_id":"uuid-a"""")) + assertTrue(!out.contains("fingerprint")) + } + + @Test + fun `decode Ready event from v1 daemon`() { + val line = """{"event":"ready","screens":[],"port":4243}""" + val ev = json.decodeFromString(IpcEvent.serializer(), line) + assertTrue(ev is IpcEvent.Ready) + assertEquals(4243, ev.port) + } + + @Test + fun `decode Initialized event from v2 daemon`() { + val line = """{"event":"initialized","screens":[],"protocol_version":2}""" + val ev = json.decodeFromString(IpcEvent.serializer(), line) + assertTrue(ev is IpcEvent.Initialized) + assertEquals(2, ev.protocolVersion) + } + + @Test + fun `decode PeerScreensLearned with screens`() { + val line = + """{"event":"peer_screens_learned","device_id":"d1","screens":[""" + + """{"id":1,"width":2560,"height":1440,"x":0,"y":0,"scale_factor":2.0,"is_primary":true}]}""" + val ev = json.decodeFromString(IpcEvent.serializer(), line) + assertTrue(ev is IpcEvent.PeerScreensLearned) + assertEquals("d1", ev.deviceId) + assertEquals(1, ev.screens.size) + assertEquals(2560, ev.screens[0].width) + } + + @Test + fun `decode Warning event preserves code`() { + val line = """{"event":"warning","code":"macos_accessibility","message":"grant access"}""" + val ev = json.decodeFromString(IpcEvent.serializer(), line) + assertTrue(ev is IpcEvent.Warning) + assertEquals("macos_accessibility", ev.code) + } + + @Test + fun `unknown event variant fails loudly`() { + val line = """{"event":"made_up_event"}""" + runCatching { json.decodeFromString(IpcEvent.serializer(), line) } + .onSuccess { error("should have failed, got $it") } + } +} +``` + +- [ ] **Step 2: Run tests — expect compile errors (types don't exist yet)** + +Run: `./gradlew app:desktopTest --tests "com.crosspaste.mouse.MouseIpcProtocolTest"` +Expected: compilation failure. + +- [ ] **Step 3: Implement the protocol types** + +```kotlin +// MouseIpcProtocol.kt +package com.crosspaste.mouse + +import kotlinx.serialization.EncodeDefault +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonClassDiscriminator + +object MouseIpcProtocol { + val json: Json = + Json { + ignoreUnknownKeys = true + encodeDefaults = true + classDiscriminator = "__unused__" // overridden per hierarchy via @JsonClassDiscriminator + } +} + +@Serializable +data class Position( + val x: Int, + val y: Int, +) + +@Serializable +data class ScreenInfo( + val id: Int, + val width: Int, + val height: Int, + val x: Int, + val y: Int, + @SerialName("scale_factor") val scaleFactor: Double, + @SerialName("is_primary") val isPrimary: Boolean, +) + +@Serializable +data class IpcPeer( + val name: String, + val address: String, + val position: Position, + // v2 additions — all optional for back-compat + @SerialName("device_id") val deviceId: String? = null, + // fingerprint intentionally NOT exposed — desktop never sends it. + // (Remote-cached screens aren't useful to us; omitted too.) +) + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +@JsonClassDiscriminator("cmd") +sealed class IpcCommand { + + @Serializable + @SerialName("start") + data class Start( + val port: Int, + val peers: List, + ) : IpcCommand() + + @Serializable + @SerialName("stop") + object Stop : IpcCommand() + + @Serializable + @SerialName("update_layout") + data class UpdateLayout( + val peers: List, + ) : IpcCommand() + + @Serializable + @SerialName("get_status") + object GetStatus : IpcCommand() + + @Serializable + @SerialName("enumerate_local_screens") + object EnumerateLocalScreens : IpcCommand() + + @Serializable + @SerialName("get_capabilities") + object GetCapabilities : IpcCommand() +} + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +@JsonClassDiscriminator("event") +sealed class IpcEvent { + + @Serializable + @SerialName("initialized") + data class Initialized( + val screens: List, + @SerialName("protocol_version") @EncodeDefault val protocolVersion: Int, + ) : IpcEvent() + + @Serializable + @SerialName("ready") + data class Ready( + val screens: List, + val port: Int, + ) : IpcEvent() + + @Serializable + @SerialName("session_started") + data class SessionStarted( + val port: Int, + @SerialName("local_device_id") val localDeviceId: String, + ) : IpcEvent() + + @Serializable + @SerialName("peer_connected") + data class PeerConnected( + val name: String, + @SerialName("device_id") val deviceId: String, + ) : IpcEvent() + + @Serializable + @SerialName("peer_screens_learned") + data class PeerScreensLearned( + @SerialName("device_id") val deviceId: String, + val screens: List, + ) : IpcEvent() + + @Serializable + @SerialName("peer_disconnected") + data class PeerDisconnected( + val name: String, + val reason: String, + ) : IpcEvent() + + @Serializable + @SerialName("mode_changed") + data class ModeChanged( + val mode: String, + val target: String? = null, + ) : IpcEvent() + + @Serializable + @SerialName("status") + data class Status( + val running: Boolean, + val mode: String, + @SerialName("connected_peers") val connectedPeers: List, + ) : IpcEvent() + + @Serializable + @SerialName("local_screens") + data class LocalScreens( + val screens: List, + ) : IpcEvent() + + @Serializable + @SerialName("capabilities") + data class Capabilities( + @SerialName("protocol_version") val protocolVersion: Int, + val features: List, + ) : IpcEvent() + + @Serializable + @SerialName("warning") + data class Warning( + val code: String, + val message: String, + ) : IpcEvent() + + @Serializable + @SerialName("license_status") + data class LicenseStatus( + val status: String, + val message: String, + @SerialName("trial_remaining_secs") val trialRemainingSecs: Long? = null, + ) : IpcEvent() + + @Serializable + @SerialName("error") + data class Error( + val message: String, + ) : IpcEvent() + + @Serializable + @SerialName("stopped") + object Stopped : IpcEvent() +} +``` + +- [ ] **Step 4: Run tests — expect all PASS** + +Run: `./gradlew app:desktopTest --tests "com.crosspaste.mouse.MouseIpcProtocolTest"` +Expected: 5 passing tests. If `Json.classDiscriminator` interaction warns, the per-hierarchy `@JsonClassDiscriminator` annotation takes precedence — the root-level `classDiscriminator` is a fallback for non-annotated hierarchies. + +- [ ] **Step 5: Format + commit** + +```bash +./gradlew ktlintFormat +git add app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseIpcProtocol.kt \ + app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseIpcProtocolTest.kt +git commit -m ":sparkles: add MouseIpcProtocol types for crosspaste-mouse plugin IPC" +``` + +--- + +## Phase 2 — Process + Stream Layer + +Goal: spawn the daemon binary, expose `events: Flow` and `suspend fun send(command)`. Tests drive `InputStream`/`OutputStream` directly — no real subprocess. + +### Task 2: Binary discovery + +**Files:** +- Create: `app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonBinary.kt` +- Test: `app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonBinaryTest.kt` + +- [ ] **Step 1: Write failing tests** + +```kotlin +// MouseDaemonBinaryTest.kt +package com.crosspaste.mouse + +import java.nio.file.Files +import kotlin.io.path.absolutePathString +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class MouseDaemonBinaryTest { + private val propKey = "crosspaste.mouse.binary" + private val savedProp = System.getProperty(propKey) + + @AfterTest + fun restore() { + if (savedProp != null) System.setProperty(propKey, savedProp) else System.clearProperty(propKey) + } + + @Test + fun `system property wins when set and file exists`() { + val file = Files.createTempFile("fake-daemon", "").toFile().apply { deleteOnExit() } + System.setProperty(propKey, file.absolutePath) + assertEquals(file.absolutePath, MouseDaemonBinary.resolve(envLookup = { null })?.absolutePath) + } + + @Test + fun `env var used when system property absent`() { + System.clearProperty(propKey) + val file = Files.createTempFile("fake-daemon", "").toFile().apply { deleteOnExit() } + val path = MouseDaemonBinary.resolve(envLookup = { if (it == "CROSSPASTE_MOUSE_BIN") file.absolutePath else null }) + assertEquals(file.absolutePath, path?.absolutePath) + } + + @Test + fun `returns null when nothing resolves`() { + System.clearProperty(propKey) + assertNull(MouseDaemonBinary.resolve(envLookup = { null }, candidatePaths = emptyList())) + } + + @Test + fun `system property pointing at missing file is ignored`() { + System.setProperty(propKey, "/definitely/does/not/exist/mouse") + assertNull(MouseDaemonBinary.resolve(envLookup = { null }, candidatePaths = emptyList())) + } +} +``` + +- [ ] **Step 2: Run tests — expect compile failure** + +Run: `./gradlew app:desktopTest --tests "com.crosspaste.mouse.MouseDaemonBinaryTest"` + +- [ ] **Step 3: Implement binary discovery** + +```kotlin +// MouseDaemonBinary.kt +package com.crosspaste.mouse + +import java.io.File + +object MouseDaemonBinary { + private const val SYSTEM_PROPERTY = "crosspaste.mouse.binary" + private const val ENV_VAR = "CROSSPASTE_MOUSE_BIN" + + /** + * Resolution order: + * 1. -Dcrosspaste.mouse.binary= + * 2. $CROSSPASTE_MOUSE_BIN + * 3. Any path in `candidatePaths` (e.g. bundled in resources/bin/...) + * Returns null if nothing points at an existing regular file. + */ + fun resolve( + envLookup: (String) -> String? = System::getenv, + candidatePaths: List = defaultCandidatePaths(), + ): File? { + System.getProperty(SYSTEM_PROPERTY)?.let { File(it).takeIf { f -> f.isFile } }?.let { return it } + envLookup(ENV_VAR)?.let { File(it).takeIf { f -> f.isFile } }?.let { return it } + return candidatePaths.asSequence() + .map(::File) + .firstOrNull { it.isFile } + } + + private fun defaultCandidatePaths(): List { + // Production bundling is a follow-up plan; for now the only + // candidate is a dev-mode symlink next to the app jar. + val home = System.getProperty("user.home") ?: return emptyList() + return listOf("$home/crosspaste-mouse/target/release/crosspaste-mouse") + } +} +``` + +- [ ] **Step 4: Run tests — expect all PASS** + +Run: `./gradlew app:desktopTest --tests "com.crosspaste.mouse.MouseDaemonBinaryTest"` + +- [ ] **Step 5: Format + commit** + +```bash +./gradlew ktlintFormat +git add app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonBinary.kt \ + app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonBinaryTest.kt +git commit -m ":sparkles: add MouseDaemonBinary resolver with env/sysprop/bundled fallback" +``` + +### Task 3: Daemon process with injected streams + +**Files:** +- Create: `app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt` +- Test: `app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonProcessTest.kt` + +Design: `MouseDaemonProcess` is constructed from already-opened input/output streams (so tests inject `PipedInputStream`/`PipedOutputStream`). The subprocess bootstrapping is a separate factory function `MouseDaemonProcess.spawn(binary)`. + +- [ ] **Step 1: Write failing tests** + +```kotlin +// MouseDaemonProcessTest.kt +package com.crosspaste.mouse + +import java.io.PipedInputStream +import java.io.PipedOutputStream +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout + +class MouseDaemonProcessTest { + + @Test + fun `reads one event per stdout line and parses it`() = runTest { + val daemonStdoutSink = PipedOutputStream() + val daemonStdoutSource = PipedInputStream(daemonStdoutSink) + val daemonStdinSink = PipedOutputStream() + // Host never reads stdin (the daemon does); we only need to see what was written. + + val process = MouseDaemonProcess( + stdout = daemonStdoutSource, + stdin = daemonStdinSink, + onClose = {}, + ) + + // Write one line as if daemon emitted it + daemonStdoutSink.write("""{"event":"ready","screens":[],"port":4243}""".toByteArray()) + daemonStdoutSink.write('\n'.code) + daemonStdoutSink.flush() + + val ev = withTimeout(2000) { process.events.first() } + assertTrue(ev is IpcEvent.Ready) + assertEquals(4243, ev.port) + + process.close() + } + + @Test + fun `send writes a line-terminated JSON command to stdin`() = runTest { + val daemonStdinSink = java.io.ByteArrayOutputStream() + val process = MouseDaemonProcess( + stdout = java.io.ByteArrayInputStream(ByteArray(0)), + stdin = daemonStdinSink, + onClose = {}, + ) + + process.send(IpcCommand.GetStatus) + val written = daemonStdinSink.toString(Charsets.UTF_8) + assertTrue(written.endsWith("\n"), "expected newline terminator, got: $written") + assertTrue(written.contains(""""cmd":"get_status"""")) + + process.close() + } + + @Test + fun `malformed line emits Error event but keeps stream alive`() = runTest { + val sink = PipedOutputStream() + val source = PipedInputStream(sink) + val process = MouseDaemonProcess( + stdout = source, + stdin = java.io.ByteArrayOutputStream(), + onClose = {}, + ) + sink.write("not json\n".toByteArray()) + sink.write("""{"event":"stopped"}""".toByteArray()) + sink.write('\n'.code) + sink.flush() + + val collected = mutableListOf() + withTimeout(2000) { + process.events.collect { + collected.add(it) + if (it is IpcEvent.Stopped) return@collect + } + } + assertTrue(collected.any { it is IpcEvent.Error }) + assertTrue(collected.any { it is IpcEvent.Stopped }) + process.close() + } +} +``` + +- [ ] **Step 2: Run tests — compile failure expected** + +Run: `./gradlew app:desktopTest --tests "com.crosspaste.mouse.MouseDaemonProcessTest"` + +- [ ] **Step 3: Implement the process wrapper** + +```kotlin +// MouseDaemonProcess.kt +package com.crosspaste.mouse + +import java.io.BufferedReader +import java.io.BufferedWriter +import java.io.File +import java.io.InputStream +import java.io.InputStreamReader +import java.io.OutputStream +import java.io.OutputStreamWriter +import java.nio.charset.StandardCharsets +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString + +class MouseDaemonProcess internal constructor( + stdout: InputStream, + stdin: OutputStream, + private val onClose: () -> Unit, +) : AutoCloseable { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val writer: BufferedWriter = + BufferedWriter(OutputStreamWriter(stdin, StandardCharsets.UTF_8)) + private val writeLock = Mutex() + + private val _events = + MutableSharedFlow( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val events: SharedFlow = _events.asSharedFlow() + + private val readerJob: Job = + scope.launch { + BufferedReader(InputStreamReader(stdout, StandardCharsets.UTF_8)).use { reader -> + while (true) { + val line = reader.readLine() ?: break + if (line.isBlank()) continue + val event = + runCatching { + MouseIpcProtocol.json.decodeFromString(IpcEvent.serializer(), line) + }.getOrElse { + IpcEvent.Error("malformed daemon output: ${it.message}") + } + _events.emit(event) + if (event is IpcEvent.Stopped) { + // keep reading — daemon may restart session; only exit on EOF. + } + } + } + } + + suspend fun send(command: IpcCommand) { + val line = MouseIpcProtocol.json.encodeToString(IpcCommand.serializer(), command) + writeLock.withLock { + withContext(Dispatchers.IO) { + writer.write(line) + writer.write("\n") + writer.flush() + } + } + } + + override fun close() { + scope.cancel() + runCatching { writer.close() } + onClose() + } + + companion object { + /** + * Spawns the daemon binary in plugin mode. Throws [IllegalStateException] + * if the binary cannot be located. + */ + fun spawn(binary: File): MouseDaemonProcess { + val process = + ProcessBuilder(binary.absolutePath, "plugin") + .redirectErrorStream(false) // keep stderr separate (daemon tracing logs go there) + .start() + return MouseDaemonProcess( + stdout = process.inputStream, + stdin = process.outputStream, + onClose = { + runCatching { process.destroy() } + runCatching { process.waitFor() } + }, + ) + } + } +} +``` + +- [ ] **Step 4: Run tests — expect all PASS** + +Run: `./gradlew app:desktopTest --tests "com.crosspaste.mouse.MouseDaemonProcessTest"` + +- [ ] **Step 5: Format + commit** + +```bash +./gradlew ktlintFormat +git add app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt \ + app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonProcessTest.kt +git commit -m ":sparkles: add MouseDaemonProcess wrapper with stdin/stdout JSON Lines" +``` + +--- + +## Phase 3 — High-level Client + Layout Persistence + Peer Mapping + +### Task 4: Peer mapping (SyncRuntimeInfo → IpcPeer) + +**Files:** +- Create: `app/src/desktopMain/kotlin/com/crosspaste/mouse/MousePeerMapper.kt` +- Test: `app/src/desktopTest/kotlin/com/crosspaste/mouse/MousePeerMapperTest.kt` + +Trust model (non-negotiable): fingerprint is NEVER set. The daemon runs with `SkipServerVerification`; desktop is the trust authority via its existing pairing flow. + +- [ ] **Step 1: Write failing tests** + +```kotlin +// MousePeerMapperTest.kt +package com.crosspaste.mouse + +import com.crosspaste.db.sync.HostInfo +import com.crosspaste.db.sync.SyncRuntimeInfo +import com.crosspaste.platform.Platform +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class MousePeerMapperTest { + private fun sri( + appInstanceId: String, + deviceId: String, + deviceName: String, + host: String?, + port: Int = 4243, + ) = SyncRuntimeInfo( + appInstanceId = appInstanceId, + appVersion = "x", + userName = "u", + deviceId = deviceId, + deviceName = deviceName, + platform = Platform(name = "Mac", arch = "arm64", bitMode = 64, version = "14"), + hostInfoList = host?.let { listOf(HostInfo(hostAddress = it, networkPrefixLength = 24)) } ?: emptyList(), + port = port, + connectHostAddress = host, + ) + + @Test + fun `maps paired device with known address to IpcPeer without fingerprint`() { + val layout = mapOf("inst-1" to Position(1920, 0)) + val peers = MousePeerMapper.map( + syncs = listOf(sri("inst-1", "dev-1", "Desktop", "192.168.1.10", 4243)), + layout = layout, + ) + assertEquals(1, peers.size) + val p = peers[0] + assertEquals("Desktop", p.name) + assertEquals("192.168.1.10:4243", p.address) + assertEquals(Position(1920, 0), p.position) + assertEquals("inst-1", p.deviceId) + // Fingerprint must stay null per product decision. + assertNull(runCatching { IpcPeer::class.java.getDeclaredField("fingerprint") }.getOrNull()) + } + + @Test + fun `drops devices without a connect host address`() { + val peers = MousePeerMapper.map( + syncs = listOf(sri("inst-1", "dev-1", "Desktop", host = null)), + layout = mapOf("inst-1" to Position(0, 0)), + ) + assertTrue(peers.isEmpty()) + } + + @Test + fun `drops devices missing layout entry (not configured yet)`() { + val peers = MousePeerMapper.map( + syncs = listOf(sri("inst-1", "dev-1", "Desktop", "192.168.1.10")), + layout = emptyMap(), + ) + assertTrue(peers.isEmpty()) + } + + @Test + fun `key is appInstanceId — matches what desktop uses elsewhere`() { + val peers = MousePeerMapper.map( + syncs = listOf(sri("inst-abc", "dev-xyz", "Desktop", "192.168.1.10")), + layout = mapOf("inst-abc" to Position(0, 1080)), + ) + assertEquals("inst-abc", peers.single().deviceId) + } +} +``` + +- [ ] **Step 2: Run tests — expect compile failure** + +Run: `./gradlew app:desktopTest --tests "com.crosspaste.mouse.MousePeerMapperTest"` + +- [ ] **Step 3: Implement the mapper** + +```kotlin +// MousePeerMapper.kt +package com.crosspaste.mouse + +import com.crosspaste.db.sync.SyncRuntimeInfo + +object MousePeerMapper { + + /** + * Convert paired-device rows into daemon peers. + * - Drops devices with no reachable host address (still sync-negotiating). + * - Drops devices the user has not yet placed in the arrangement canvas. + * - Uses [SyncRuntimeInfo.appInstanceId] as the peer key (daemon's `device_id`), + * matching what MouseLayoutStore uses. + * - Never sets fingerprint (product decision: desktop is the trust authority). + */ + fun map( + syncs: List, + layout: Map, + ): List { + return syncs.mapNotNull { sri -> + val host = sri.connectHostAddress ?: return@mapNotNull null + val position = layout[sri.appInstanceId] ?: return@mapNotNull null + IpcPeer( + name = sri.deviceName.ifBlank { sri.appInstanceId }, + address = "$host:${sri.port}", + position = position, + deviceId = sri.appInstanceId, + ) + } + } +} +``` + +- [ ] **Step 4: Run tests — expect all PASS** + +Run: `./gradlew app:desktopTest --tests "com.crosspaste.mouse.MousePeerMapperTest"` + +- [ ] **Step 5: Commit** + +```bash +./gradlew ktlintFormat +git add app/src/desktopMain/kotlin/com/crosspaste/mouse/MousePeerMapper.kt \ + app/src/desktopTest/kotlin/com/crosspaste/mouse/MousePeerMapperTest.kt +git commit -m ":sparkles: map paired SyncRuntimeInfo rows to mouse daemon peers" +``` + +### Task 5: Layout store (persist `Map`) + +**Files:** +- Create: `app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseLayoutStore.kt` +- Test: `app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseLayoutStoreTest.kt` +- Modify: `app/src/desktopMain/kotlin/com/crosspaste/config/DesktopAppConfig.kt` (add `mouseLayout: Map` + `mouseEnabled: Boolean` + `mouseListenPort: Int` fields with sensible defaults). + +Implementation uses `DesktopConfigManager` the same way other desktop-scoped config does — check existing `DesktopAppConfig` for the pattern and follow it exactly. If `Position` can't be stored directly in that config (e.g. because it relies on `@Serializable` set up differently), wrap it in a config-local `SerializedPosition(xLocal: Int, yLocal: Int)` and convert at the store boundary. + +- [ ] **Step 1: Read existing config pattern** + +Read `app/src/desktopMain/kotlin/com/crosspaste/config/DesktopAppConfig.kt` and `DesktopConfigManager.kt` to understand: +- how fields are added (check at least 2 existing boolean + numeric + collection fields) +- how `@Serializable` is set up on the config class +- whether mutation goes through `update { ... }` or direct assignment + save + +Record the observed pattern — Tasks 5 and 10 both rely on it. + +- [ ] **Step 2: Write failing tests for the store** + +```kotlin +// MouseLayoutStoreTest.kt +package com.crosspaste.mouse + +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlin.test.Test +import kotlin.test.assertEquals + +class MouseLayoutStoreTest { + + @Test + fun `returns empty map when config has no entries`() { + val cfg = FakeConfig(mutableMapOf()) + val store = MouseLayoutStore(cfg) + assertEquals(emptyMap(), store.all()) + } + + @Test + fun `upsert writes a single device position`() { + val cfg = FakeConfig(mutableMapOf()) + val store = MouseLayoutStore(cfg) + store.upsert("inst-1", Position(1920, 0)) + assertEquals(Position(1920, 0), store.get("inst-1")) + } + + @Test + fun `remove deletes an entry`() { + val cfg = FakeConfig(mutableMapOf("inst-1" to Position(0, 0))) + val store = MouseLayoutStore(cfg) + store.remove("inst-1") + assertEquals(emptyMap(), store.all()) + } + + @Test + fun `flow emits after upsert`() = kotlinx.coroutines.test.runTest { + val cfg = FakeConfig(mutableMapOf()) + val store = MouseLayoutStore(cfg) + val updates = mutableListOf>() + val job = kotlinx.coroutines.launch { store.flow().collect { updates.add(it) } } + kotlinx.coroutines.yield() + store.upsert("inst-1", Position(1920, 0)) + kotlinx.coroutines.yield() + job.cancel() + assertEquals(Position(1920, 0), updates.last()["inst-1"]) + } + + // Fake config stand-in that matches the store's expected interface. + private class FakeConfig(val backing: MutableMap) : MouseLayoutStore.Backing { + private val flow = kotlinx.coroutines.flow.MutableStateFlow(backing.toMap()) + override fun snapshot() = backing.toMap() + override fun set(newMap: Map) { backing.clear(); backing.putAll(newMap); flow.value = snapshot() } + override fun flow() = flow + } +} +``` + +- [ ] **Step 3: Implement MouseLayoutStore with a `Backing` interface** + +Keep the store decoupled from `DesktopAppConfig` through a tiny adapter interface, so tests can substitute a fake. Real binding happens in the Koin module (Task 10). + +```kotlin +// MouseLayoutStore.kt +package com.crosspaste.mouse + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class MouseLayoutStore(private val backing: Backing) { + + interface Backing { + fun snapshot(): Map + fun set(newMap: Map) + fun flow(): MutableStateFlow> + } + + fun all(): Map = backing.snapshot() + fun get(deviceId: String): Position? = backing.snapshot()[deviceId] + + fun upsert(deviceId: String, position: Position) { + val next = backing.snapshot().toMutableMap().apply { put(deviceId, position) } + backing.set(next) + } + + fun remove(deviceId: String) { + val next = backing.snapshot().toMutableMap().apply { remove(deviceId) } + backing.set(next) + } + + fun flow(): Flow> = backing.flow() +} +``` + +- [ ] **Step 4: Run tests — expect all PASS** + +Run: `./gradlew app:desktopTest --tests "com.crosspaste.mouse.MouseLayoutStoreTest"` + +- [ ] **Step 5: Wire DesktopAppConfig fields (no store-side tests yet — integration test in Task 10)** + +Add to `DesktopAppConfig`: +```kotlin +val mouseEnabled: Boolean = false, +val mouseListenPort: Int = 4243, +val mouseLayout: Map = emptyMap(), +``` + +Be sure `Position` is the one from `com.crosspaste.mouse` and that its `@Serializable` annotation is present. If the existing config serializer doesn't walk imported types, add a `typealias` or re-declare locally using `@SerialName("mouse_layout")` — pattern documented in the existing module. + +- [ ] **Step 6: Commit** + +```bash +./gradlew ktlintFormat +git add app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseLayoutStore.kt \ + app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseLayoutStoreTest.kt \ + app/src/desktopMain/kotlin/com/crosspaste/config/DesktopAppConfig.kt +git commit -m ":sparkles: persist mouse arrangement layout in DesktopAppConfig" +``` + +### Task 6: MouseDaemonClient (capabilities + start/stop/updateLayout) + +**Files:** +- Create: `app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonClient.kt` +- Test: `app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonClientTest.kt` + +Design: `MouseDaemonClient` wraps a `MouseDaemonProcess`. Exposes suspend APIs that serialize a send and (where applicable) wait for a correlated response event. For `updateLayout`: +- If daemon capabilities include `"update_layout"` AND daemon returned `protocol_version >= 2` → send `UpdateLayout`. +- Otherwise → fall back to `stop` → `start`. This is the current reality (daemon's v2 still returns `Error { "update_layout not yet supported" }`). + +- [ ] **Step 1: Write failing tests** + +```kotlin +// MouseDaemonClientTest.kt +package com.crosspaste.mouse + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class MouseDaemonClientTest { + + private class FakeProcess : MouseDaemonClient.DaemonHandle { + val sent = mutableListOf() + private val _events = MutableSharedFlow(extraBufferCapacity = 32) + override val events: SharedFlow = _events.asSharedFlow() + override suspend fun send(command: IpcCommand) { sent.add(command) } + override fun close() {} + suspend fun emit(ev: IpcEvent) { _events.emit(ev) } + } + + @Test + fun `negotiates capabilities on startup and records features`() = runTest { + val proc = FakeProcess() + val client = MouseDaemonClient(proc) + val job = launch { client.run() } + yield() + proc.emit(IpcEvent.Initialized(screens = emptyList(), protocolVersion = 2)) + yield() + // client should have asked for capabilities + assertTrue(proc.sent.any { it is IpcCommand.GetCapabilities }) + proc.emit( + IpcEvent.Capabilities( + protocolVersion = 2, + features = listOf("get_capabilities", "enumerate_local_screens"), + ), + ) + yield() + assertTrue(client.capabilities.value.protocolVersion == 2) + assertTrue("enumerate_local_screens" in client.capabilities.value.features) + job.cancel() + } + + @Test + fun `updateLayout falls back to stop+start when feature unsupported`() = runTest { + val proc = FakeProcess() + val client = MouseDaemonClient(proc) + val job = launch { client.run() } + yield() + proc.emit(IpcEvent.Initialized(screens = emptyList(), protocolVersion = 2)) + proc.emit(IpcEvent.Capabilities(protocolVersion = 2, features = emptyList())) // no update_layout + yield() + + client.updateLayout(port = 4243, peers = emptyList()) + yield() + + val sentNames = proc.sent.map { it::class.simpleName } + assertEquals(listOf("GetCapabilities", "Stop", "Start"), sentNames) + job.cancel() + } + + @Test + fun `updateLayout uses native command when feature present`() = runTest { + val proc = FakeProcess() + val client = MouseDaemonClient(proc) + val job = launch { client.run() } + yield() + proc.emit(IpcEvent.Initialized(screens = emptyList(), protocolVersion = 2)) + proc.emit( + IpcEvent.Capabilities(protocolVersion = 2, features = listOf("update_layout")), + ) + yield() + + client.updateLayout(port = 4243, peers = emptyList()) + yield() + + assertTrue(proc.sent.last() is IpcCommand.UpdateLayout) + job.cancel() + } + + @Test + fun `events reach consumers as a SharedFlow`() = runTest { + val proc = FakeProcess() + val client = MouseDaemonClient(proc) + val job = launch { client.run() } + yield() + val got = launch { client.events.first { it is IpcEvent.PeerConnected } } + proc.emit(IpcEvent.PeerConnected(name = "Desktop", deviceId = "d1")) + got.join() + job.cancel() + } +} +``` + +- [ ] **Step 2: Run tests — compile failure** + +Run: `./gradlew app:desktopTest --tests "com.crosspaste.mouse.MouseDaemonClientTest"` + +- [ ] **Step 3: Implement the client** + +```kotlin +// MouseDaemonClient.kt +package com.crosspaste.mouse + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collect + +class MouseDaemonClient( + private val handle: DaemonHandle, +) { + interface DaemonHandle { + val events: SharedFlow + suspend fun send(command: IpcCommand) + fun close() + } + + data class CapabilitySnapshot( + val protocolVersion: Int = 1, + val features: List = emptyList(), + ) + + private val _capabilities = MutableStateFlow(CapabilitySnapshot()) + val capabilities: StateFlow = _capabilities.asStateFlow() + + val events: SharedFlow get() = handle.events + + /** + * Drives the daemon: asks for capabilities once Initialized/Ready lands, + * forwards caps into [capabilities]. Suspends forever; cancel to stop. + */ + suspend fun run() { + handle.events.collect { ev -> + when (ev) { + is IpcEvent.Initialized, is IpcEvent.Ready -> { + handle.send(IpcCommand.GetCapabilities) + } + is IpcEvent.Capabilities -> { + _capabilities.value = + CapabilitySnapshot(protocolVersion = ev.protocolVersion, features = ev.features) + } + else -> Unit + } + } + } + + suspend fun start(port: Int, peers: List) { + handle.send(IpcCommand.Start(port, peers)) + } + + suspend fun stop() { + handle.send(IpcCommand.Stop) + } + + /** + * Applies a new layout. If the daemon advertises `update_layout`, sends it + * natively; otherwise falls back to stop + start (current reality — daemon + * ticket #9 still pending). + */ + suspend fun updateLayout(port: Int, peers: List) { + if ("update_layout" in _capabilities.value.features) { + handle.send(IpcCommand.UpdateLayout(peers)) + } else { + handle.send(IpcCommand.Stop) + handle.send(IpcCommand.Start(port, peers)) + } + } + + suspend fun getStatus() { + handle.send(IpcCommand.GetStatus) + } + + suspend fun enumerateLocalScreens() { + handle.send(IpcCommand.EnumerateLocalScreens) + } + + fun close() { + handle.close() + } +} +``` + +Also add a thin adapter so `MouseDaemonProcess` satisfies `DaemonHandle`: + +```kotlin +// MouseDaemonProcess.kt — append +fun MouseDaemonProcess.asDaemonHandle(): MouseDaemonClient.DaemonHandle = + object : MouseDaemonClient.DaemonHandle { + override val events = this@asDaemonHandle.events + override suspend fun send(command: IpcCommand) = this@asDaemonHandle.send(command) + override fun close() = this@asDaemonHandle.close() + } +``` + +- [ ] **Step 4: Run tests — all PASS** + +Run: `./gradlew app:desktopTest --tests "com.crosspaste.mouse.MouseDaemonClientTest"` + +- [ ] **Step 5: Commit** + +```bash +./gradlew ktlintFormat +git add app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonClient.kt \ + app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt \ + app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonClientTest.kt +git commit -m ":sparkles: add MouseDaemonClient with capability negotiation and stop+start fallback" +``` + +### Task 7: MouseDaemonManager lifecycle + +**Files:** +- Create: `app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonManager.kt` +- Test: `app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonManagerTest.kt` + +Responsibilities: +- Observes: `mouseEnabled` flag, `mouseListenPort`, `MouseLayoutStore.flow()`, `syncRuntimeInfoDao.getAllSyncRuntimeInfosFlow()`. +- When enabled: spawn daemon, connect client, send `Start { port, peers }`. +- On any of (layout / paired devices / port) change while running: call `client.updateLayout(...)`. +- When disabled: `client.stop()` + close process. +- Exposes `state: StateFlow` (disabled / starting / running(peers, mode) / warning(code, message) / error(msg)). + +Because this is the bulk of the runtime behavior, we test with substituted `DaemonHandle` + `SyncRuntimeInfoDao` + `MouseLayoutStore`. + +- [ ] **Step 1: Write failing tests** + +```kotlin +// MouseDaemonManagerTest.kt +package com.crosspaste.mouse + +import com.crosspaste.db.sync.SyncRuntimeInfo +import com.crosspaste.db.sync.SyncRuntimeInfoDao +import com.crosspaste.platform.Platform +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class MouseDaemonManagerTest { + + private class FakeHandle : MouseDaemonClient.DaemonHandle { + val sent = mutableListOf() + private val _events = MutableSharedFlow(extraBufferCapacity = 32) + override val events: SharedFlow = _events.asSharedFlow() + override suspend fun send(command: IpcCommand) { sent.add(command) } + override fun close() {} + suspend fun emit(ev: IpcEvent) { _events.emit(ev) } + } + + private fun sri(inst: String, host: String?) = SyncRuntimeInfo( + appInstanceId = inst, + appVersion = "x", + userName = "u", + deviceId = "d-$inst", + deviceName = "Dev-$inst", + platform = Platform("Mac", "arm64", 64, "14"), + port = 4243, + connectHostAddress = host, + ) + + @Test + fun `does nothing when disabled`() = runTest { + val handle = FakeHandle() + val store = inMemoryStore() + val mgr = newManager(handle, store, enabled = MutableStateFlow(false)) + val job = launch { mgr.run() } + yield() + assertTrue(handle.sent.isEmpty()) + job.cancel() + } + + @Test + fun `sends Start with peers when enabled`() = runTest { + val handle = FakeHandle() + val store = inMemoryStore().apply { upsert("inst-1", Position(1920, 0)) } + val syncs = MutableStateFlow(listOf(sri("inst-1", "192.168.1.10"))) + val mgr = newManager(handle, store, syncs = syncs, enabled = MutableStateFlow(true)) + val job = launch { mgr.run() } + yield() + handle.emit(IpcEvent.Initialized(emptyList(), 2)) + handle.emit(IpcEvent.Capabilities(2, emptyList())) + yield() + val start = handle.sent.filterIsInstance().singleOrNull() + assertTrue(start != null, "expected exactly one Start, got ${handle.sent}") + assertEquals("192.168.1.10:4243", start.peers.single().address) + job.cancel() + } + + @Test + fun `layout change triggers updateLayout`() = runTest { + val handle = FakeHandle() + val store = inMemoryStore().apply { upsert("inst-1", Position(1920, 0)) } + val syncs = MutableStateFlow(listOf(sri("inst-1", "192.168.1.10"))) + val mgr = newManager(handle, store, syncs = syncs, enabled = MutableStateFlow(true)) + val job = launch { mgr.run() } + yield() + handle.emit(IpcEvent.Initialized(emptyList(), 2)) + handle.emit(IpcEvent.Capabilities(2, emptyList())) // no update_layout → stop+start + yield() + store.upsert("inst-1", Position(-1920, 0)) // user dragged peer to the left + yield() + // Stop+start fallback expected: [Start, Stop, Start] + val starts = handle.sent.filterIsInstance() + assertEquals(2, starts.size) + assertEquals(Position(-1920, 0), starts.last().peers.single().position) + job.cancel() + } + + @Test + fun `disable sends Stop`() = runTest { + val handle = FakeHandle() + val store = inMemoryStore().apply { upsert("inst-1", Position(1920, 0)) } + val syncs = MutableStateFlow(listOf(sri("inst-1", "192.168.1.10"))) + val enabled = MutableStateFlow(true) + val mgr = newManager(handle, store, syncs = syncs, enabled = enabled) + val job = launch { mgr.run() } + yield() + handle.emit(IpcEvent.Initialized(emptyList(), 2)) + handle.emit(IpcEvent.Capabilities(2, emptyList())) + yield() + enabled.value = false + yield() + assertTrue(handle.sent.any { it is IpcCommand.Stop }) + job.cancel() + } + + // --- test helpers --- + private fun inMemoryStore(): MouseLayoutStore { + val backing = object : MouseLayoutStore.Backing { + private val flow = MutableStateFlow>(emptyMap()) + override fun snapshot() = flow.value + override fun set(newMap: Map) { flow.value = newMap } + override fun flow() = flow + } + return MouseLayoutStore(backing) + } + + private fun newManager( + handle: FakeHandle, + store: MouseLayoutStore, + syncs: MutableStateFlow> = MutableStateFlow(emptyList()), + enabled: MutableStateFlow, + port: MutableStateFlow = MutableStateFlow(4243), + ): MouseDaemonManager { + val dao = mockk() + every { dao.getAllSyncRuntimeInfosFlow() } returns syncs + val client = MouseDaemonClient(handle) + return MouseDaemonManager( + enabledFlow = enabled, + portFlow = port, + layoutStore = store, + syncRuntimeInfoDao = dao, + clientFactory = { client }, + ) + } +} +``` + +- [ ] **Step 2: Run — compile failure** + +Run: `./gradlew app:desktopTest --tests "com.crosspaste.mouse.MouseDaemonManagerTest"` + +- [ ] **Step 3: Implement MouseDaemonManager** + +```kotlin +// MouseDaemonManager.kt +package com.crosspaste.mouse + +import com.crosspaste.db.sync.SyncRuntimeInfoDao +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.launch + +sealed interface MouseState { + object Disabled : MouseState + object Starting : MouseState + data class Running(val connectedPeers: List) : MouseState + data class Warning(val code: String, val message: String) : MouseState + data class Error(val message: String) : MouseState +} + +class MouseDaemonManager( + private val enabledFlow: Flow, + private val portFlow: Flow, + private val layoutStore: MouseLayoutStore, + private val syncRuntimeInfoDao: SyncRuntimeInfoDao, + private val clientFactory: () -> MouseDaemonClient, +) { + private val _state = MutableStateFlow(MouseState.Disabled) + val state: StateFlow = _state.asStateFlow() + + suspend fun run() = coroutineScope { + var activeClient: MouseDaemonClient? = null + var activeClientJob: kotlinx.coroutines.Job? = null + var lastPeers: List? = null + var lastPort: Int = -1 + + combine( + enabledFlow, + portFlow, + layoutStore.flow(), + syncRuntimeInfoDao.getAllSyncRuntimeInfosFlow(), + ) { enabled, port, layout, syncs -> + MergedInputs(enabled, port, MousePeerMapper.map(syncs, layout)) + }.distinctUntilChanged().collect { inputs -> + if (!inputs.enabled) { + activeClient?.let { + runCatching { it.stop() } + it.close() + } + activeClientJob?.cancel() + activeClient = null + activeClientJob = null + lastPeers = null + lastPort = -1 + _state.value = MouseState.Disabled + return@collect + } + + if (activeClient == null) { + _state.value = MouseState.Starting + val client = clientFactory() + activeClient = client + activeClientJob = launch { client.run() } + + // Route events into state. + launch { + client.events.collect { ev -> + when (ev) { + is IpcEvent.PeerConnected -> + _state.value = MouseState.Running( + connectedPeers = (state.value as? MouseState.Running) + ?.connectedPeers.orEmpty() + ev.name, + ) + is IpcEvent.PeerDisconnected -> + _state.value = MouseState.Running( + connectedPeers = (state.value as? MouseState.Running) + ?.connectedPeers.orEmpty() - ev.name, + ) + is IpcEvent.Warning -> _state.value = MouseState.Warning(ev.code, ev.message) + is IpcEvent.Error -> _state.value = MouseState.Error(ev.message) + else -> Unit + } + } + } + client.start(inputs.port, inputs.peers) + lastPort = inputs.port + lastPeers = inputs.peers + return@collect + } + + if (lastPort != inputs.port || lastPeers != inputs.peers) { + activeClient?.updateLayout(inputs.port, inputs.peers) + lastPort = inputs.port + lastPeers = inputs.peers + } + } + } + + private data class MergedInputs( + val enabled: Boolean, + val port: Int, + val peers: List, + ) +} +``` + +- [ ] **Step 4: Run tests — expect all PASS** + +Run: `./gradlew app:desktopTest --tests "com.crosspaste.mouse.MouseDaemonManagerTest"` + +- [ ] **Step 5: Commit** + +```bash +./gradlew ktlintFormat +git add app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonManager.kt \ + app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonManagerTest.kt +git commit -m ":sparkles: add MouseDaemonManager lifecycle — enables daemon driven by config and paired devices" +``` + +### Task 8: Koin wiring + +**Files:** +- Modify: `app/src/desktopMain/kotlin/com/crosspaste/DesktopAppModule.kt` +- Modify: `app/src/desktopMain/kotlin/com/crosspaste/config/DesktopAppConfig.kt` (already touched in Task 5) + +- [ ] **Step 1: Read the existing DesktopAppModule** + +Read `DesktopAppModule.kt` to see how other services are registered (single vs factory, how flows are derived from config). Note the `appModule()` function signature so you can add providers next to existing ones. + +- [ ] **Step 2: Add a `mouseModule()` factory** + +Create `mouseModule()` in the same file (or a sibling `DesktopMouseModule.kt` if `DesktopAppModule.kt` is already large — follow the existing pattern; e.g. `DesktopNetworkModule` is sibling). Register: +- `MouseLayoutStore` — construct a `MouseLayoutStore.Backing` that reads/writes `DesktopAppConfig.mouseLayout` through `DesktopConfigManager`. Expose the config-backed `MutableStateFlow` via the existing config-observation API. +- `MouseDaemonClient` — factory: resolve binary via `MouseDaemonBinary.resolve()`, call `MouseDaemonProcess.spawn(...)`, wrap `.asDaemonHandle()` into `MouseDaemonClient(handle)`. If the binary can't be resolved, the factory should throw a descriptive `IllegalStateException` — surfaced as `MouseState.Error` by the manager. +- `MouseDaemonManager` — single, passing: + - `enabledFlow` = `config.map { it.mouseEnabled }.distinctUntilChanged()` + - `portFlow` = `config.map { it.mouseListenPort }.distinctUntilChanged()` + - `layoutStore`, `syncRuntimeInfoDao` from Koin + - `clientFactory` = a lambda that calls `get()` each time a new session is needed + +- [ ] **Step 3: Register in `appModule()`** + +Add `mouseModule()` to whichever central list `DesktopAppModule` exposes (likely in `DesktopModule.kt`'s `appModule()` return value). Verify by: + +```bash +./gradlew app:desktopTest --tests "*MouseDaemon*" +``` + +- [ ] **Step 4: Start the manager at app startup** + +Find where other long-lived services are `launch`ed on app start (search for `launch` in `app/src/desktopMain/kotlin/com/crosspaste/CrossPaste.kt` or similar). Add: + +```kotlin +val mouseManager: MouseDaemonManager = koin.get() +applicationScope.launch { mouseManager.run() } +``` + +Make this match the convention in `CrossPaste.kt` for other managers; do not introduce a new scope. + +- [ ] **Step 5: Run full test suite and app** + +```bash +./gradlew ktlintFormat +./gradlew app:desktopTest +``` + +Expected: all mouse tests still pass. + +- [ ] **Step 6: Commit** + +```bash +git add app/src/desktopMain/kotlin/com/crosspaste/DesktopAppModule.kt \ + app/src/desktopMain/kotlin/com/crosspaste/config/DesktopAppConfig.kt \ + app/src/desktopMain/kotlin/com/crosspaste/CrossPaste.kt +git commit -m ":sparkles: wire MouseDaemonManager into Koin and start on app launch" +``` + +--- + +## Phase 4 — Configuration UI (Settings screen + arrangement canvas) + +### Task 9: ScreenArrangementViewModel + +**Files:** +- Create: `app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModel.kt` +- Test: `app/src/desktopTest/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModelTest.kt` + +State: +- `localScreens: List` (from `IpcEvent.Initialized` / `LocalScreens`) +- `remoteDevices: Map` where `RemoteDeviceInfo(name, screens, position)` + - screens come from `IpcEvent.PeerScreensLearned` + - position comes from `MouseLayoutStore` +- `selectedDeviceId: String?` + +Actions: +- `onDragDevice(deviceId, dx, dy)`: updates `position` in-memory +- `onDragEnd(deviceId)`: commits to `MouseLayoutStore.upsert(deviceId, position)` (triggers manager → daemon restart) + +- [ ] **Step 1: Write failing tests** + +```kotlin +// ScreenArrangementViewModelTest.kt +package com.crosspaste.ui.mouse + +import com.crosspaste.mouse.IpcEvent +import com.crosspaste.mouse.MouseLayoutStore +import com.crosspaste.mouse.Position +import com.crosspaste.mouse.ScreenInfo +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import kotlin.test.Test +import kotlin.test.assertEquals + +class ScreenArrangementViewModelTest { + + private fun store(initial: Map = emptyMap()): MouseLayoutStore { + val backing = object : MouseLayoutStore.Backing { + val flow = MutableStateFlow(initial) + override fun snapshot() = flow.value + override fun set(newMap: Map) { flow.value = newMap } + override fun flow() = flow + } + return MouseLayoutStore(backing) + } + + @Test + fun `learns local and remote screens from events`() = runTest { + val events = MutableSharedFlow(replay = 0, extraBufferCapacity = 16) + val vm = ScreenArrangementViewModel(events.asSharedFlow(), store()) + val job = launch { vm.observe() } + yield() + events.emit( + IpcEvent.Initialized( + screens = listOf(ScreenInfo(0, 1920, 1080, 0, 0, 1.0, true)), + protocolVersion = 2, + ), + ) + events.emit( + IpcEvent.PeerScreensLearned( + deviceId = "inst-1", + screens = listOf(ScreenInfo(0, 2560, 1440, 0, 0, 2.0, true)), + ), + ) + yield() + assertEquals(1, vm.localScreens.value.size) + assertEquals(2560, vm.remoteDevices.value["inst-1"]!!.screens.single().width) + job.cancel() + } + + @Test + fun `drag updates in-memory position without committing`() { + val s = store(mapOf("inst-1" to Position(1920, 0))) + val vm = ScreenArrangementViewModel(MutableSharedFlow(), s) + vm.seedRemote("inst-1", "Desktop", listOf(ScreenInfo(0, 2560, 1440, 0, 0, 1.0, true))) + vm.onDragDevice("inst-1", dx = 100, dy = 0) + assertEquals(Position(2020, 0), vm.remoteDevices.value["inst-1"]!!.position) + assertEquals(Position(1920, 0), s.get("inst-1")) // not committed + } + + @Test + fun `onDragEnd commits to store`() { + val s = store(mapOf("inst-1" to Position(1920, 0))) + val vm = ScreenArrangementViewModel(MutableSharedFlow(), s) + vm.seedRemote("inst-1", "Desktop", listOf(ScreenInfo(0, 2560, 1440, 0, 0, 1.0, true))) + vm.onDragDevice("inst-1", dx = -3840, dy = 0) // user put peer on far left + vm.onDragEnd("inst-1") + assertEquals(Position(-1920, 0), s.get("inst-1")) + } +} +``` + +- [ ] **Step 2: Run — compile failure** + +Run: `./gradlew app:desktopTest --tests "com.crosspaste.ui.mouse.ScreenArrangementViewModelTest"` + +- [ ] **Step 3: Implement the view model** + +```kotlin +// ScreenArrangementViewModel.kt +package com.crosspaste.ui.mouse + +import com.crosspaste.mouse.IpcEvent +import com.crosspaste.mouse.MouseLayoutStore +import com.crosspaste.mouse.Position +import com.crosspaste.mouse.ScreenInfo +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +data class RemoteDeviceInfo( + val name: String, + val screens: List, + val position: Position, +) + +class ScreenArrangementViewModel( + private val events: SharedFlow, + private val layoutStore: MouseLayoutStore, +) { + private val _localScreens = MutableStateFlow>(emptyList()) + val localScreens: StateFlow> = _localScreens.asStateFlow() + + private val _remoteDevices = MutableStateFlow>(emptyMap()) + val remoteDevices: StateFlow> = _remoteDevices.asStateFlow() + + /** For tests: inject a remote without emitting an event. */ + fun seedRemote(deviceId: String, name: String, screens: List) { + val existing = _remoteDevices.value[deviceId] + val position = existing?.position ?: layoutStore.get(deviceId) ?: Position(0, 0) + _remoteDevices.value = _remoteDevices.value + (deviceId to RemoteDeviceInfo(name, screens, position)) + } + + suspend fun observe() { + events.collect { ev -> + when (ev) { + is IpcEvent.Initialized -> _localScreens.value = ev.screens + is IpcEvent.LocalScreens -> _localScreens.value = ev.screens + is IpcEvent.PeerScreensLearned -> { + val existing = _remoteDevices.value[ev.deviceId] + _remoteDevices.value = _remoteDevices.value + ( + ev.deviceId to RemoteDeviceInfo( + name = existing?.name ?: ev.deviceId, + screens = ev.screens, + position = existing?.position ?: layoutStore.get(ev.deviceId) ?: Position(0, 0), + ) + ) + } + is IpcEvent.PeerConnected -> { + val existing = _remoteDevices.value[ev.deviceId] + if (existing != null && existing.name != ev.name) { + _remoteDevices.value = _remoteDevices.value + ( + ev.deviceId to existing.copy(name = ev.name) + ) + } + } + else -> Unit + } + } + } + + fun onDragDevice(deviceId: String, dx: Int, dy: Int) { + val existing = _remoteDevices.value[deviceId] ?: return + val next = existing.copy(position = Position(existing.position.x + dx, existing.position.y + dy)) + _remoteDevices.value = _remoteDevices.value + (deviceId to next) + } + + fun onDragEnd(deviceId: String) { + val dev = _remoteDevices.value[deviceId] ?: return + layoutStore.upsert(deviceId, dev.position) + } +} +``` + +- [ ] **Step 4: Run tests — all PASS** + +Run: `./gradlew app:desktopTest --tests "com.crosspaste.ui.mouse.ScreenArrangementViewModelTest"` + +- [ ] **Step 5: Commit** + +```bash +./gradlew ktlintFormat +git add app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModel.kt \ + app/src/desktopTest/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModelTest.kt +git commit -m ":sparkles: add ScreenArrangementViewModel for mouse layout UI" +``` + +### Task 10: ScreenCanvas composable + +**Files:** +- Create: `app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenCanvas.kt` + +No unit tests for this file — it's presentational; verified manually + by ViewModel tests. + +- [ ] **Step 1: Write the composable** + +```kotlin +// ScreenCanvas.kt +package com.crosspaste.ui.mouse + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.dp +import com.crosspaste.mouse.Position +import com.crosspaste.mouse.ScreenInfo +import com.crosspaste.ui.theme.AppUISize + +/** + * Canvas that lets the user drag remote devices around a virtual desktop. + * Local screens are locked at (0, 0). Each remote device is a rigid group + * of its own screens; dragging any screen of the group shifts the group's + * [Position] offset. + */ +@Composable +fun ScreenCanvas( + viewModel: ScreenArrangementViewModel, + modifier: Modifier = Modifier, +) { + val locals by viewModel.localScreens.collectAsState() + val remotes by viewModel.remoteDevices.collectAsState() + val measurer: TextMeasurer = rememberTextMeasurer() + + BoxWithConstraints(modifier = modifier.fillMaxWidth().height(AppUISize.xxxxLarge * 6)) { + val viewportPx = maxWidth.value.coerceAtLeast(400f) + val (bounds, scale) = remember(locals, remotes, viewportPx) { + computeBoundsAndScale(locals, remotes, viewportPx) + } + + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(AppUISize.xxxxLarge * 6) + .background(Color(0xFFF2F2F2)) + .pointerInput(remotes.keys) { + detectDragGestures( + onDrag = { change, drag -> + change.consume() + val hitId = remotes.entries.firstOrNull { (_, info) -> + info.screens.any { screen -> + val rect = toRect(info.position, screen, bounds, scale) + rect.contains(change.position) + } + }?.key ?: return@detectDragGestures + viewModel.onDragDevice( + deviceId = hitId, + dx = (drag.x / scale).toInt(), + dy = (drag.y / scale).toInt(), + ) + }, + onDragEnd = { + remotes.keys.forEach { viewModel.onDragEnd(it) } + }, + ) + }, + ) { + // Draw local screens (blue), origin-locked + locals.forEach { screen -> + val rect = toRect(Position(0, 0), screen, bounds, scale) + drawRect(color = Color(0xFF1E88E5).copy(alpha = 0.35f), topLeft = rect.topLeft, size = rect.size) + drawRect(color = Color(0xFF1E88E5), topLeft = rect.topLeft, size = rect.size, style = Stroke(width = 2f)) + } + // Draw each remote device's screens (orange), as a group + remotes.forEach { (_, info) -> + info.screens.forEach { screen -> + val rect = toRect(info.position, screen, bounds, scale) + drawRect(color = Color(0xFFFB8C00).copy(alpha = 0.35f), topLeft = rect.topLeft, size = rect.size) + drawRect(color = Color(0xFFFB8C00), topLeft = rect.topLeft, size = rect.size, style = Stroke(width = 2f)) + drawText( + textMeasurer = measurer, + text = AnnotatedString(info.name), + topLeft = rect.topLeft + Offset(4f, 4f), + ) + } + } + } + } +} + +private data class WorldBounds(val minX: Int, val minY: Int) +private data class RectFloats(val topLeft: Offset, val size: Size) { + fun contains(p: Offset) = p.x in topLeft.x..(topLeft.x + size.width) && p.y in topLeft.y..(topLeft.y + size.height) +} + +private fun computeBoundsAndScale( + locals: List, + remotes: Map, + viewportPx: Float, +): Pair { + val allRects = buildList { + locals.forEach { add(IntRect(0, 0, it.width, it.height)) } + remotes.values.forEach { info -> + info.screens.forEach { + add(IntRect(info.position.x, info.position.y, info.position.x + it.width, info.position.y + it.height)) + } + } + } + val minX = allRects.minOfOrNull { it.left } ?: 0 + val minY = allRects.minOfOrNull { it.top } ?: 0 + val maxX = allRects.maxOfOrNull { it.right } ?: 1920 + val worldWidth = (maxX - minX).coerceAtLeast(1920) + val scale = ((viewportPx - 40f) / worldWidth).coerceAtLeast(0.01f) + return WorldBounds(minX, minY) to scale +} + +private fun toRect( + position: Position, + screen: ScreenInfo, + bounds: WorldBounds, + scale: Float, +): RectFloats { + val x = (position.x - bounds.minX) * scale + 20f + val y = (position.y - bounds.minY) * scale + 20f + return RectFloats( + topLeft = Offset(x, y), + size = Size(screen.width * scale, screen.height * scale), + ) +} + +private data class IntRect(val left: Int, val top: Int, val right: Int, val bottom: Int) +``` + +- [ ] **Step 2: Verify compilation** + +```bash +./gradlew ktlintFormat +./gradlew app:desktopMainClasses +``` + +- [ ] **Step 3: Commit** + +```bash +git add app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenCanvas.kt +git commit -m ":sparkles: add drag-to-arrange ScreenCanvas for mouse layout" +``` + +### Task 11: MouseSettingsScreen + permission dialog + route + +**Files:** +- Create: `app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MouseSettingsScreen.kt` +- Create: `app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MousePermissionDialog.kt` +- Modify: `app/src/commonMain/kotlin/com/crosspaste/ui/Route.kt` — add `MouseSettings` object +- Modify: wherever `Settings` screen's nav list is built (find via grep for `NetworkSettings` usage as a sibling) — add entry +- Modify: i18n keys (Task 12) + +- [ ] **Step 1: Add the Route** + +Edit `Route.kt`: +```kotlin +@Serializable +object MouseSettings : Route { + const val NAME: String = "mouse_settings" + override val name: String = NAME +} +``` + +- [ ] **Step 2: Write the permission dialog** + +```kotlin +// MousePermissionDialog.kt +package com.crosspaste.ui.mouse + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import com.crosspaste.mouse.IpcEvent + +@Composable +fun MousePermissionDialog( + warning: IpcEvent.Warning?, + onDismiss: () -> Unit, +) { + if (warning == null) return + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(warning.code) }, + text = { Text(warning.message) }, + confirmButton = { TextButton(onClick = onDismiss) { Text("OK") } }, + ) +} +``` + +- [ ] **Step 3: Write the settings screen** + +```kotlin +// MouseSettingsScreen.kt +package com.crosspaste.ui.mouse + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.crosspaste.mouse.IpcEvent +import com.crosspaste.mouse.MouseDaemonManager +import com.crosspaste.mouse.MouseState +import com.crosspaste.ui.theme.AppUISize +import org.koin.compose.koinInject + +@Composable +fun MouseSettingsScreen() { + val manager: MouseDaemonManager = koinInject() + val viewModel: ScreenArrangementViewModel = koinInject() + + val state by manager.state.collectAsState() + var warning by remember { mutableStateOf(null) } + + // TODO(host-wire-up): observe manager.state or a dedicated warnings flow and set `warning`. + // Keep simple for MVP: render current state summary; dialog populated when state is Warning. + val currentWarning = (state as? MouseState.Warning)?.let { IpcEvent.Warning(it.code, it.message) } + MousePermissionDialog(warning = currentWarning, onDismiss = { warning = null }) + + Column(modifier = Modifier.padding(AppUISize.medium), verticalArrangement = Arrangement.spacedBy(AppUISize.medium)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text("Share keyboard/mouse", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.weight(1f)) + // TODO Task 12: replace with config-backed toggle from DesktopAppConfig.mouseEnabled + Switch(checked = state !is MouseState.Disabled, onCheckedChange = { /* config update */ }) + } + Text( + text = when (state) { + MouseState.Disabled -> "Off" + MouseState.Starting -> "Starting…" + is MouseState.Running -> "Running — ${(state as MouseState.Running).connectedPeers.size} peer(s)" + is MouseState.Warning -> "Warning: ${(state as MouseState.Warning).code}" + is MouseState.Error -> "Error: ${(state as MouseState.Error).message}" + }, + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(AppUISize.small)) + ScreenCanvas(viewModel = viewModel, modifier = Modifier.fillMaxWidth()) + } +} +``` + +- [ ] **Step 4: Wire the route into the Settings nav list** + +Find the existing settings list (likely `SettingsContentView` / `Settings.kt`) and add an entry for `MouseSettings`. Follow the same pattern the `NetworkSettings` entry uses — same icon style, same i18n key mechanism. + +- [ ] **Step 5: Register the nav destination** + +Find the central `NavHost` / route dispatcher (grep for `NetworkSettings.NAME`). Add: +```kotlin +composable(route = MouseSettings.NAME) { MouseSettingsScreen() } +``` + +- [ ] **Step 6: Register ScreenArrangementViewModel in Koin** + +In the mouse module, add: +```kotlin +single { ScreenArrangementViewModel(events = get().events, layoutStore = get()) } +``` + +Caveat: `MouseDaemonClient` is re-created per session. Better pattern: bridge events through `MouseDaemonManager`. If time-constrained for MVP, expose `manager.events: SharedFlow` that multiplexes across sessions. Add this as a follow-up step in Task 7 if not already there. + +- [ ] **Step 7: Verify compilation + full tests** + +```bash +./gradlew ktlintFormat +./gradlew app:desktopTest +./gradlew app:desktopMainClasses +``` + +- [ ] **Step 8: Commit** + +```bash +git add app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ \ + app/src/commonMain/kotlin/com/crosspaste/ui/Route.kt \ + app/src/commonMain/kotlin/com/crosspaste/ui/settings/ \ + app/src/desktopMain/kotlin/com/crosspaste/DesktopAppModule.kt +git commit -m ":sparkles: add MouseSettings screen with arrangement canvas and permission dialog" +``` + +--- + +## Phase 5 — Polish: i18n + manual verification + +### Task 12: i18n keys + +**Files:** +- Modify: `app/src/desktopMain/resources/i18n/en.properties` +- Modify: `app/src/desktopMain/resources/i18n/zh.properties` +- Run: `./i18n_batch_update.sh` to propagate to other locales (per repo convention — check the script before running). + +- [ ] **Step 1: Enumerate strings used** + +From Tasks 10–11, collect hardcoded UI strings: +- `Share keyboard/mouse` +- `Off`, `Starting…` +- `Running — %d peer(s)` +- `Warning: %s` +- `Error: %s` +- Settings entry label: `Mouse` +- Permission codes (don't translate the code itself, but the message comes from daemon) + +- [ ] **Step 2: Add to `en.properties`** + +``` +mouse_settings.title=Mouse +mouse_settings.switch=Share keyboard/mouse +mouse_settings.state.disabled=Off +mouse_settings.state.starting=Starting… +mouse_settings.state.running={0} connected peer(s) +mouse_settings.state.warning=Warning: {0} +mouse_settings.state.error=Error: {0} +mouse_settings.canvas.help=Drag a device to position its screens relative to yours. +``` + +- [ ] **Step 3: Add Chinese translations to `zh.properties`** + +``` +mouse_settings.title=鼠标共享 +mouse_settings.switch=共享键盘与鼠标 +mouse_settings.state.disabled=已关闭 +mouse_settings.state.starting=启动中… +mouse_settings.state.running=已连接 {0} 台设备 +mouse_settings.state.warning=警告:{0} +mouse_settings.state.error=错误:{0} +mouse_settings.canvas.help=拖拽设备以调整它相对于本机的屏幕位置。 +``` + +- [ ] **Step 4: Replace hardcoded strings in `MouseSettingsScreen.kt`** + +Use the project's existing i18n helper (check how `NetworkSettings` reads from i18n — likely via a `LocalI18n.current.getText(...)` or a composable). + +- [ ] **Step 5: Propagate to other locales** + +Read `i18n_batch_update.sh` (in repo root) to understand what it does. If it calls a translation service, confirm with the user before running. If it's a mechanical copy, run it: +```bash +./i18n_batch_update.sh +``` + +- [ ] **Step 6: Commit** + +```bash +./gradlew ktlintFormat +git add app/src/desktopMain/resources/i18n/ \ + app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MouseSettingsScreen.kt +git commit -m ":memo: add i18n strings for mouse settings screen" +``` + +### Task 13: Manual end-to-end verification + +Not a code task — a verification checklist before the PR. + +- [ ] **Step 1: Build the daemon** + +```bash +cd ~/crosspaste-mouse +cargo build --release +``` + +- [ ] **Step 2: Run desktop with the daemon path exposed** + +```bash +cd ~/crosspaste-desktop +CROSSPASTE_MOUSE_BIN=~/crosspaste-mouse/target/release/crosspaste-mouse \ + ./gradlew app:run +``` + +- [ ] **Step 3: In the app** + +1. Pair a second device via the existing device pairing flow. +2. Open Settings → Mouse. +3. Toggle on. +4. Expect: permission warning dialog on macOS (grant Accessibility if not already). +5. Expect: "Starting…" → "1 connected peer(s)". +6. In the canvas, drag the remote device rectangle to the LEFT of the local rectangle. Release. +7. Move cursor to the left edge of the local screen → should appear on the remote device's right edge. +8. Drag the remote rectangle to the RIGHT. Release. +9. Cursor at the right edge of local → appears on remote's left. + +- [ ] **Step 4: Confirm restart behavior** + +Between drag-end events, watch stderr (or app logs) for QUIC re-handshake — expected in the MVP since `update_layout` falls back to `stop`+`start` until daemon ticket #9 lands. + +- [ ] **Step 5: Document known gaps in commit or PR** + +When opening the PR, mention: +- Layout changes drop and re-establish QUIC connections (waiting on daemon ticket #9 — Hot swap). +- Daemon binary is NOT bundled into the installer yet; production users must install separately. +- No fingerprint pinning (deliberate — desktop is trust authority). + +--- + +## Self-Review Notes + +- **Spec coverage:** Daemon discovery (Task 2), protocol (Task 1), process IO (Task 3), peer mapping without fingerprint (Task 4), layout persistence (Task 5), client w/ capability negotiation (Task 6), lifecycle (Task 7), Koin wiring (Task 8), drag UI backing (Tasks 9–10), settings screen + permissions (Task 11), i18n (Task 12), verification (Task 13). The user's statement "crosspaste-desktop will tell mouse the trusted peer address+port, so no fingerprint" is enforced in Task 4 (no fingerprint field ever set) and noted at the plan level. +- **Type consistency:** `IpcPeer.deviceId` (nullable String) used consistently from Task 1 → Task 4 → Task 7. `MouseLayoutStore.Backing` contract identical across Tasks 5, 7, 9. `MouseDaemonClient.DaemonHandle` interface used by Tasks 6 and 7 tests. +- **Gap flagged:** Task 11 Step 6 notes a Koin-wiring caveat (events across session restarts). Tracked as an inline TODO rather than a separate task — if it breaks during verification, it becomes a small follow-up commit. +- **Out of scope:** Bundling the Rust binary into the installer (needs conveyor + cargo cross-compile), daemon tickets #8 (cert pinning — not needed, per decision), #9 (update_layout hot swap — worked around via stop+start), #10–13 (polish). From b1bfce355f2a81fcd053da43b9ba824699021a17 Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Mon, 20 Apr 2026 22:20:59 +0800 Subject: [PATCH 18/38] :hammer: store mouseLayout as JSON string, matching blacklist/sourceExclusions pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the superfluous updateConfig(updater) lambda overload on DesktopConfigManager — it duplicated machinery the scalar path already has. mouseLayout now flows through the standard scalar copy(key, value) path: the MouseLayoutStore.Backing adapter JSON-encodes on write and decodes on read (via Position's existing @Serializable), mirroring how blacklist, sourceExclusions and useNetworkInterfaces are persisted. --- .../com/crosspaste/DesktopMouseModule.kt | 28 +++++++++++++------ .../com/crosspaste/config/DesktopAppConfig.kt | 11 ++++---- .../crosspaste/config/DesktopConfigManager.kt | 25 ----------------- 3 files changed, 24 insertions(+), 40 deletions(-) diff --git a/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt b/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt index ad91d40cc..5fb187264 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt @@ -5,6 +5,7 @@ import com.crosspaste.mouse.MouseDaemonBinary import com.crosspaste.mouse.MouseDaemonClient import com.crosspaste.mouse.MouseDaemonManager import com.crosspaste.mouse.MouseDaemonProcess +import com.crosspaste.mouse.MouseIpcProtocol import com.crosspaste.mouse.MouseLayoutStore import com.crosspaste.mouse.Position import com.crosspaste.mouse.asDaemonHandle @@ -12,34 +13,43 @@ import com.crosspaste.ui.mouse.ScreenArrangementViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer import org.koin.core.module.Module import org.koin.dsl.module /** * Bridges [MouseLayoutStore] to [DesktopConfigManager]-backed persistence. * - * Reads are served off the current config snapshot. Writes go through - * [DesktopConfigManager.updateConfig] (the typed-updater overload) so the - * layout persists to `appConfig.json` and the config StateFlow stays in - * sync. The store's own [flow] is the authoritative observable surface — - * it's updated in lockstep with `set()` so observers see new layouts - * immediately, independent of any config-save latency. + * `mouseLayout` is stored as a JSON-encoded `Map` in + * [com.crosspaste.config.DesktopAppConfig.mouseLayout], matching the + * `blacklist` / `sourceExclusions` pattern: encode on write, decode on read, + * flow through the scalar `updateConfig(key, value)` path. The store's own + * [flow] is the authoritative observable surface — updated in lockstep with + * `set()` so observers see new layouts immediately, independent of config + * save latency. */ class DesktopAppConfigMouseLayoutBacking( private val configManager: DesktopConfigManager, ) : MouseLayoutStore.Backing { + private val mapSerializer = MapSerializer(String.serializer(), Position.serializer()) + private val _flow: MutableStateFlow> = - MutableStateFlow(configManager.config.value.mouseLayout) + MutableStateFlow(decode(configManager.config.value.mouseLayout)) - override fun snapshot(): Map = configManager.config.value.mouseLayout + override fun snapshot(): Map = decode(configManager.config.value.mouseLayout) override fun set(newMap: Map) { - configManager.updateConfig { it.copy(mouseLayout = newMap) } + configManager.updateConfig("mouseLayout", MouseIpcProtocol.json.encodeToString(mapSerializer, newMap)) _flow.value = newMap } override fun flow(): MutableStateFlow> = _flow + + private fun decode(json: String): Map = + runCatching { MouseIpcProtocol.json.decodeFromString(mapSerializer, json) } + .getOrElse { emptyMap() } } fun desktopMouseModule(): Module = diff --git a/app/src/desktopMain/kotlin/com/crosspaste/config/DesktopAppConfig.kt b/app/src/desktopMain/kotlin/com/crosspaste/config/DesktopAppConfig.kt index 54db9cd29..9c55ac79d 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/config/DesktopAppConfig.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/config/DesktopAppConfig.kt @@ -4,7 +4,6 @@ import com.crosspaste.config.AppConfig.Companion.toBoolean import com.crosspaste.config.AppConfig.Companion.toInt import com.crosspaste.config.AppConfig.Companion.toLong import com.crosspaste.config.AppConfig.Companion.toString -import com.crosspaste.mouse.Position import com.crosspaste.ui.extension.ProxyType import kotlinx.serialization.Serializable @@ -70,7 +69,10 @@ data class DesktopAppConfig( // Mouse daemon (crosspaste-mouse plugin) val mouseEnabled: Boolean = false, val mouseListenPort: Int = 4243, - val mouseLayout: Map = emptyMap(), + // JSON-encoded Map (deviceId → virtual-desktop offset). + // Stored as a String so it flows through the scalar copy(key, value) path, + // matching the blacklist / sourceExclusions / useNetworkInterfaces pattern. + val mouseLayout: String = "{}", ) : AppConfig { override fun copy( key: String, @@ -187,9 +189,6 @@ data class DesktopAppConfig( }, mouseEnabled = if (key == "mouseEnabled") toBoolean(value) else mouseEnabled, mouseListenPort = if (key == "mouseListenPort") toInt(value) else mouseListenPort, - // mouseLayout is Map and is not mutated through the - // generic scalar-based copy(key, value) path. It is updated via typed - // data-class copy() by the MouseLayoutStore backing adapter (Task 8). - mouseLayout = mouseLayout, + mouseLayout = if (key == "mouseLayout") toString(value) else mouseLayout, ) } diff --git a/app/src/desktopMain/kotlin/com/crosspaste/config/DesktopConfigManager.kt b/app/src/desktopMain/kotlin/com/crosspaste/config/DesktopConfigManager.kt index 340c15872..e41a21c99 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/config/DesktopConfigManager.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/config/DesktopConfigManager.kt @@ -82,31 +82,6 @@ class DesktopConfigManager( } } - /** - * Typed, lambda-based updater for fields that cannot flow through the - * scalar `copy(key, value)` path (e.g. `Map` / complex types). On save - * failure, reverts the in-memory state and surfaces a notification, - * matching the scalar [updateConfig] behavior. - */ - @Synchronized - fun updateConfig(updater: (DesktopAppConfig) -> DesktopAppConfig) { - val oldConfig = _config.value - val newConfig = updater(oldConfig) - _config.value = newConfig - runCatching { - saveConfig(newConfig) - }.onFailure { e -> - logger.error(e) { "Failed to save config" } - notificationManager?.let { manager -> - manager.sendNotification( - title = { it.getText("failed_to_save_config") }, - messageType = MessageType.Error, - ) - } - _config.value = oldConfig - } - } - fun saveConfig(config: DesktopAppConfig) { configFilePersist.save(config) } From f9251b820f06ed5177f73e02d09370dba1af30df Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Tue, 21 Apr 2026 11:21:28 +0800 Subject: [PATCH 19/38] :bug: close read-modify-write race in MouseLayoutStore upsert/remove previously did snapshot() -> mutate -> set(), allowing concurrent writers to read the same baseline and overwrite each other on write (lost-update race). Replace Backing.set() with update((Map) -> Map) so the read, mutate and write happen atomically inside the backing. - DesktopAppConfigMouseLayoutBacking wraps the whole update in synchronized(updateLock). - Tests' FakeBacking impls use @Synchronized update(). - Add a concurrency regression test: 200 concurrent upserts on distinct keys must all survive (would have silently lost most under the old design). --- .../com/crosspaste/DesktopMouseModule.kt | 15 +++++++-- .../com/crosspaste/mouse/MouseLayoutStore.kt | 13 +++++--- .../mouse/MouseDaemonManagerTest.kt | 5 +-- .../crosspaste/mouse/MouseLayoutStoreTest.kt | 31 +++++++++++++++++-- .../mouse/ScreenArrangementViewModelTest.kt | 5 +-- 5 files changed, 54 insertions(+), 15 deletions(-) diff --git a/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt b/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt index 5fb187264..855060059 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt @@ -38,11 +38,20 @@ class DesktopAppConfigMouseLayoutBacking( private val _flow: MutableStateFlow> = MutableStateFlow(decode(configManager.config.value.mouseLayout)) + private val updateLock = Any() + override fun snapshot(): Map = decode(configManager.config.value.mouseLayout) - override fun set(newMap: Map) { - configManager.updateConfig("mouseLayout", MouseIpcProtocol.json.encodeToString(mapSerializer, newMap)) - _flow.value = newMap + override fun update(updater: (Map) -> Map) { + synchronized(updateLock) { + val current = decode(configManager.config.value.mouseLayout) + val next = updater(current) + configManager.updateConfig( + "mouseLayout", + MouseIpcProtocol.json.encodeToString(mapSerializer, next), + ) + _flow.value = next + } } override fun flow(): MutableStateFlow> = _flow diff --git a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseLayoutStore.kt b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseLayoutStore.kt index b26305ec1..c5d8d679d 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseLayoutStore.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseLayoutStore.kt @@ -20,11 +20,16 @@ class MouseLayoutStore( * The store treats the layout as a `Map`; the adapter * decides how to serialize it into whatever config representation the * platform uses (plain map, JSON-encoded strings, wrapper class, etc). + * + * Mutations are expressed as an [update] function that atomically reads + * the current state, applies the updater, and persists the result. This + * closes a read–modify–write race that a separate `snapshot() + set()` + * pair would leave open under concurrent [upsert] / [remove] calls. */ interface Backing { fun snapshot(): Map - fun set(newMap: Map) + fun update(updater: (Map) -> Map) fun flow(): MutableStateFlow> } @@ -37,13 +42,11 @@ class MouseLayoutStore( deviceId: String, position: Position, ) { - val next = backing.snapshot().toMutableMap().apply { put(deviceId, position) } - backing.set(next) + backing.update { it + (deviceId to position) } } fun remove(deviceId: String) { - val next = backing.snapshot().toMutableMap().apply { remove(deviceId) } - backing.set(next) + backing.update { it - deviceId } } fun flow(): Flow> = backing.flow() diff --git a/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonManagerTest.kt b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonManagerTest.kt index d212efba6..be91f9583 100644 --- a/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonManagerTest.kt +++ b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonManagerTest.kt @@ -168,8 +168,9 @@ class MouseDaemonManagerTest { override fun snapshot() = flow.value - override fun set(newMap: Map) { - flow.value = newMap + @Synchronized + override fun update(updater: (Map) -> Map) { + flow.value = updater(flow.value) } override fun flow() = flow diff --git a/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseLayoutStoreTest.kt b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseLayoutStoreTest.kt index 355c7cc61..ff7d7e414 100644 --- a/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseLayoutStoreTest.kt +++ b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseLayoutStoreTest.kt @@ -1,7 +1,10 @@ package com.crosspaste.mouse +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import kotlinx.coroutines.yield import kotlin.test.Test @@ -46,6 +49,26 @@ class MouseLayoutStoreTest { assertEquals(Position(1920, 0), updates.last()["inst-1"]) } + @Test + fun `concurrent upserts on distinct keys do not lose updates`() = + runBlocking { + // Under the previous snapshot() + set() design, concurrent upserts + // could read the same snapshot and overwrite each other on write, + // losing all but one update. The atomic update() contract closes + // that race — all 200 distinct keys must survive. + val cfg = FakeConfig(mutableMapOf()) + val store = MouseLayoutStore(cfg) + val n = 200 + coroutineScope { + repeat(n) { i -> + launch(Dispatchers.Default) { + store.upsert("device-$i", Position(i, 0)) + } + } + } + assertEquals(n, store.all().size) + } + // Fake config stand-in that matches the store's expected interface. private class FakeConfig( val backing: MutableMap, @@ -54,10 +77,12 @@ class MouseLayoutStoreTest { override fun snapshot() = backing.toMap() - override fun set(newMap: Map) { + @Synchronized + override fun update(updater: (Map) -> Map) { + val next = updater(backing.toMap()) backing.clear() - backing.putAll(newMap) - flow.value = snapshot() + backing.putAll(next) + flow.value = next } override fun flow() = flow diff --git a/app/src/desktopTest/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModelTest.kt b/app/src/desktopTest/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModelTest.kt index 9fc186b4f..5260c13dc 100644 --- a/app/src/desktopTest/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModelTest.kt +++ b/app/src/desktopTest/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModelTest.kt @@ -22,8 +22,9 @@ class ScreenArrangementViewModelTest { override fun snapshot() = flow.value - override fun set(newMap: Map) { - flow.value = newMap + @Synchronized + override fun update(updater: (Map) -> Map) { + flow.value = updater(flow.value) } override fun flow() = flow From 314981fdc866edb28fd5fcf2b4fe467f4ce2993d Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Tue, 21 Apr 2026 11:26:46 +0800 Subject: [PATCH 20/38] :bug: drop runBlocking from MouseDaemonProcess.close() to avoid coroutine-context deadlock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit close() used runBlocking { cancelAndJoin() } to wait for the reader coroutine before returning. If close() is called from a coroutine context (e.g. the MouseDaemonManager collector on a constrained dispatcher, or from inside the reader itself), runBlocking inside a coroutine can deadlock the caller's thread. Replace with a non-suspending scope.cancel(). runInterruptible already translates the cancel into Thread.interrupt() on the reader's own Dispatchers.IO thread — the reader unwinds asynchronously. We lose the strict 'no worker thread outlives close()' property, but the reader has no external side effects beyond _events.tryEmit into a DROP_OLDEST buffer, so an async teardown is safe. --- .../crosspaste/mouse/MouseDaemonProcess.kt | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt index 107d200b2..575977097 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt @@ -5,13 +5,12 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -106,17 +105,21 @@ class MouseDaemonProcess internal constructor( } override fun close() { - // Close the stdout stream first to unblock any reader thread parked in - // `readLine()` (coroutine cancellation alone can't interrupt a blocking - // JVM I/O call). Then cancel the scope and wait for the reader to fully - // unwind, so the AutoCloseable contract is honored: no worker thread - // outlives close(). + // Signal cancellation; runInterruptible translates this into + // Thread.interrupt() on the reader thread, which unblocks readLine() + // (InterruptedIOException). The reader coroutine then unwinds on its + // own Dispatchers.IO thread. + // + // We intentionally do NOT runBlocking { cancelAndJoin() } here: + // close() can be called from a coroutine context (e.g. the + // MouseDaemonManager collector) and runBlocking inside a coroutine + // is a deadlock risk if the caller's dispatcher is saturated or if + // close() is ever invoked from inside the reader itself. + // Trade-off: AutoCloseable returns before the reader is fully dead, + // but the reader has no external side effects beyond _events.tryEmit + // into a DROP_OLDEST buffer — safe. + scope.cancel() runCatching { stdoutStream.close() } - runCatching { - runBlocking { - scope.coroutineContext[Job]?.cancelAndJoin() - } - } runCatching { writer.close() } onClose() } From b7ba930bcd58f5e598cf40bce713bf2376369e6b Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Tue, 21 Apr 2026 11:30:38 +0800 Subject: [PATCH 21/38] :bug: derive MouseLayoutStore flow from DesktopConfigManager, eliminating out-of-sync risk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous design kept a separate _flow MutableStateFlow inside the backing adapter and updated it after configManager.updateConfig. If updateConfig failed and internally rolled back _config.value, our _flow would still advance to the new value — leaving observers in an inconsistent state relative to the persisted config. Refactor: make Backing.flow() return Flow (not MutableStateFlow), and in the desktop adapter derive it directly from configManager.config via map + distinctUntilChanged. Config is now the single source of truth — rollbacks in updateConfig propagate to observers automatically. Test FakeBacking impls declare override return type as Flow<...> explicitly so the compiler generates the required bridge method at the JVM level (AbstractMethodError would fire otherwise). --- .../com/crosspaste/DesktopMouseModule.kt | 21 ++++++++++--------- .../com/crosspaste/mouse/MouseLayoutStore.kt | 8 +++++-- .../mouse/MouseDaemonManagerTest.kt | 2 +- .../crosspaste/mouse/MouseLayoutStoreTest.kt | 2 +- .../mouse/ScreenArrangementViewModelTest.kt | 2 +- 5 files changed, 20 insertions(+), 15 deletions(-) diff --git a/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt b/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt index 855060059..18752c0a9 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt @@ -10,7 +10,7 @@ import com.crosspaste.mouse.MouseLayoutStore import com.crosspaste.mouse.Position import com.crosspaste.mouse.asDaemonHandle import com.crosspaste.ui.mouse.ScreenArrangementViewModel -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.serialization.builtins.MapSerializer @@ -24,10 +24,12 @@ import org.koin.dsl.module * `mouseLayout` is stored as a JSON-encoded `Map` in * [com.crosspaste.config.DesktopAppConfig.mouseLayout], matching the * `blacklist` / `sourceExclusions` pattern: encode on write, decode on read, - * flow through the scalar `updateConfig(key, value)` path. The store's own - * [flow] is the authoritative observable surface — updated in lockstep with - * `set()` so observers see new layouts immediately, independent of config - * save latency. + * flow through the scalar `updateConfig(key, value)` path. + * + * Observable [flow] is derived from `configManager.config` directly — this + * is the single source of truth. If a save fails inside `updateConfig` and + * the config rolls back, the derived flow automatically reflects the + * rollback; observers never see a value that isn't persisted. */ class DesktopAppConfigMouseLayoutBacking( private val configManager: DesktopConfigManager, @@ -35,9 +37,6 @@ class DesktopAppConfigMouseLayoutBacking( private val mapSerializer = MapSerializer(String.serializer(), Position.serializer()) - private val _flow: MutableStateFlow> = - MutableStateFlow(decode(configManager.config.value.mouseLayout)) - private val updateLock = Any() override fun snapshot(): Map = decode(configManager.config.value.mouseLayout) @@ -50,11 +49,13 @@ class DesktopAppConfigMouseLayoutBacking( "mouseLayout", MouseIpcProtocol.json.encodeToString(mapSerializer, next), ) - _flow.value = next } } - override fun flow(): MutableStateFlow> = _flow + override fun flow(): Flow> = + configManager.config + .map { decode(it.mouseLayout) } + .distinctUntilChanged() private fun decode(json: String): Map = runCatching { MouseIpcProtocol.json.decodeFromString(mapSerializer, json) } diff --git a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseLayoutStore.kt b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseLayoutStore.kt index c5d8d679d..699acd7a2 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseLayoutStore.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseLayoutStore.kt @@ -1,7 +1,6 @@ package com.crosspaste.mouse import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow /** * Store for the per-device virtual-screen layout driving the mouse daemon. @@ -31,7 +30,12 @@ class MouseLayoutStore( fun update(updater: (Map) -> Map) - fun flow(): MutableStateFlow> + /** + * Observable surface. Must be derived from (or kept in lockstep with) + * the same state `snapshot()` reads, so observers never see a + * persisted-but-not-yet-emitted state or vice versa. + */ + fun flow(): Flow> } fun all(): Map = backing.snapshot() diff --git a/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonManagerTest.kt b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonManagerTest.kt index be91f9583..b3f7893a3 100644 --- a/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonManagerTest.kt +++ b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonManagerTest.kt @@ -173,7 +173,7 @@ class MouseDaemonManagerTest { flow.value = updater(flow.value) } - override fun flow() = flow + override fun flow(): kotlinx.coroutines.flow.Flow> = flow } return MouseLayoutStore(backing) } diff --git a/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseLayoutStoreTest.kt b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseLayoutStoreTest.kt index ff7d7e414..cdfb1b6cb 100644 --- a/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseLayoutStoreTest.kt +++ b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseLayoutStoreTest.kt @@ -85,6 +85,6 @@ class MouseLayoutStoreTest { flow.value = next } - override fun flow() = flow + override fun flow(): kotlinx.coroutines.flow.Flow> = flow } } diff --git a/app/src/desktopTest/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModelTest.kt b/app/src/desktopTest/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModelTest.kt index 5260c13dc..132981d0f 100644 --- a/app/src/desktopTest/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModelTest.kt +++ b/app/src/desktopTest/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModelTest.kt @@ -27,7 +27,7 @@ class ScreenArrangementViewModelTest { flow.value = updater(flow.value) } - override fun flow() = flow + override fun flow(): kotlinx.coroutines.flow.Flow> = flow } return MouseLayoutStore(backing) } From 3a9eba886b19b128b3f8a0292d165ede17de4288 Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Tue, 21 Apr 2026 11:46:48 +0800 Subject: [PATCH 22/38] :bug: suppress transient Stopped from updateLayout fallback at the client boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stop+start fallback (used when the daemon does not advertise update_layout) emits a Stopped between the two commands that is session replacement, not a real shutdown. Downstream observers (MouseDaemonManager state machine, UI) must not treat it as the daemon stopping. Route all handle.events through run()'s single collector, which: - suppresses Stopped while restartInProgress is set, - forwards everything else to a private SharedFlow, - clears restartInProgress once the new session's Initialized or Ready arrives — any Stopped after that is a real shutdown. Doing the decision at run()'s entry (not with a downstream filter) removes the race where a lazy filter could be evaluated after the flag had already been cleared. Add regression test covering both the suppressed transient and the subsequent real Stopped. --- .../com/crosspaste/mouse/MouseDaemonClient.kt | 58 +++++++++++++++++-- .../crosspaste/mouse/MouseDaemonClientTest.kt | 42 ++++++++++++++ 2 files changed, 95 insertions(+), 5 deletions(-) diff --git a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonClient.kt b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonClient.kt index b080f584e..5f99e8b27 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonClient.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonClient.kt @@ -1,9 +1,13 @@ package com.crosspaste.mouse +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import java.util.concurrent.atomic.AtomicBoolean class MouseDaemonClient( private val handle: DaemonHandle, @@ -24,17 +28,58 @@ class MouseDaemonClient( private val _capabilities = MutableStateFlow(CapabilitySnapshot()) val capabilities: StateFlow = _capabilities.asStateFlow() - val events: SharedFlow get() = handle.events + /** + * Transient-Stopped suppression window for the [updateLayout] fallback. + * + * When the daemon does not advertise `update_layout`, we simulate a + * layout swap with `Stop` + `Start`. The daemon emits [IpcEvent.Stopped] + * during that window — from an observer's perspective it's session + * replacement, not a real shutdown. We hide it so downstream consumers + * (manager state machine, UI) don't misinterpret a hot-swap as "daemon + * stopped." The flag is set before sending `Stop` and cleared when the + * next session's [IpcEvent.Initialized] / [IpcEvent.Ready] arrives. + */ + private val restartInProgress = AtomicBoolean(false) + + private val _events = + MutableSharedFlow( + replay = 0, + extraBufferCapacity = 256, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) /** - * Drives the daemon: asks for capabilities once Initialized/Ready lands, - * forwards caps into [capabilities]. Suspends forever; cancel to stop. + * Observable event stream. Populated by [run]; the transient [IpcEvent.Stopped] + * emitted during [updateLayout]'s stop+start fallback is filtered here + * (atomically with the state change that clears the restart window), so + * late subscribers never observe a spurious shutdown. + */ + val events: SharedFlow = _events.asSharedFlow() + + /** + * Drives the daemon: forwards events from the handle to [events] (with + * transient-Stopped filtering), asks for capabilities once Initialized/Ready + * lands, and caches caps into [capabilities]. Suspends forever; cancel to stop. */ suspend fun run() { handle.events.collect { ev -> + // Single decision point: suppress the transient Stopped from the + // stop+start fallback here, BEFORE downstream observers can see it. + // Doing this at the point of entry avoids a race where a filter + // applied downstream might be evaluated after run() has already + // cleared restartInProgress in response to the new session's + // Initialized event. + if (ev is IpcEvent.Stopped && restartInProgress.get()) { + return@collect + } + + _events.tryEmit(ev) + when (ev) { is IpcEvent.Initialized, is IpcEvent.Ready -> { handle.send(IpcCommand.GetCapabilities) + // New session is up — any subsequent Stopped is a real shutdown. + restartInProgress.set(false) } is IpcEvent.Capabilities -> { _capabilities.value = @@ -61,8 +106,9 @@ class MouseDaemonClient( /** * Applies a new layout. If the daemon advertises `update_layout`, sends it - * natively; otherwise falls back to stop + start (current reality — daemon - * ticket #9 still pending). + * natively; otherwise falls back to `Stop` + `Start` (current reality — + * daemon ticket #9 still pending). The fallback's transient `Stopped` is + * hidden from [events] via [restartInProgress]. */ suspend fun updateLayout( port: Int, @@ -71,8 +117,10 @@ class MouseDaemonClient( if ("update_layout" in _capabilities.value.features) { handle.send(IpcCommand.UpdateLayout(peers)) } else { + restartInProgress.set(true) handle.send(IpcCommand.Stop) handle.send(IpcCommand.Start(port, peers)) + // restartInProgress cleared by run() when Initialized/Ready arrives. } } diff --git a/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonClientTest.kt b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonClientTest.kt index 8d3144b6b..31a733e25 100644 --- a/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonClientTest.kt +++ b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonClientTest.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlinx.coroutines.yield import kotlin.test.Test @@ -104,4 +105,45 @@ class MouseDaemonClientTest { got.join() job.cancel() } + + @Test + fun `transient Stopped during updateLayout fallback is hidden from events`() = + runTest { + val proc = FakeProcess() + val client = MouseDaemonClient(proc) + val job = launch { client.run() } + advanceUntilIdle() + // Handshake without update_layout → fallback path + proc.emit(IpcEvent.Initialized(screens = emptyList(), protocolVersion = 2)) + proc.emit(IpcEvent.Capabilities(protocolVersion = 2, features = emptyList())) + advanceUntilIdle() + + val observed = mutableListOf() + val collector = launch { client.events.collect { observed.add(it) } } + advanceUntilIdle() + + // Trigger the stop+start fallback + client.updateLayout(port = 4243, peers = emptyList()) + advanceUntilIdle() + // Daemon would emit Stopped in between, then Initialized on new session. + proc.emit(IpcEvent.Stopped) + proc.emit(IpcEvent.Initialized(screens = emptyList(), protocolVersion = 2)) + advanceUntilIdle() + + // The transient Stopped must NOT reach observers + assertTrue( + observed.none { it is IpcEvent.Stopped }, + "transient Stopped leaked through: $observed", + ) + // A subsequent real Stopped (after handshake clears the flag) should pass + proc.emit(IpcEvent.Stopped) + advanceUntilIdle() + assertTrue( + observed.any { it is IpcEvent.Stopped }, + "real Stopped after restart window was suppressed: $observed", + ) + + collector.cancel() + job.cancel() + } } From e2eef7cd3d7687ff57f7c998984c8e02df64b0e4 Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Tue, 21 Apr 2026 11:53:24 +0800 Subject: [PATCH 23/38] :memo: warn on mouseLayout JSON decode failure instead of silently dropping layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, a corrupt mouseLayout field in appConfig.json would fall back to emptyMap() with no log output — users would lose their arrangement with no diagnostic. Add a KotlinLogging warn with the caught exception and the offending JSON (truncated to 200 chars) so operators can see what went wrong. De-duplicated via lastWarnedJson to avoid spam: decode() runs on every snapshot/update and on every emission of the derived flow, so a persistently-corrupt value would otherwise log the same error dozens of times per session. --- .../com/crosspaste/DesktopMouseModule.kt | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt b/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt index 18752c0a9..8c2a4bc85 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt @@ -10,6 +10,7 @@ import com.crosspaste.mouse.MouseLayoutStore import com.crosspaste.mouse.Position import com.crosspaste.mouse.asDaemonHandle import com.crosspaste.ui.mouse.ScreenArrangementViewModel +import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map @@ -35,10 +36,21 @@ class DesktopAppConfigMouseLayoutBacking( private val configManager: DesktopConfigManager, ) : MouseLayoutStore.Backing { + private val logger = KotlinLogging.logger {} + private val mapSerializer = MapSerializer(String.serializer(), Position.serializer()) private val updateLock = Any() + /** + * Last JSON value we already logged a decode failure for. De-duplicates + * warnings across the many `decode()` calls that happen while a corrupt + * config value keeps sitting in [DesktopConfigManager.config] — we only + * want one warning per distinct bad payload. + */ + @Volatile + private var lastWarnedJson: String? = null + override fun snapshot(): Map = decode(configManager.config.value.mouseLayout) override fun update(updater: (Map) -> Map) { @@ -59,7 +71,16 @@ class DesktopAppConfigMouseLayoutBacking( private fun decode(json: String): Map = runCatching { MouseIpcProtocol.json.decodeFromString(mapSerializer, json) } - .getOrElse { emptyMap() } + .getOrElse { e -> + if (lastWarnedJson != json) { + lastWarnedJson = json + logger.warn(e) { + "Failed to decode mouseLayout JSON; falling back to empty layout. " + + "Offending value (truncated): ${json.take(200)}" + } + } + emptyMap() + } } fun desktopMouseModule(): Module = From e4509238ef8056d2be6f69641e7499887d60db69 Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Tue, 21 Apr 2026 14:20:03 +0800 Subject: [PATCH 24/38] :hammer: split MouseDaemonManager.run() into teardownClient / spawnClient / updateClientLayout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run() was an ~82 line combine().collect {} lambda mixing three control paths (disabled teardown, first-enable spawn, layout-change update). Extract a local Session bundle (client + two Jobs + last inputs) and three private methods: - teardownClient(current): stop + close client, cancel jobs, reset state - spawnClient(inputs): create client, wire event forwarding, start - updateClientLayout(current, inputs): diff port/peers, call updateLayout Plus forwardClientEvents() carved out of the inline event-routing coroutine. run() itself now reads as a four-way state-machine dispatch. Behavior preserved — all 5 manager tests still pass unchanged. --- .../crosspaste/mouse/MouseDaemonManager.kt | 134 +++++++++--------- 1 file changed, 68 insertions(+), 66 deletions(-) diff --git a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonManager.kt b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonManager.kt index 41a5163c7..02902808b 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonManager.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonManager.kt @@ -1,6 +1,7 @@ package com.crosspaste.mouse import com.crosspaste.db.sync.SyncRuntimeInfoDao +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.coroutineScope @@ -70,14 +71,18 @@ class MouseDaemonManager( ) val events: SharedFlow = _events.asSharedFlow() + /** Snapshot of an active daemon session (bundle of client + coroutines + last inputs). */ + private data class Session( + val client: MouseDaemonClient, + val runJob: Job, + val eventJob: Job, + val port: Int, + val peers: List, + ) + suspend fun run() = coroutineScope { - var activeClient: MouseDaemonClient? = null - var activeClientJob: Job? = null - var eventJob: Job? = null - var lastPeers: List? = null - var lastPort = -1 - + var session: Session? = null combine( enabledFlow, portFlow, @@ -85,73 +90,70 @@ class MouseDaemonManager( syncRuntimeInfoDao.getAllSyncRuntimeInfosFlow(), ) { enabled, port, layout, syncs -> MergedInputs(enabled, port, MousePeerMapper.map(syncs, layout)) - }.distinctUntilChanged().collect { inputs -> - if (!inputs.enabled) { - activeClient?.let { - runCatching { it.stop() } - it.close() - } - eventJob?.cancel() - activeClientJob?.cancel() - activeClient = null - activeClientJob = null - eventJob = null - lastPeers = null - lastPort = -1 - _state.value = MouseState.Disabled - return@collect + }.distinctUntilChanged() + .collect { inputs -> + session = + when { + !inputs.enabled -> teardownClient(session) + session == null -> spawnClient(inputs) + else -> updateClientLayout(session!!, inputs) + } } + } - if (activeClient == null) { - _state.value = MouseState.Starting - val client = clientFactory() - activeClient = client - activeClientJob = launch { client.run() } + /** Stop, close, and cancel the current session's coroutines; reset state. */ + private suspend fun teardownClient(current: Session?): Session? { + current?.let { + runCatching { it.client.stop() } + it.client.close() + it.eventJob.cancel() + it.runJob.cancel() + } + _state.value = MouseState.Disabled + return null + } - // Route events into state. - eventJob = - launch { - client.events.collect { ev -> - // Re-broadcast every event for UI subscribers. - _events.tryEmit(ev) - when (ev) { - is IpcEvent.PeerConnected -> - _state.value = - MouseState.Running( - connectedPeers = - (state.value as? MouseState.Running) - ?.connectedPeers - .orEmpty() + ev.name, - ) - is IpcEvent.PeerDisconnected -> - _state.value = - MouseState.Running( - connectedPeers = - (state.value as? MouseState.Running) - ?.connectedPeers - .orEmpty() - ev.name, - ) - is IpcEvent.Warning -> - _state.value = MouseState.Warning(ev.code, ev.message) - is IpcEvent.Error -> - _state.value = MouseState.Error(ev.message) - else -> Unit - } - } - } - client.start(inputs.port, inputs.peers) - lastPort = inputs.port - lastPeers = inputs.peers - return@collect - } + /** Create a new client, wire up event forwarding, send the initial `Start`. */ + private suspend fun CoroutineScope.spawnClient(inputs: MergedInputs): Session { + _state.value = MouseState.Starting + val client = clientFactory() + val runJob = launch { client.run() } + val eventJob = launch { forwardClientEvents(client.events) } + client.start(inputs.port, inputs.peers) + return Session(client, runJob, eventJob, inputs.port, inputs.peers) + } - if (lastPort != inputs.port || lastPeers != inputs.peers) { - activeClient?.updateLayout(inputs.port, inputs.peers) - lastPort = inputs.port - lastPeers = inputs.peers + /** Push a layout/port diff down to the existing session via `updateLayout`. */ + private suspend fun updateClientLayout( + current: Session, + inputs: MergedInputs, + ): Session { + if (current.port == inputs.port && current.peers == inputs.peers) { + return current + } + current.client.updateLayout(inputs.port, inputs.peers) + return current.copy(port = inputs.port, peers = inputs.peers) + } + + /** Re-broadcast every event for UI subscribers and translate into [MouseState] transitions. */ + private suspend fun forwardClientEvents(source: SharedFlow) { + source.collect { ev -> + _events.tryEmit(ev) + when (ev) { + is IpcEvent.PeerConnected -> { + val prev = (state.value as? MouseState.Running)?.connectedPeers.orEmpty() + _state.value = MouseState.Running(prev + ev.name) + } + is IpcEvent.PeerDisconnected -> { + val prev = (state.value as? MouseState.Running)?.connectedPeers.orEmpty() + _state.value = MouseState.Running(prev - ev.name) } + is IpcEvent.Warning -> _state.value = MouseState.Warning(ev.code, ev.message) + is IpcEvent.Error -> _state.value = MouseState.Error(ev.message) + else -> Unit } } + } private data class MergedInputs( val enabled: Boolean, From ddc7929a0afd1e6123d0ed5eabb8138a724d378e Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Tue, 21 Apr 2026 14:34:26 +0800 Subject: [PATCH 25/38] :hammer: restrict ScreenArrangementViewModel.seedRemote visibility to internal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Method is a test-only injection hook — make it internal so external consumers of the app module can't reach it while desktopTest (same module) retains access. The 'For tests' KDoc alone did not enforce the restriction; the repo has no @VisibleForTesting convention. --- .../com/crosspaste/ui/mouse/ScreenArrangementViewModel.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModel.kt b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModel.kt index 5bf5c96ee..acfa943b9 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModel.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModel.kt @@ -25,8 +25,8 @@ class ScreenArrangementViewModel( private val _remoteDevices = MutableStateFlow>(emptyMap()) val remoteDevices: StateFlow> = _remoteDevices.asStateFlow() - /** For tests: inject a remote without emitting an event. */ - fun seedRemote( + /** Test-only: inject a remote device without going through the event stream. */ + internal fun seedRemote( deviceId: String, name: String, screens: List, From 5310a390c23b253c58dc84299293e7a5a6f41e05 Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Tue, 21 Apr 2026 14:35:48 +0800 Subject: [PATCH 26/38] :memo: document intentional NonCancellable trade-off on MouseDaemonProcess.send() Flag for the next person to read this code: the NonCancellable wrap was chosen over caller-cancellation semantics to protect the daemon's JSON-Lines parser from truncated writes. The consequence is that close() waits on writeLock until any in-flight send finishes. Spell that out in the KDoc so nobody 'simplifies' it away. --- .../com/crosspaste/mouse/MouseDaemonProcess.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt index 575977097..cceae2dba 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt @@ -93,6 +93,19 @@ class MouseDaemonProcess internal constructor( } } + /** + * Send one command as a line on daemon stdin. Serialization + write + + * flush is wrapped in [NonCancellable] on purpose: if the caller gets + * cancelled mid-write, we'd otherwise leave a truncated JSON line in + * the daemon's stdin — the Rust JSON-Lines parser would then fail the + * next command (and every command thereafter) with a protocol error. + * + * Trade-off: a concurrent [close] call will wait on [writeLock] until + * the in-flight send completes. That's intentional — the alternative + * (yanking the writer mid-flush) would corrupt the daemon. The whole + * write is short (single line, flushed immediately) so the wait is + * bounded by OS pipe-buffer latency. + */ suspend fun send(command: IpcCommand) { val line = MouseIpcProtocol.json.encodeToString(IpcCommand.serializer(), command) writeLock.withLock { From 19ad7143067f061b365c60cc169cf22f801de1ac Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Sun, 26 Apr 2026 13:32:20 +0800 Subject: [PATCH 27/38] :sparkles: enrich ScreenInfo with display name + wallpaper via LocalScreensProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a synchronous LocalScreensProvider so the canvas can render the user's monitors even before the mouse daemon has emitted Initialized. ScreenInfo gains @Transient `name` and `wallpaperPath` populated on the desktop side (NSScreen + wallpaper PNG copy on macOS, AWT fallback elsewhere) — never crosses the daemon IPC boundary. --- .../com/crosspaste/DesktopMouseModule.kt | 9 ++ .../crosspaste/mouse/LocalScreensProvider.kt | 42 +++++++ .../mouse/MacosLocalScreensProvider.kt | 79 ++++++++++++ .../com/crosspaste/mouse/MouseIpcProtocol.kt | 15 +++ .../crosspaste/platform/macos/api/MacosApi.kt | 4 + .../ui/mouse/ScreenArrangementViewModel.kt | 15 ++- app/src/desktopMain/swift/MacosApi.swift | 115 ++++++++++++++++++ .../mouse/ScreenArrangementViewModelTest.kt | 48 ++++++++ 8 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 app/src/desktopMain/kotlin/com/crosspaste/mouse/LocalScreensProvider.kt create mode 100644 app/src/desktopMain/kotlin/com/crosspaste/mouse/MacosLocalScreensProvider.kt diff --git a/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt b/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt index 8c2a4bc85..5628c2dab 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt @@ -1,6 +1,9 @@ package com.crosspaste import com.crosspaste.config.DesktopConfigManager +import com.crosspaste.mouse.AwtLocalScreensProvider +import com.crosspaste.mouse.LocalScreensProvider +import com.crosspaste.mouse.MacosLocalScreensProvider import com.crosspaste.mouse.MouseDaemonBinary import com.crosspaste.mouse.MouseDaemonClient import com.crosspaste.mouse.MouseDaemonManager @@ -9,6 +12,7 @@ import com.crosspaste.mouse.MouseIpcProtocol import com.crosspaste.mouse.MouseLayoutStore import com.crosspaste.mouse.Position import com.crosspaste.mouse.asDaemonHandle +import com.crosspaste.platform.Platform import com.crosspaste.ui.mouse.ScreenArrangementViewModel import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.flow.Flow @@ -85,6 +89,10 @@ class DesktopAppConfigMouseLayoutBacking( fun desktopMouseModule(): Module = module { + single { + val platform = get() + if (platform.isMacos()) MacosLocalScreensProvider() else AwtLocalScreensProvider() + } single { MouseLayoutStore(DesktopAppConfigMouseLayoutBacking(get())) } @@ -113,6 +121,7 @@ fun desktopMouseModule(): Module = ScreenArrangementViewModel( events = get().events, layoutStore = get(), + localScreensProvider = get(), ) } } diff --git a/app/src/desktopMain/kotlin/com/crosspaste/mouse/LocalScreensProvider.kt b/app/src/desktopMain/kotlin/com/crosspaste/mouse/LocalScreensProvider.kt new file mode 100644 index 000000000..3f4be90f6 --- /dev/null +++ b/app/src/desktopMain/kotlin/com/crosspaste/mouse/LocalScreensProvider.kt @@ -0,0 +1,42 @@ +package com.crosspaste.mouse + +import java.awt.GraphicsEnvironment + +/** + * Source of truth for "what monitors does this machine have right now." + * + * The mouse daemon emits [IpcEvent.Initialized] with the same information, + * but only after it has been spawned. Until then — and across the brief + * window before the [com.crosspaste.ui.mouse.ScreenArrangementViewModel] + * starts collecting — we still want the canvas to render the local + * screens so the user has a visual baseline. + * + * Implementations must be safe to call on any thread; the default AWT + * implementation just queries the JVM graphics environment. + */ +fun interface LocalScreensProvider { + fun snapshot(): List +} + +class AwtLocalScreensProvider : LocalScreensProvider { + override fun snapshot(): List { + val env = + runCatching { GraphicsEnvironment.getLocalGraphicsEnvironment() }.getOrNull() + ?: return emptyList() + if (env.isHeadlessInstance) return emptyList() + val primary = env.defaultScreenDevice + return env.screenDevices.mapIndexed { index, device -> + val config = device.defaultConfiguration + val bounds = config.bounds + ScreenInfo( + id = index, + width = bounds.width, + height = bounds.height, + x = bounds.x, + y = bounds.y, + scaleFactor = config.defaultTransform.scaleX.takeIf { it > 0.0 } ?: 1.0, + isPrimary = device == primary, + ) + } + } +} diff --git a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MacosLocalScreensProvider.kt b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MacosLocalScreensProvider.kt new file mode 100644 index 000000000..3a58b0215 --- /dev/null +++ b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MacosLocalScreensProvider.kt @@ -0,0 +1,79 @@ +package com.crosspaste.mouse + +import com.crosspaste.platform.macos.api.MacosApi +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json + +/** + * Reads the local monitor list from `NSScreen` via the Swift dylib so we + * can include each display's marketing name (e.g. "DELL U2725QE", + * "LG SDQHD"). AWT's `GraphicsDevice` doesn't expose that — the only place + * macOS surfaces it through a stable public API is `NSScreen.localizedName` + * (10.15+). + * + * If the native call fails for any reason (dylib missing, JSON corrupted, + * security context restricted), we fall back to the AWT provider so the + * canvas still renders rectangles — names will just be missing. + */ +class MacosLocalScreensProvider( + private val fallback: LocalScreensProvider = AwtLocalScreensProvider(), +) : LocalScreensProvider { + + private val logger = KotlinLogging.logger {} + private val json = Json { ignoreUnknownKeys = true } + + override fun snapshot(): List { + val raw = + runCatching { MacosApi.getString(MacosApi.INSTANCE.getLocalScreensJson()) } + .getOrElse { e -> + logger.warn(e) { "getLocalScreensJson native call failed; falling back to AWT" } + null + } + ?: return fallback.snapshot() + val parsed = + runCatching { json.decodeFromString(ListSerializer(MacosScreenJson.serializer()), raw) } + .getOrElse { e -> + logger.warn(e) { "failed to parse getLocalScreensJson output; falling back to AWT" } + null + } ?: return fallback.snapshot() + if (parsed.isEmpty()) return fallback.snapshot() + return parsed.map { it.toScreenInfo().withWallpaper() } + } + + private fun ScreenInfo.withWallpaper(): ScreenInfo { + val path = + runCatching { MacosApi.getString(MacosApi.INSTANCE.getDesktopWallpaperPng(id)) } + .getOrElse { e -> + logger.warn(e) { "getDesktopWallpaperPng failed for displayId=$id" } + null + } + return copy(wallpaperPath = path?.takeIf { it.isNotBlank() }) + } + + @Serializable + private data class MacosScreenJson( + val id: Int, + val name: String? = null, + val x: Int, + val y: Int, + val width: Int, + val height: Int, + @SerialName("scaleFactor") val scaleFactor: Double, + @SerialName("isPrimary") val isPrimary: Boolean, + ) { + fun toScreenInfo(): ScreenInfo = + ScreenInfo( + id = id, + width = width, + height = height, + x = x, + y = y, + scaleFactor = scaleFactor, + isPrimary = isPrimary, + name = name?.takeIf { it.isNotBlank() }, + ) + } +} diff --git a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseIpcProtocol.kt b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseIpcProtocol.kt index 103c72cfb..84d2ced33 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseIpcProtocol.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseIpcProtocol.kt @@ -3,6 +3,7 @@ package com.crosspaste.mouse import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonClassDiscriminator @@ -30,6 +31,20 @@ data class ScreenInfo( val y: Int, @SerialName("scale_factor") val scaleFactor: Double, @SerialName("is_primary") val isPrimary: Boolean, + /** + * Human-readable monitor name (e.g. "DELL U2725QE", "Built-in Retina Display"). + * Not part of the daemon IPC contract — populated on the desktop side + * from platform APIs (`NSScreen.localizedName` on macOS) and rendered + * in the [com.crosspaste.ui.mouse.ScreenCanvas] label. + */ + @Transient val name: String? = null, + /** + * Filesystem path of a PNG copy of this monitor's desktop wallpaper. + * Same caveat as [name] — desktop-side enrichment only, never crosses + * the daemon IPC boundary. Null when the platform doesn't expose + * wallpapers or the lookup failed. + */ + @Transient val wallpaperPath: String? = null, ) @Serializable diff --git a/app/src/desktopMain/kotlin/com/crosspaste/platform/macos/api/MacosApi.kt b/app/src/desktopMain/kotlin/com/crosspaste/platform/macos/api/MacosApi.kt index e15ff54e2..73559f40c 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/platform/macos/api/MacosApi.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/platform/macos/api/MacosApi.kt @@ -49,6 +49,10 @@ interface MacosApi : Library { fun getHardwareUUID(): Pointer? + fun getLocalScreensJson(): Pointer? + + fun getDesktopWallpaperPng(displayId: Int): Pointer? + fun getCurrentActiveAppInfo(): Pointer? fun getRunningApplications(): Pointer? diff --git a/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModel.kt b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModel.kt index acfa943b9..e48e1a0e1 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModel.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModel.kt @@ -1,6 +1,7 @@ package com.crosspaste.ui.mouse import com.crosspaste.mouse.IpcEvent +import com.crosspaste.mouse.LocalScreensProvider import com.crosspaste.mouse.MouseLayoutStore import com.crosspaste.mouse.Position import com.crosspaste.mouse.ScreenInfo @@ -18,8 +19,20 @@ data class RemoteDeviceInfo( class ScreenArrangementViewModel( private val events: SharedFlow, private val layoutStore: MouseLayoutStore, + private val localScreensProvider: LocalScreensProvider? = null, ) { - private val _localScreens = MutableStateFlow>(emptyList()) + /** + * Seeded synchronously from [localScreensProvider] so the canvas can + * render the user's monitors even before the daemon spawns (or when + * the daemon's [IpcEvent.Initialized] fires before [observe] starts + * collecting — `MouseDaemonManager.events` is `replay = 0`, so a + * late subscriber never sees that event again). + * + * `Initialized` / `LocalScreens` events still take precedence — the + * daemon's reading is more authoritative since it goes through the + * native APIs the daemon will actually move the cursor across. + */ + private val _localScreens = MutableStateFlow(localScreensProvider?.snapshot().orEmpty()) val localScreens: StateFlow> = _localScreens.asStateFlow() private val _remoteDevices = MutableStateFlow>(emptyMap()) diff --git a/app/src/desktopMain/swift/MacosApi.swift b/app/src/desktopMain/swift/MacosApi.swift index 5e3cf1adf..b45d2ff94 100644 --- a/app/src/desktopMain/swift/MacosApi.swift +++ b/app/src/desktopMain/swift/MacosApi.swift @@ -218,6 +218,121 @@ public func getHardwareUUID() -> UnsafePointer? { return nil } +/// Returns the path of a downsampled PNG copy of the desktop wallpaper for +/// `displayId` (a `CGDirectDisplayID`). Reading the wallpaper file via +/// `NSWorkspace.desktopImageURL(for:)` does NOT require Screen Recording +/// permission — that permission only gates capturing live screen contents. +/// +/// Why decode + re-encode here rather than handing the original path back? +/// macOS 14+ defaults to HEIC wallpapers, and neither the JVM's `ImageIO` +/// nor Skia decode HEIC reliably across versions. Doing the conversion in +/// Swift (where Apple's image stack handles every format the OS supports) +/// keeps the Kotlin side trivially correct. +/// +/// Output is written to `NSTemporaryDirectory()/crosspaste-wallpaper-.png` +/// and overwritten on each call so the path is stable across redraws. +@_cdecl("getDesktopWallpaperPng") +public func getDesktopWallpaperPng(displayId: Int32) -> UnsafePointer? { + let target = CGDirectDisplayID(displayId) + var matched: NSScreen? + for screen in NSScreen.screens { + var sid: CGDirectDisplayID = 0 + if let raw = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? UInt32 { + sid = raw + } + if sid == target { + matched = screen + break + } + } + guard let screen = matched else { return nil } + guard let url = NSWorkspace.shared.desktopImageURL(for: screen) else { return nil } + guard let image = NSImage(contentsOf: url) else { return nil } + + // Downsample so we don't spend ~100MB of bitmap memory rendering a + // 6K wallpaper into a ~200px-wide rectangle. 1024px wide preserves + // enough detail for any zoom level the user is likely to use. + let originalSize = image.size + guard originalSize.width > 0, originalSize.height > 0 else { return nil } + let maxWidth: CGFloat = 1024 + let scale = min(1.0, maxWidth / originalSize.width) + let targetSize = NSSize( + width: max(1, originalSize.width * scale), + height: max(1, originalSize.height * scale) + ) + + let resized = NSImage(size: targetSize) + resized.lockFocus() + image.draw( + in: NSRect(origin: .zero, size: targetSize), + from: NSRect(origin: .zero, size: originalSize), + operation: .copy, + fraction: 1.0 + ) + resized.unlockFocus() + + guard let tiffData = resized.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiffData), + let pngData = bitmap.representation(using: .png, properties: [:]) + else { return nil } + + let tempPath = "\(NSTemporaryDirectory())crosspaste-wallpaper-\(displayId).png" + do { + try pngData.write(to: URL(fileURLWithPath: tempPath), options: .atomic) + } catch { + return nil + } + return UnsafePointer(strdup(tempPath)) +} + +/// Returns a JSON array describing every NSScreen, e.g. +/// `[{"id":1,"name":"DELL U2725QE","x":0,"y":0,"width":3840,"height":2160, +/// "scaleFactor":2.0,"isPrimary":true}]` +/// +/// `id` is the CGDirectDisplayID (a stable per-display identifier across +/// reboots). `name` comes from `NSScreen.localizedName` (macOS 10.15+). +/// +/// Coordinates use **CoreGraphics' top-left-origin** convention via +/// `CGDisplayBounds`, NOT `NSScreen.frame` (which is bottom-left-origin). +/// This matches what the crosspaste-mouse Rust daemon emits and what +/// Compose Canvas expects, so a "screen 2 above screen 1" arrangement +/// shows screen 2 at a smaller (more negative) y in the canvas — same +/// as the user sees in System Settings → Displays → Arrangement. +@_cdecl("getLocalScreensJson") +public func getLocalScreensJson() -> UnsafePointer? { + let screens = NSScreen.screens + let mainScreen = NSScreen.main + var items: [[String: Any]] = [] + for screen in screens { + var displayID: CGDirectDisplayID = 0 + if let raw = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? UInt32 { + displayID = raw + } else if let raw = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? Int { + displayID = CGDirectDisplayID(raw) + } + // CGDisplayBounds is top-left-origin (relative to the main display). + // NSScreen.frame would be bottom-left-origin, which the rest of the + // pipeline (daemon + Compose) treats as inverted. + let bounds = CGDisplayBounds(displayID) + let entry: [String: Any] = [ + "id": Int(displayID), + "name": screen.localizedName, + "x": Int(bounds.origin.x), + "y": Int(bounds.origin.y), + "width": Int(bounds.size.width), + "height": Int(bounds.size.height), + "scaleFactor": screen.backingScaleFactor, + "isPrimary": screen == mainScreen, + ] + items.append(entry) + } + guard + let data = try? JSONSerialization.data(withJSONObject: items, options: []), + let str = String(data: data, encoding: .utf8) + else { return nil } + return UnsafePointer(strdup(str)) +} + private func IOPlatformUUID() -> String? { let platformExpert = IOServiceGetMatchingService(kIOMasterPortDefault, IOServiceMatching("IOPlatformExpertDevice")) diff --git a/app/src/desktopTest/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModelTest.kt b/app/src/desktopTest/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModelTest.kt index 132981d0f..6334e8df7 100644 --- a/app/src/desktopTest/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModelTest.kt +++ b/app/src/desktopTest/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModelTest.kt @@ -1,6 +1,7 @@ package com.crosspaste.ui.mouse import com.crosspaste.mouse.IpcEvent +import com.crosspaste.mouse.LocalScreensProvider import com.crosspaste.mouse.MouseLayoutStore import com.crosspaste.mouse.Position import com.crosspaste.mouse.ScreenInfo @@ -82,4 +83,51 @@ class ScreenArrangementViewModelTest { vm.onDragEnd("inst-1") assertEquals(Position(-1920, 0), s.get("inst-1")) } + + @Test + fun `seeds local screens from provider at construction`() { + val provider = + LocalScreensProvider { + listOf( + ScreenInfo(0, 1920, 1080, 0, 0, 1.0, true), + ScreenInfo(1, 2560, 1440, 1920, 0, 2.0, false), + ) + } + val vm = ScreenArrangementViewModel(MutableSharedFlow(), store(), provider) + assertEquals(2, vm.localScreens.value.size) + assertEquals(2560, vm.localScreens.value[1].width) + } + + @Test + fun `daemon Initialized event overrides provider seed`() = + runTest { + val events = MutableSharedFlow(replay = 0, extraBufferCapacity = 16) + val provider = + LocalScreensProvider { + listOf(ScreenInfo(0, 1280, 720, 0, 0, 1.0, true)) + } + val vm = ScreenArrangementViewModel(events.asSharedFlow(), store(), provider) + assertEquals( + 1280, + vm.localScreens.value + .single() + .width, + ) + val job = launch { vm.observe() } + yield() + events.emit( + IpcEvent.Initialized( + screens = listOf(ScreenInfo(0, 3840, 2160, 0, 0, 2.0, true)), + protocolVersion = 2, + ), + ) + yield() + assertEquals( + 3840, + vm.localScreens.value + .single() + .width, + ) + job.cancel() + } } From 173fcf6b6933ba6ea94b099c927970afc64ef081 Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Sun, 26 Apr 2026 13:32:29 +0800 Subject: [PATCH 28/38] :bug: filter daemon stdout log spillover and treat Initialized/Ready as Running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daemon's plugin mode is supposed to keep stdout pure JSONL, but log lines (with ANSI escapes) occasionally leak there. Treat any line not starting with '{' as log spillover — log it on our side, but don't turn it into IpcEvent.Error and surface a red banner in the UI. Also map IpcEvent.Initialized / Ready to MouseState.Running so a freshly-started daemon doesn't sit in Starting forever and any prior transient warning gets cleared. --- .../crosspaste/mouse/MouseDaemonManager.kt | 10 ++++ .../crosspaste/mouse/MouseDaemonProcess.kt | 16 ++++++ .../mouse/MouseDaemonProcessTest.kt | 51 +++++++++++++++++-- 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonManager.kt b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonManager.kt index 02902808b..6f8b6c698 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonManager.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonManager.kt @@ -140,6 +140,16 @@ class MouseDaemonManager( source.collect { ev -> _events.tryEmit(ev) when (ev) { + // Initialized/Ready means the daemon successfully spun up a + // session. Treat it as "running with no connected peers + // yet" — also clears any prior Error/Warning so a transient + // failure (e.g. log spillover briefly mis-parsed before + // we filtered it out) doesn't get stuck on screen. + is IpcEvent.Initialized, is IpcEvent.Ready -> { + if (state.value !is MouseState.Running) { + _state.value = MouseState.Running(emptyList()) + } + } is IpcEvent.PeerConnected -> { val prev = (state.value as? MouseState.Running)?.connectedPeers.orEmpty() _state.value = MouseState.Running(prev + ev.name) diff --git a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt index cceae2dba..3cd19ad54 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt @@ -1,5 +1,6 @@ package com.crosspaste.mouse +import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -25,6 +26,8 @@ import java.io.OutputStream import java.io.OutputStreamWriter import java.nio.charset.StandardCharsets +private val logger = KotlinLogging.logger {} + /** * Wraps a running daemon subprocess for JSON Lines IPC. * @@ -78,6 +81,19 @@ class MouseDaemonProcess internal constructor( while (true) { val line = reader.readLine() ?: break if (line.isBlank()) continue + // The daemon's `plugin` mode is supposed to emit only + // JSON Lines on stdout and route logs through stderr, + // but in practice some log output (with ANSI color + // codes — \x1b[…) leaks onto stdout. Treat anything + // that isn't a JSON object as log spillover: log + // it on the JVM side for diagnostics, but don't + // promote it to an IpcEvent.Error that would surface + // as a red banner in the settings UI. + val firstNonWhitespace = line.firstOrNull { !it.isWhitespace() } + if (firstNonWhitespace != '{') { + logger.warn { "non-JSON line on daemon stdout (log spillover): ${line.take(200)}" } + continue + } val event = runCatching { MouseIpcProtocol.json.decodeFromString(IpcEvent.serializer(), line) diff --git a/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonProcessTest.kt b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonProcessTest.kt index be6464447..6d2ad0bd4 100644 --- a/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonProcessTest.kt +++ b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonProcessTest.kt @@ -9,6 +9,7 @@ import java.io.PipedInputStream import java.io.PipedOutputStream import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue // Uses `runBlocking` (not `runTest`) because the reader job runs on real @@ -64,8 +65,14 @@ class MouseDaemonProcessTest { } @Test - fun `malformed line emits Error event but keeps stream alive`() = + fun `non-JSON stdout line is treated as log spillover and ignored`() = runBlocking { + // The daemon's `plugin` mode occasionally lets log lines (with + // ANSI color codes, "INFO ..." text, etc.) leak onto stdout + // instead of stderr. Such lines should be logged on our side + // but must NOT surface as IpcEvent.Error — that would show up + // as a red banner in MouseSettings even though everything is + // fine. val sink = PipedOutputStream() val source = PipedInputStream(sink) val process = @@ -74,14 +81,50 @@ class MouseDaemonProcessTest { stdin = java.io.ByteArrayOutputStream(), onClose = {}, ) + sink.write("2026-04-26T03:37:51Z INFO whatever\n".toByteArray()) sink.write("not json\n".toByteArray()) sink.write("""{"event":"stopped"}""".toByteArray()) sink.write('\n'.code) sink.flush() - // NOTE: deviation from plan — the plan's `return@collect` does not - // terminate a SharedFlow collection (it only returns from the lambda - // iteration). Use `takeWhile` (inclusive of Stopped) + `toList` instead. + val collected = + withTimeout(2000) { + val events = mutableListOf() + process.events + .takeWhile { ev -> + events.add(ev) + ev !is IpcEvent.Stopped + }.toList() + events + } + assertFalse( + collected.any { it is IpcEvent.Error }, + "log spillover must not surface as Error: $collected", + ) + assertTrue(collected.any { it is IpcEvent.Stopped }) + process.close() + } + + @Test + fun `malformed JSON object still emits Error event`() = + runBlocking { + // A line that starts with '{' is unambiguously meant to be a + // protocol message — if we can't parse it that's a real bug + // worth surfacing. + val sink = PipedOutputStream() + val source = PipedInputStream(sink) + val process = + MouseDaemonProcess( + stdout = source, + stdin = java.io.ByteArrayOutputStream(), + onClose = {}, + ) + sink.write("""{"event":"stopped"""".toByteArray()) // missing closing brace + sink.write('\n'.code) + sink.write("""{"event":"stopped"}""".toByteArray()) + sink.write('\n'.code) + sink.flush() + val collected = withTimeout(2000) { val events = mutableListOf() From da383e1812d8cb230c2ba226146e855614bb3705 Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Sun, 26 Apr 2026 13:32:38 +0800 Subject: [PATCH 29/38] :sparkles: open keyboard & mouse settings in a dedicated window Replaces the in-app navigation entry with a standalone Compose Window launched from Extensions. The window is resizable and has a 600x500 px minimum size enforced on the underlying AWT window so it can grow to fit denser layouts without ever shrinking past usable. Visibility is driven by a new mouseSettingsWindowVisible state on DesktopAppWindowManager. Removes the now-orphaned commonMain MouseSettings route, the duplicate entry in AdvancedSettings, and the SettingsGraph composable registration so navigation no longer reaches the in-app screen. --- .../kotlin/com/crosspaste/ui/Route.kt | 6 -- .../crosspaste/app/DesktopAppWindowManager.kt | 11 ++++ .../com/crosspaste/ui/CrossPasteWindows.kt | 6 ++ .../kotlin/com/crosspaste/ui/DesktopRoute.kt | 2 - .../crosspaste/ui/DesktopScreenProvider.kt | 7 --- .../ui/extension/ExtensionContentView.kt | 16 ++++++ .../ui/mouse/MouseSettingsWindow.kt | 57 +++++++++++++++++++ .../settings/AdvancedSettingsContentView.kt | 10 ---- 8 files changed, 90 insertions(+), 25 deletions(-) create mode 100644 app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MouseSettingsWindow.kt diff --git a/app/src/commonMain/kotlin/com/crosspaste/ui/Route.kt b/app/src/commonMain/kotlin/com/crosspaste/ui/Route.kt index 64d21fc63..4d5073f21 100644 --- a/app/src/commonMain/kotlin/com/crosspaste/ui/Route.kt +++ b/app/src/commonMain/kotlin/com/crosspaste/ui/Route.kt @@ -133,12 +133,6 @@ object NetworkSettings : Route { override val name: String = NAME } -@Serializable -object MouseSettings : Route { - const val NAME: String = "mouse_settings" - override val name: String = NAME -} - @Serializable object StorageSettings : Route { const val NAME: String = "storage_settings" diff --git a/app/src/desktopMain/kotlin/com/crosspaste/app/DesktopAppWindowManager.kt b/app/src/desktopMain/kotlin/com/crosspaste/app/DesktopAppWindowManager.kt index 111a55880..d97685cdc 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/app/DesktopAppWindowManager.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/app/DesktopAppWindowManager.kt @@ -164,6 +164,17 @@ abstract class DesktopAppWindowManager( fun isBubbleWindowVisible(): Boolean = _bubbleWindowInfo.value.show + private val _mouseSettingsWindowVisible = MutableStateFlow(false) + val mouseSettingsWindowVisible: StateFlow = _mouseSettingsWindowVisible + + fun showMouseSettingsWindow() { + _mouseSettingsWindowVisible.value = true + } + + fun hideMouseSettingsWindow() { + _mouseSettingsWindowVisible.value = false + } + abstract suspend fun focusBubbleWindow() abstract fun startWindowService() diff --git a/app/src/desktopMain/kotlin/com/crosspaste/ui/CrossPasteWindows.kt b/app/src/desktopMain/kotlin/com/crosspaste/ui/CrossPasteWindows.kt index 1d0ea76a3..129f56dde 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/ui/CrossPasteWindows.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/ui/CrossPasteWindows.kt @@ -11,6 +11,7 @@ import com.crosspaste.app.generated.resources.Res import com.crosspaste.app.generated.resources.crosspaste import com.crosspaste.app.generated.resources.crosspaste_mac import com.crosspaste.platform.Platform +import com.crosspaste.ui.mouse.MouseSettingsWindow import com.crosspaste.ui.tray.MacTrayView import com.crosspaste.ui.tray.NonMacTrayView import org.jetbrains.compose.resources.painterResource @@ -49,4 +50,9 @@ fun ApplicationScope.CrossPasteWindows(exiting: Boolean) { if (bubbleWindowInfo.show) { BubbleWindow(windowIcon) } + + val mouseSettingsVisible by appWindowManager.mouseSettingsWindowVisible.collectAsState() + if (mouseSettingsVisible) { + MouseSettingsWindow(windowIcon) + } } diff --git a/app/src/desktopMain/kotlin/com/crosspaste/ui/DesktopRoute.kt b/app/src/desktopMain/kotlin/com/crosspaste/ui/DesktopRoute.kt index ab890db35..bcd117fce 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/ui/DesktopRoute.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/ui/DesktopRoute.kt @@ -22,7 +22,6 @@ fun getRouteName(dest: NavDestination): String? = dest.hasRoute() -> Settings.NAME dest.hasRoute() -> PasteboardSettings.NAME dest.hasRoute() -> NetworkSettings.NAME - dest.hasRoute() -> MouseSettings.NAME dest.hasRoute() -> StorageSettings.NAME dest.hasRoute() -> ShortcutKeys.NAME else -> null @@ -47,7 +46,6 @@ fun getRootRouteName(dest: NavDestination): String? = dest.hasRoute() -> Settings.NAME dest.hasRoute() -> Settings.NAME dest.hasRoute() -> Settings.NAME - dest.hasRoute() -> Settings.NAME dest.hasRoute() -> Settings.NAME dest.hasRoute() -> ShortcutKeys.NAME else -> null diff --git a/app/src/desktopMain/kotlin/com/crosspaste/ui/DesktopScreenProvider.kt b/app/src/desktopMain/kotlin/com/crosspaste/ui/DesktopScreenProvider.kt index f8cb3570c..6bc0e0c9e 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/ui/DesktopScreenProvider.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/ui/DesktopScreenProvider.kt @@ -42,7 +42,6 @@ import com.crosspaste.ui.extension.ExtensionContentView import com.crosspaste.ui.extension.mcp.McpContentView import com.crosspaste.ui.extension.ocr.OCRContentView import com.crosspaste.ui.extension.sourcecontrol.SourceControlContentView -import com.crosspaste.ui.mouse.MouseSettingsScreen import com.crosspaste.ui.paste.PasteExportContentView import com.crosspaste.ui.paste.PasteImportContentView import com.crosspaste.ui.settings.DesktopNetworkSettingsContentView @@ -194,12 +193,6 @@ class DesktopScreenProvider( ) { NetworkSettingsScreen() } - composable( - exitTransition = { slideOutRight() }, - enterTransition = { slideInLeft() }, - ) { - MouseSettingsScreen() - } composable( exitTransition = { slideOutRight() }, enterTransition = { slideInLeft() }, diff --git a/app/src/desktopMain/kotlin/com/crosspaste/ui/extension/ExtensionContentView.kt b/app/src/desktopMain/kotlin/com/crosspaste/ui/extension/ExtensionContentView.kt index 75417e56e..7ace6de40 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/ui/extension/ExtensionContentView.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/ui/extension/ExtensionContentView.kt @@ -15,6 +15,8 @@ import com.composables.icons.materialsymbols.MaterialSymbols import com.composables.icons.materialsymbols.rounded.Block import com.composables.icons.materialsymbols.rounded.Code import com.composables.icons.materialsymbols.rounded.Document_scanner +import com.composables.icons.materialsymbols.rounded.Mouse +import com.crosspaste.app.DesktopAppWindowManager import com.crosspaste.ui.LocalThemeExtState import com.crosspaste.ui.MCP import com.crosspaste.ui.NavigationManager @@ -32,6 +34,7 @@ import org.koin.compose.koinInject @Composable fun ExtensionContentView() { val navigateManager = koinInject() + val appWindowManager = koinInject() val themeExt = LocalThemeExtState.current var isProxyExpanded by remember { mutableStateOf(false) } @@ -91,6 +94,19 @@ fun ExtensionContentView() { navigateManager.navigate(SourceControl) }, ) + HorizontalDivider(modifier = Modifier.padding(start = xxxxLarge)) + SettingListItem( + title = "mouse_settings", + subtitle = "mouse_settings_desc", + icon = + IconData( + imageVector = MaterialSymbols.Rounded.Mouse, + iconColor = themeExt.purpleIconColor, + ), + onClick = { + appWindowManager.showMouseSettingsWindow() + }, + ) } } } diff --git a/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MouseSettingsWindow.kt b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MouseSettingsWindow.kt new file mode 100644 index 000000000..8513bd628 --- /dev/null +++ b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MouseSettingsWindow.kt @@ -0,0 +1,57 @@ +package com.crosspaste.ui.mouse + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.rememberWindowState +import com.crosspaste.app.DesktopAppWindowManager +import com.crosspaste.i18n.GlobalCopywriter +import org.koin.compose.koinInject +import java.awt.Dimension + +private val INITIAL_SIZE = DpSize(720.dp, 640.dp) +private val MIN_SIZE = Dimension(600, 500) + +@Composable +fun MouseSettingsWindow(windowIcon: Painter?) { + val appWindowManager = koinInject() + val copywriter = koinInject() + + val windowState = + rememberWindowState( + size = INITIAL_SIZE, + position = WindowPosition(Alignment.Center), + ) + + Window( + onCloseRequest = { appWindowManager.hideMouseSettingsWindow() }, + state = windowState, + title = copywriter.getText("mouse_settings"), + icon = windowIcon, + resizable = true, + ) { + DisposableEffect(window) { + window.minimumSize = MIN_SIZE + onDispose {} + } + + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + ) { + MouseSettingsScreen() + } + } +} diff --git a/app/src/desktopMain/kotlin/com/crosspaste/ui/settings/AdvancedSettingsContentView.kt b/app/src/desktopMain/kotlin/com/crosspaste/ui/settings/AdvancedSettingsContentView.kt index 9944362cc..bb3121be0 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/ui/settings/AdvancedSettingsContentView.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/ui/settings/AdvancedSettingsContentView.kt @@ -6,11 +6,9 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.composables.icons.materialsymbols.MaterialSymbols import com.composables.icons.materialsymbols.rounded.Content_paste -import com.composables.icons.materialsymbols.rounded.Mouse import com.composables.icons.materialsymbols.rounded.Storage import com.composables.icons.materialsymbols.rounded.Wifi import com.crosspaste.ui.LocalThemeExtState -import com.crosspaste.ui.MouseSettings import com.crosspaste.ui.NavigationManager import com.crosspaste.ui.NetworkSettings import com.crosspaste.ui.PasteboardSettings @@ -40,14 +38,6 @@ fun AdvancedSettingsContentView() { navigationManager.navigate(NetworkSettings) } HorizontalDivider(modifier = Modifier.padding(start = xxxxLarge)) - SettingListItem( - title = "mouse_settings", - subtitle = "mouse_settings_desc", - icon = IconData(MaterialSymbols.Rounded.Mouse, themeExt.purpleIconColor), - ) { - navigationManager.navigate(MouseSettings) - } - HorizontalDivider(modifier = Modifier.padding(start = xxxxLarge)) SettingListItem( title = "storage_settings", subtitle = "storage_settings_desc", From 773f95864fd9a0d9d1d553665fb7d0b16da3ed2d Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Sun, 26 Apr 2026 13:32:51 +0800 Subject: [PATCH 30/38] :sparkles: stretch screen canvas, clip overflow, and clamp pan/zoom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MouseSettingsScreen now fills the whole window so the canvas grows with it (both width and height). The canvas itself adds Modifier .clipToBounds() so screen rectangles, wallpapers, and labels at extreme pan/zoom can no longer bleed onto neighbouring UI. Pan and zoom now respect a world-bounding-box-aware clamp: - Pan keeps at least 80 px of bounds visible against every viewport edge so the layout can never drift entirely off-canvas. - Zoom-out floor is fixed at 50 dp per reference 1080p screen via LocalDensity, independent of window size — maximising the window no longer pushes the floor up to fit-the-bounds. - Zoom-in ceiling stays at 1:1. Viewport dimensions now come from BoxWithConstraints.constraints (px) instead of maxWidth/maxHeight (dp). The dp-as-px mismatch had been silently halving the right/bottom pan reach on HiDPI screens. Screen labels rendered with TextMeasurer: font size scales with the rect's smaller side, text is wrapped/ellipsised to fit inside the rect, and a translucent rounded backdrop keeps it legible over any wallpaper. --- .../ui/mouse/MouseSettingsScreen.kt | 8 +- .../com/crosspaste/ui/mouse/ScreenCanvas.kt | 485 +++++++++++++++--- 2 files changed, 406 insertions(+), 87 deletions(-) diff --git a/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MouseSettingsScreen.kt b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MouseSettingsScreen.kt index b9fce66a7..6eee93eae 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MouseSettingsScreen.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MouseSettingsScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -59,7 +60,7 @@ fun MouseSettingsScreen() { ) Column( - modifier = Modifier.padding(AppUISize.medium), + modifier = Modifier.fillMaxSize().padding(AppUISize.medium), verticalArrangement = Arrangement.spacedBy(AppUISize.medium), ) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { @@ -91,6 +92,9 @@ fun MouseSettingsScreen() { style = MaterialTheme.typography.bodyMedium, ) Spacer(Modifier.height(AppUISize.small)) - ScreenCanvas(viewModel = viewModel, modifier = Modifier.fillMaxWidth()) + ScreenCanvas( + viewModel = viewModel, + modifier = Modifier.fillMaxWidth().weight(1f), + ) } } diff --git a/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenCanvas.kt b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenCanvas.kt index eb6606f4f..873a8a0d1 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenCanvas.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenCanvas.kt @@ -3,33 +3,84 @@ package com.crosspaste.ui.mouse import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.toComposeImageBitmap +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.drawText import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.crosspaste.mouse.Position import com.crosspaste.mouse.ScreenInfo -import com.crosspaste.ui.theme.AppUISize +import org.jetbrains.skia.Image +import java.io.File /** - * Canvas that lets the user drag remote devices around a virtual desktop. - * Local screens are locked at (0, 0). Each remote device is a rigid group - * of its own screens; dragging any screen of the group shifts the group's - * [Position] offset. + * Infinite-canvas view of the mouse-sharing layout. + * + * Coordinate system: + * world = px in the daemon's virtual desktop (the units `Position` uses) + * view = px on the Compose canvas + * view = pan + world * scale + * + * Gestures: + * - drag on empty space → pan the canvas + * - long-press on a remote device's screen + drag → move that device's + * entire screen group (its [Position] offset). Locals are pinned at + * world (0, 0) — only the remote layout is editable. + * - mouse wheel → zoom around the cursor position. View coords under + * the cursor stay anchored to the same world coords across zoom, so + * the rectangle the user is pointing at doesn't drift away. */ +private const val INITIAL_SCALE = 0.08f + +private const val ABSOLUTE_MAX_SCALE = 1f +private const val ZOOM_STEP = 1.1f + +// Lower zoom-out limit, expressed as "a 1080p-class display never renders +// smaller than [MIN_VISIBLE_SCREEN_DP] across on the canvas". Anchoring the +// floor to a dp size (rather than a fraction of the viewport) means the +// minimum scale stays the same when the user resizes the window — without +// this, a maximised window would push the minimum up to where the layout +// already fills the canvas, blocking further zoom-out. +private val MIN_VISIBLE_SCREEN_DP = 50.dp +private const val REFERENCE_SCREEN_WORLD_WIDTH = 1920f + +// When panning, keep at least this many view px of the bounding box visible +// against every viewport edge so the user can't fling everything off-canvas. +private const val PAN_VISIBLE_MARGIN = 80f + +@OptIn(ExperimentalComposeUiApi::class) @Composable fun ScreenCanvas( viewModel: ScreenArrangementViewModel, @@ -38,63 +89,154 @@ fun ScreenCanvas( val locals by viewModel.localScreens.collectAsState() val remotes by viewModel.remoteDevices.collectAsState() val measurer: TextMeasurer = rememberTextMeasurer() + val wallpapers = rememberWallpaperBitmaps(locals) + val density = LocalDensity.current + + BoxWithConstraints( + modifier = modifier, + ) { + // Use the px-valued layout constraints — pointer drag deltas, scroll + // events, and DrawScope all operate in pixels, so deriving viewport + // dimensions from `maxWidth.value` (a Dp) silently mis-scaled the pan + // limits on HiDPI screens (density > 1), which made the canvas + // un-pannable past the right and bottom edges. + val viewportWidth = constraints.maxWidth.toFloat().coerceAtLeast(1f) + val viewportHeight = constraints.maxHeight.toFloat().coerceAtLeast(1f) + + // Bounding box of every screen (locals + remotes) in world units. + // Used for pan clamping so the layout can't drift entirely off-canvas. + val bounds = remember(locals, remotes) { computeWorldBounds(locals, remotes) } - BoxWithConstraints(modifier = modifier.fillMaxWidth().height(AppUISize.xxxxLarge * 6)) { - val viewportPx = maxWidth.value.coerceAtLeast(400f) - val (bounds, scale) = - remember(locals, remotes, viewportPx) { - computeBoundsAndScale(locals, remotes, viewportPx) + // Zoom limits are window-independent: a fixed dp-based floor (tied to + // a reference 1080p display) and a 1:1 view-px ceiling. Recomputed + // only when density changes so HiDPI screens see the same dp limit. + val minScale = + remember(density) { + with(density) { MIN_VISIBLE_SCREEN_DP.toPx() } / REFERENCE_SCREEN_WORLD_WIDTH } + val maxScale = ABSOLUTE_MAX_SCALE + + var scale by remember { mutableStateOf(INITIAL_SCALE) } + + // Track whether the user has manually panned. Until then, recenter + // automatically whenever the local-screens layout changes (so the + // first frame doesn't paint everything off-canvas). + var pan by remember { mutableStateOf(Offset.Zero) } + var userPanned by remember { mutableStateOf(false) } + LaunchedEffect(locals, remotes, viewportWidth, viewportHeight, scale) { + val clampedScale = scale.coerceIn(minScale, maxScale) + if (clampedScale != scale) { + scale = clampedScale + return@LaunchedEffect + } + pan = + if (!userPanned) { + centerLocals(locals, viewportWidth, viewportHeight, clampedScale) + } else { + clampPan(pan, bounds, clampedScale, viewportWidth, viewportHeight) + } + } + + var draggingDeviceId by remember { mutableStateOf(null) } Canvas( modifier = Modifier - .fillMaxWidth() - .height(AppUISize.xxxxLarge * 6) + .fillMaxSize() .background(Color(0xFFF2F2F2)) - .pointerInput(remotes.keys) { - detectDragGestures( + // Clip drawing to the canvas rect so screen rectangles + // (and their wallpapers/labels) at extreme pan/zoom + // can't bleed into the surrounding UI. + .clipToBounds() + // Mouse wheel → zoom about the cursor. We re-anchor pan + // so that the world point under the cursor stays fixed: + // pivotWorld = (cursor - pan) / scaleOld + // pan' = cursor - pivotWorld * scaleNew + .onPointerEvent(PointerEventType.Scroll) { event -> + val change = event.changes.firstOrNull() ?: return@onPointerEvent + val scrollY = change.scrollDelta.y + if (scrollY == 0f) return@onPointerEvent + val factor = if (scrollY < 0f) ZOOM_STEP else 1f / ZOOM_STEP + val newScale = (scale * factor).coerceIn(minScale, maxScale) + if (newScale == scale) return@onPointerEvent + val cursor = change.position + val pivotWorld = + Offset((cursor.x - pan.x) / scale, (cursor.y - pan.y) / scale) + val pivotedPan = + Offset( + cursor.x - pivotWorld.x * newScale, + cursor.y - pivotWorld.y * newScale, + ) + pan = clampPan(pivotedPan, bounds, newScale, viewportWidth, viewportHeight) + scale = newScale + userPanned = true + change.consume() + } + // Long-press on a device screen → drag that device's + // whole group. Compose's long-press detector waits for + // the long-press threshold before claiming the gesture, + // so a short drag falls through to the pan handler below. + .pointerInput(remotes.keys, pan, scale) { + detectDragGesturesAfterLongPress( + onDragStart = { offset -> + draggingDeviceId = hitTestDevice(remotes, offset, pan, scale) + }, onDrag = { change, drag -> change.consume() - val hitId = - remotes.entries - .firstOrNull { (_, info) -> - info.screens.any { screen -> - val rect = toRect(info.position, screen, bounds, scale) - rect.contains(change.position) - } - }?.key ?: return@detectDragGestures + val id = draggingDeviceId ?: return@detectDragGesturesAfterLongPress viewModel.onDragDevice( - deviceId = hitId, + deviceId = id, dx = (drag.x / scale).toInt(), dy = (drag.y / scale).toInt(), ) }, onDragEnd = { - remotes.keys.forEach { viewModel.onDragEnd(it) } + draggingDeviceId?.let { viewModel.onDragEnd(it) } + draggingDeviceId = null }, + onDragCancel = { draggingDeviceId = null }, ) + } + // Plain drag → pan the infinite canvas. + // Keyed on bounds + viewport so the closure captures fresh + // values whenever a remote moves or the window resizes; + // otherwise the clamp would use stale limits. + .pointerInput(bounds, viewportWidth, viewportHeight) { + detectDragGestures { change, drag -> + change.consume() + pan = clampPan(pan + drag, bounds, scale, viewportWidth, viewportHeight) + userPanned = true + } }, ) { - // Draw local screens (blue), origin-locked - locals.forEach { screen -> - val rect = toRect(Position(0, 0), screen, bounds, scale) - drawRect( - color = Color(0xFF1E88E5).copy(alpha = 0.35f), - topLeft = rect.topLeft, - size = rect.size, - ) + // Local screens (blue) — origin-locked at world (0, 0). Each + // screen's own (x, y) places it relative to the device origin + // (e.g. a second monitor at world x = 1920). + locals.forEachIndexed { index, screen -> + val rect = toRect(Position(0, 0), screen, pan, scale) + val bitmap = screen.wallpaperPath?.let { wallpapers[it] } + if (bitmap != null) { + drawWallpaper(bitmap, rect) + } else { + drawRect( + color = Color(0xFF1E88E5).copy(alpha = 0.35f), + topLeft = rect.topLeft, + size = rect.size, + ) + } drawRect( color = Color(0xFF1E88E5), topLeft = rect.topLeft, size = rect.size, style = Stroke(width = 2f), ) + drawScreenLabel(measurer, localScreenLabel(index, screen), rect) } - // Draw each remote device's screens (orange), as a group + // Remote devices (orange) — every screen of one device shares the + // device's [Position] offset and moves as one rigid group. remotes.forEach { (_, info) -> info.screens.forEach { screen -> - val rect = toRect(info.position, screen, bounds, scale) + val rect = toRect(info.position, screen, pan, scale) drawRect( color = Color(0xFFFB8C00).copy(alpha = 0.35f), topLeft = rect.topLeft, @@ -106,22 +248,13 @@ fun ScreenCanvas( size = rect.size, style = Stroke(width = 2f), ) - drawText( - textMeasurer = measurer, - text = AnnotatedString(info.name), - topLeft = rect.topLeft + Offset(4f, 4f), - ) + drawScreenLabel(measurer, info.name, rect) } } } } } -private data class WorldBounds( - val minX: Int, - val minY: Int, -) - private data class RectFloats( val topLeft: Offset, val size: Size, @@ -131,52 +264,234 @@ private data class RectFloats( p.y in topLeft.y..(topLeft.y + size.height) } -private fun computeBoundsAndScale( - locals: List, - remotes: Map, - viewportPx: Float, -): Pair { - val allRects = - buildList { - locals.forEach { add(IntRect(0, 0, it.width, it.height)) } - remotes.values.forEach { info -> - info.screens.forEach { - add( - IntRect( - info.position.x, - info.position.y, - info.position.x + it.width, - info.position.y + it.height, - ), - ) - } - } - } - val minX = allRects.minOfOrNull { it.left } ?: 0 - val minY = allRects.minOfOrNull { it.top } ?: 0 - val maxX = allRects.maxOfOrNull { it.right } ?: 1920 - val worldWidth = (maxX - minX).coerceAtLeast(1920) - val scale = ((viewportPx - 40f) / worldWidth).coerceAtLeast(0.01f) - return WorldBounds(minX, minY) to scale -} - +/** + * Map (device-position + screen.x/screen.y) into view coordinates. + * + * Earlier versions of this function ignored `screen.x` / `screen.y`, which + * is why every screen of a multi-monitor device drew at the device origin + * and the rectangles overlapped. The within-device offset is what places a + * second monitor to the right of the primary on the virtual desktop. + */ private fun toRect( position: Position, screen: ScreenInfo, - bounds: WorldBounds, + pan: Offset, scale: Float, ): RectFloats { - val x = (position.x - bounds.minX) * scale + 20f - val y = (position.y - bounds.minY) * scale + 20f + val worldX = position.x + screen.x + val worldY = position.y + screen.y return RectFloats( - topLeft = Offset(x, y), + topLeft = Offset(pan.x + worldX * scale, pan.y + worldY * scale), size = Size(screen.width * scale, screen.height * scale), ) } -private data class IntRect( - val left: Int, - val top: Int, - val right: Int, - val bottom: Int, -) +private fun hitTestDevice( + remotes: Map, + pointer: Offset, + pan: Offset, + scale: Float, +): String? = + remotes.entries + .firstOrNull { (_, info) -> + info.screens.any { screen -> + toRect(info.position, screen, pan, scale).contains(pointer) + } + }?.key + +private data class WorldBounds( + val minX: Float, + val minY: Float, + val maxX: Float, + val maxY: Float, +) { + val width: Float get() = maxX - minX + val height: Float get() = maxY - minY +} + +private fun computeWorldBounds( + locals: List, + remotes: Map, +): WorldBounds? { + var minX = Float.POSITIVE_INFINITY + var minY = Float.POSITIVE_INFINITY + var maxX = Float.NEGATIVE_INFINITY + var maxY = Float.NEGATIVE_INFINITY + var any = false + locals.forEach { + any = true + minX = minOf(minX, it.x.toFloat()) + minY = minOf(minY, it.y.toFloat()) + maxX = maxOf(maxX, (it.x + it.width).toFloat()) + maxY = maxOf(maxY, (it.y + it.height).toFloat()) + } + remotes.values.forEach { info -> + info.screens.forEach { s -> + any = true + minX = minOf(minX, (info.position.x + s.x).toFloat()) + minY = minOf(minY, (info.position.y + s.y).toFloat()) + maxX = maxOf(maxX, (info.position.x + s.x + s.width).toFloat()) + maxY = maxOf(maxY, (info.position.y + s.y + s.height).toFloat()) + } + } + if (!any || maxX <= minX || maxY <= minY) return null + return WorldBounds(minX, minY, maxX, maxY) +} + +/** + * Pull [pan] back so the bounding box can never escape the viewport entirely: + * at least [PAN_VISIBLE_MARGIN] view px of it must remain visible against + * each edge. When `bounds` is null (nothing on canvas yet) we leave pan alone. + */ +private fun clampPan( + pan: Offset, + bounds: WorldBounds?, + scale: Float, + viewportWidth: Float, + viewportHeight: Float, +): Offset { + if (bounds == null) return pan + val margin = PAN_VISIBLE_MARGIN + val viewMinX = bounds.minX * scale + val viewMaxX = bounds.maxX * scale + val viewMinY = bounds.minY * scale + val viewMaxY = bounds.maxY * scale + val minPanX = margin - viewMaxX + val maxPanX = viewportWidth - margin - viewMinX + val minPanY = margin - viewMaxY + val maxPanY = viewportHeight - margin - viewMinY + return Offset( + pan.x.coerceIn(minPanX, maxPanX), + pan.y.coerceIn(minPanY, maxPanY), + ) +} + +private fun centerLocals( + locals: List, + viewportWidth: Float, + viewportHeight: Float, + scale: Float, +): Offset { + if (locals.isEmpty()) return Offset(viewportWidth / 2f, viewportHeight / 2f) + val minX = locals.minOf { it.x } + val minY = locals.minOf { it.y } + val maxX = locals.maxOf { it.x + it.width } + val maxY = locals.maxOf { it.y + it.height } + val centerWorld = Offset((minX + maxX) / 2f, (minY + maxY) / 2f) + return Offset( + x = viewportWidth / 2f - centerWorld.x * scale, + y = viewportHeight / 2f - centerWorld.y * scale, + ) +} + +private fun localScreenLabel( + index: Int, + screen: ScreenInfo, +): String { + val display = screen.name?.takeIf { it.isNotBlank() } ?: "Display ${index + 1}" + val tag = if (screen.isPrimary) "$display (Primary)" else display + return "$tag\n${screen.width}×${screen.height}" +} + +/** + * Draw a label centered in the given screen rect with a translucent backdrop + * so the text stays legible over arbitrary wallpapers. Font size scales with + * the rect (so it tracks zoom), and the layout is constrained to the rect's + * inner bounds — overflowing characters get ellipsized rather than spilling + * outside the screen. + */ +private fun DrawScope.drawScreenLabel( + measurer: TextMeasurer, + text: String, + rect: RectFloats, +) { + if (rect.size.width < 24f || rect.size.height < 24f) return + + val outerPadding = 4f + val maxWidth = (rect.size.width - 2 * outerPadding).toInt() + val maxHeight = (rect.size.height - 2 * outerPadding).toInt() + if (maxWidth <= 0 || maxHeight <= 0) return + + // Tie font size to the smaller side of the rect so labels feel + // proportional, and convert to sp through the current Density so the + // result matches user-perceived size on HiDPI displays. + val targetPx = minOf(rect.size.width, rect.size.height) * 0.14f + val fontSize = (targetPx / density).coerceIn(8f, 22f).sp + + val style = + TextStyle( + fontSize = fontSize, + textAlign = TextAlign.Center, + color = Color.White, + ) + + val layout = + measurer.measure( + text = AnnotatedString(text), + style = style, + constraints = Constraints(maxWidth = maxWidth, maxHeight = maxHeight), + overflow = TextOverflow.Ellipsis, + softWrap = true, + maxLines = 3, + ) + + val w = layout.size.width.toFloat() + val h = layout.size.height.toFloat() + if (w <= 0f || h <= 0f) return + + val x = rect.topLeft.x + (rect.size.width - w) / 2f + val y = rect.topLeft.y + (rect.size.height - h) / 2f + + val bgPadding = 4f + drawRoundRect( + color = Color.Black.copy(alpha = 0.55f), + topLeft = Offset(x - bgPadding, y - bgPadding), + size = Size(w + 2 * bgPadding, h + 2 * bgPadding), + cornerRadius = CornerRadius(4f, 4f), + ) + drawText( + textLayoutResult = layout, + topLeft = Offset(x, y), + ) +} + +private fun DrawScope.drawWallpaper( + bitmap: ImageBitmap, + rect: RectFloats, +) { + val width = + rect.size.width + .toInt() + .coerceAtLeast(1) + val height = + rect.size.height + .toInt() + .coerceAtLeast(1) + drawImage( + image = bitmap, + dstOffset = IntOffset(rect.topLeft.x.toInt(), rect.topLeft.y.toInt()), + dstSize = IntSize(width, height), + ) +} + +/** + * Decode each unique wallpaper PNG once and keep [ImageBitmap]s alive across + * recomposition. We key on the path list so adding/removing/swapping a screen + * triggers a reload, but redraws (pan, zoom, drag) hit the cache. + * + * Skia decodes PNG/JPEG natively. HEIC is handled in Swift before we get + * here — see `getDesktopWallpaperPng` in MacosApi.swift. + */ +@Composable +private fun rememberWallpaperBitmaps(screens: List): Map { + val paths = screens.mapNotNull { it.wallpaperPath }.distinct() + return remember(paths) { + paths + .mapNotNull { path -> + runCatching { + val bytes = File(path).readBytes() + path to Image.makeFromEncoded(bytes).toComposeImageBitmap() + }.getOrNull() + }.toMap() + } +} From 0a978c47b2451f3bd1d0041e7e3415efcee872ca Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Sun, 26 Apr 2026 13:33:00 +0800 Subject: [PATCH 31/38] :sparkles: rename mouse_settings to "Keyboard & Mouse" and complete locale coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames the entry label in en/zh from "Mouse" / "鼠标共享" to "Keyboard & Mouse" / "键鼠共享" — the feature shares keyboard and mouse, and the surrounding strings already reflected that. Adds the full mouse_settings key set (10 entries: title, desc, canvas help, 5 state strings, switch label, dialog OK) for de, es, fa, fr, ja, ko, pt, and zh_hant; previously they fell back to en. --- app/src/desktopMain/resources/i18n/de.properties | 10 ++++++++++ app/src/desktopMain/resources/i18n/en.properties | 2 +- app/src/desktopMain/resources/i18n/es.properties | 10 ++++++++++ app/src/desktopMain/resources/i18n/fa.properties | 10 ++++++++++ app/src/desktopMain/resources/i18n/fr.properties | 10 ++++++++++ app/src/desktopMain/resources/i18n/ja.properties | 10 ++++++++++ app/src/desktopMain/resources/i18n/ko.properties | 10 ++++++++++ app/src/desktopMain/resources/i18n/pt.properties | 10 ++++++++++ app/src/desktopMain/resources/i18n/zh.properties | 2 +- app/src/desktopMain/resources/i18n/zh_hant.properties | 10 ++++++++++ 10 files changed, 82 insertions(+), 2 deletions(-) diff --git a/app/src/desktopMain/resources/i18n/de.properties b/app/src/desktopMain/resources/i18n/de.properties index 691c62fc9..4a7327230 100644 --- a/app/src/desktopMain/resources/i18n/de.properties +++ b/app/src/desktopMain/resources/i18n/de.properties @@ -147,6 +147,16 @@ mcp_settings_desc=Model Context Protocol Server-Einstellungen menu=Menü missing_file=Fehlende Datei month=Monat +mouse_settings=Tastatur & Maus +mouse_settings.canvas.help=Ziehen Sie ein Gerät, um seine Bildschirme relativ zu Ihren anzuordnen. +mouse_settings.state.disabled=Aus +mouse_settings.state.error_prefix=Fehler: %s +mouse_settings.state.running_n_peers=Verbunden: %s Gerät(e) +mouse_settings.state.starting=Wird gestartet… +mouse_settings.state.warning_prefix=Warnung: %s +mouse_settings.switch=Tastatur/Maus teilen +mouse_settings.warning_dialog.ok=OK +mouse_settings_desc=Tastatur und Maus zwischen gekoppelten Geräten teilen my_devices=Meine Geräte nearby_device_detail=Details zum Gerät in der Nähe nearby_devices=Geräte in der Nähe diff --git a/app/src/desktopMain/resources/i18n/en.properties b/app/src/desktopMain/resources/i18n/en.properties index c889ff8eb..df55fccf0 100644 --- a/app/src/desktopMain/resources/i18n/en.properties +++ b/app/src/desktopMain/resources/i18n/en.properties @@ -147,7 +147,7 @@ mcp_settings_desc=Model Context Protocol server settings menu=Menu missing_file=Missing File month=Month -mouse_settings=Mouse +mouse_settings=Keyboard & Mouse mouse_settings.canvas.help=Drag a device to position its screens relative to yours. mouse_settings.state.disabled=Off mouse_settings.state.error_prefix=Error: %s diff --git a/app/src/desktopMain/resources/i18n/es.properties b/app/src/desktopMain/resources/i18n/es.properties index f12f351e1..8c8ccaa1b 100644 --- a/app/src/desktopMain/resources/i18n/es.properties +++ b/app/src/desktopMain/resources/i18n/es.properties @@ -147,6 +147,16 @@ mcp_settings_desc=Configuración del servidor Model Context Protocol menu=Menú missing_file=Archivo faltante month=Mes +mouse_settings=Teclado y ratón +mouse_settings.canvas.help=Arrastra un dispositivo para colocar sus pantallas en relación con las tuyas. +mouse_settings.state.disabled=Desactivado +mouse_settings.state.error_prefix=Error: %s +mouse_settings.state.running_n_peers=Conectado: %s dispositivo(s) +mouse_settings.state.starting=Iniciando… +mouse_settings.state.warning_prefix=Advertencia: %s +mouse_settings.switch=Compartir teclado/ratón +mouse_settings.warning_dialog.ok=Aceptar +mouse_settings_desc=Comparte teclado y ratón entre dispositivos emparejados my_devices=Mis dispositivos nearby_device_detail=Detalles del dispositivo cercano nearby_devices=Dispositivos cercanos diff --git a/app/src/desktopMain/resources/i18n/fa.properties b/app/src/desktopMain/resources/i18n/fa.properties index ac68ebccd..eac9b7cac 100644 --- a/app/src/desktopMain/resources/i18n/fa.properties +++ b/app/src/desktopMain/resources/i18n/fa.properties @@ -147,6 +147,16 @@ mcp_settings_desc=تنظیمات سرور Model Context Protocol menu=منو missing_file=فایل مفقود شده month=ماه +mouse_settings=صفحه‌کلید و موس +mouse_settings.canvas.help=یک دستگاه را بکشید تا صفحه‌نمایش‌های آن نسبت به دستگاه شما قرار گیرند. +mouse_settings.state.disabled=خاموش +mouse_settings.state.error_prefix=خطا: %s +mouse_settings.state.running_n_peers=متصل: %s دستگاه +mouse_settings.state.starting=در حال راه‌اندازی… +mouse_settings.state.warning_prefix=هشدار: %s +mouse_settings.switch=اشتراک صفحه‌کلید/موس +mouse_settings.warning_dialog.ok=تأیید +mouse_settings_desc=اشتراک‌گذاری صفحه‌کلید و موس بین دستگاه‌های جفت‌شده my_devices=دستگاه‌های من nearby_device_detail=جزئیات دستگاه نزدیک nearby_devices=دستگاه‌های نزدیک diff --git a/app/src/desktopMain/resources/i18n/fr.properties b/app/src/desktopMain/resources/i18n/fr.properties index 3182e02f4..1cefc7dc3 100644 --- a/app/src/desktopMain/resources/i18n/fr.properties +++ b/app/src/desktopMain/resources/i18n/fr.properties @@ -147,6 +147,16 @@ mcp_settings_desc=Paramètres du serveur Model Context Protocol menu=Menu missing_file=Fichier manquant month=Mois +mouse_settings=Clavier et souris +mouse_settings.canvas.help=Faites glisser un appareil pour positionner ses écrans par rapport aux vôtres. +mouse_settings.state.disabled=Désactivé +mouse_settings.state.error_prefix=Erreur : %s +mouse_settings.state.running_n_peers=Connecté : %s pair(s) +mouse_settings.state.starting=Démarrage… +mouse_settings.state.warning_prefix=Avertissement : %s +mouse_settings.switch=Partager clavier/souris +mouse_settings.warning_dialog.ok=OK +mouse_settings_desc=Partager clavier et souris entre appareils appairés my_devices=Mes appareils nearby_device_detail=Détails de l’appareil à proximité nearby_devices=Appareils à proximité diff --git a/app/src/desktopMain/resources/i18n/ja.properties b/app/src/desktopMain/resources/i18n/ja.properties index 8598d32ef..ab478595d 100644 --- a/app/src/desktopMain/resources/i18n/ja.properties +++ b/app/src/desktopMain/resources/i18n/ja.properties @@ -147,6 +147,16 @@ mcp_settings_desc=Model Context Protocolサーバー設定 menu=メニュー missing_file=ファイルが見つかりません month=月 +mouse_settings=キーボードとマウス +mouse_settings.canvas.help=デバイスをドラッグして、ローカル画面に対する位置を調整します。 +mouse_settings.state.disabled=オフ +mouse_settings.state.error_prefix=エラー: %s +mouse_settings.state.running_n_peers=接続中: %s 台 +mouse_settings.state.starting=起動中… +mouse_settings.state.warning_prefix=警告: %s +mouse_settings.switch=キーボード/マウスを共有 +mouse_settings.warning_dialog.ok=OK +mouse_settings_desc=ペアリング済みデバイス間でキーボードとマウスを共有 my_devices=マイデバイス nearby_device_detail=近くのデバイスの詳細 nearby_devices=近くのデバイス diff --git a/app/src/desktopMain/resources/i18n/ko.properties b/app/src/desktopMain/resources/i18n/ko.properties index 1a04ee4fc..579c4ef0f 100644 --- a/app/src/desktopMain/resources/i18n/ko.properties +++ b/app/src/desktopMain/resources/i18n/ko.properties @@ -147,6 +147,16 @@ mcp_settings_desc=Model Context Protocol 서버 설정 menu=메뉴 missing_file=누락된파일 month=월 +mouse_settings=키보드 및 마우스 +mouse_settings.canvas.help=장치를 드래그하여 로컬 화면을 기준으로 위치를 조정하세요. +mouse_settings.state.disabled=꺼짐 +mouse_settings.state.error_prefix=오류: %s +mouse_settings.state.running_n_peers=연결됨: %s대 +mouse_settings.state.starting=시작 중… +mouse_settings.state.warning_prefix=경고: %s +mouse_settings.switch=키보드/마우스 공유 +mouse_settings.warning_dialog.ok=확인 +mouse_settings_desc=페어링된 장치 간에 키보드와 마우스 공유 my_devices=내기기 nearby_device_detail=근처 기기 세부정보 nearby_devices=근처기기 diff --git a/app/src/desktopMain/resources/i18n/pt.properties b/app/src/desktopMain/resources/i18n/pt.properties index 154949773..0b8cec901 100644 --- a/app/src/desktopMain/resources/i18n/pt.properties +++ b/app/src/desktopMain/resources/i18n/pt.properties @@ -147,6 +147,16 @@ mcp_settings_desc=Configurações do servidor Model Context Protocol menu=Menu missing_file=Arquivo ausente month=Mês +mouse_settings=Teclado e mouse +mouse_settings.canvas.help=Arraste um dispositivo para posicionar suas telas em relação às suas. +mouse_settings.state.disabled=Desativado +mouse_settings.state.error_prefix=Erro: %s +mouse_settings.state.running_n_peers=Conectado: %s dispositivo(s) +mouse_settings.state.starting=Iniciando… +mouse_settings.state.warning_prefix=Aviso: %s +mouse_settings.switch=Compartilhar teclado/mouse +mouse_settings.warning_dialog.ok=OK +mouse_settings_desc=Compartilhe teclado e mouse entre dispositivos pareados my_devices=Meus dispositivos nearby_device_detail=Detalhes do dispositivo próximo nearby_devices=Dispositivos próximos diff --git a/app/src/desktopMain/resources/i18n/zh.properties b/app/src/desktopMain/resources/i18n/zh.properties index 4ad9b464c..23cb07ab2 100644 --- a/app/src/desktopMain/resources/i18n/zh.properties +++ b/app/src/desktopMain/resources/i18n/zh.properties @@ -147,7 +147,7 @@ mcp_settings_desc=Model Context Protocol 服务器设置 menu=菜单 missing_file=丢失文件 month=月 -mouse_settings=鼠标共享 +mouse_settings=键鼠共享 mouse_settings.canvas.help=拖拽设备以调整它相对于本机的屏幕位置。 mouse_settings.state.disabled=已关闭 mouse_settings.state.error_prefix=错误:%s diff --git a/app/src/desktopMain/resources/i18n/zh_hant.properties b/app/src/desktopMain/resources/i18n/zh_hant.properties index a05c91722..a76bb37ae 100644 --- a/app/src/desktopMain/resources/i18n/zh_hant.properties +++ b/app/src/desktopMain/resources/i18n/zh_hant.properties @@ -147,6 +147,16 @@ mcp_settings_desc=Model Context Protocol 伺服器設定 menu=選單 missing_file=遺失檔案 month=月 +mouse_settings=鍵鼠共享 +mouse_settings.canvas.help=拖曳裝置以調整其相對於本機的螢幕位置。 +mouse_settings.state.disabled=已關閉 +mouse_settings.state.error_prefix=錯誤:%s +mouse_settings.state.running_n_peers=已連線 %s 台裝置 +mouse_settings.state.starting=啟動中… +mouse_settings.state.warning_prefix=警告:%s +mouse_settings.switch=共享鍵盤與滑鼠 +mouse_settings.warning_dialog.ok=確定 +mouse_settings_desc=在已配對裝置間共享鍵盤與滑鼠 my_devices=我的裝置 nearby_device_detail=附近裝置詳情 nearby_devices=附近的裝置 From 0fa07c3dab91768d5cda4c6751bf828585eac370 Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Sun, 26 Apr 2026 14:50:10 +0800 Subject: [PATCH 32/38] :bug: broadcast SyncRuntimeInfo changes via SharedFlow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DAO previously used a Channel(UNLIMITED) to notify getAllSyncRuntimeInfosFlow subscribers of writes. Channel.receiveAsFlow has fan-out semantics — each emission is consumed by exactly one collector — so adding a second subscriber caused them to race for each event. About half the time the wrong collector won, leaving the SyncManager-driven UI/sync state stale (e.g. removed devices not disappearing from the list). Switch to a MutableSharedFlow "table changed" signal: every subscriber sees every change. Subscribers re-read the full table on each tick via onSubscription { emit(Unit) }.map { getAllSyncRuntimeInfos() }, which also gives them an initial snapshot without a separate code path. Add a regression test that pins the multi-subscriber behavior. --- .../db/sync/SyncRuntimeInfoDaoTest.kt | 38 +++++++++++ .../db/sync/SqlSyncRuntimeInfoDao.kt | 66 +++++++++---------- 2 files changed, 69 insertions(+), 35 deletions(-) diff --git a/app/src/desktopTest/kotlin/com/crosspaste/db/sync/SyncRuntimeInfoDaoTest.kt b/app/src/desktopTest/kotlin/com/crosspaste/db/sync/SyncRuntimeInfoDaoTest.kt index 1eaff9806..ea9fe136f 100644 --- a/app/src/desktopTest/kotlin/com/crosspaste/db/sync/SyncRuntimeInfoDaoTest.kt +++ b/app/src/desktopTest/kotlin/com/crosspaste/db/sync/SyncRuntimeInfoDaoTest.kt @@ -8,6 +8,8 @@ import com.crosspaste.db.sync.SyncRuntimeInfo.Companion.createSyncRuntimeInfo import com.crosspaste.dto.sync.EndpointInfo import com.crosspaste.dto.sync.SyncInfo import com.crosspaste.platform.Platform +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.test.runTest import kotlin.collections.listOf import kotlin.test.Test @@ -106,6 +108,42 @@ class SyncRuntimeInfoDaoTest { } } + // Regression: previously updateNotifier was a Channel(UNLIMITED), and + // multiple collectors would compete (fan-out) for each emission — adding a + // second subscriber (e.g. MouseDaemonManager) caused half of the delete + // events to be stolen, leaving the UI/sync state stale. The DAO now uses a + // MutableSharedFlow broadcast signal so every subscriber sees every change. + @Test + fun `getAllSyncRuntimeInfosFlow broadcasts to multiple subscribers`() = runTest { + syncRuntimeInfoDao.insertOrUpdateSyncInfo(testSyncInfo) + + // Two independent collectors must each receive the delete emission. + val sub1 = async { + syncRuntimeInfoDao.getAllSyncRuntimeInfosFlow().test { + assertEquals(1, awaitItem().size) + val afterDelete = awaitItem() + assertTrue(afterDelete.isEmpty(), "subscriber 1 missed the delete") + cancelAndIgnoreRemainingEvents() + } + } + val sub2 = async { + syncRuntimeInfoDao.getAllSyncRuntimeInfosFlow().test { + assertEquals(1, awaitItem().size) + val afterDelete = awaitItem() + assertTrue(afterDelete.isEmpty(), "subscriber 2 missed the delete") + cancelAndIgnoreRemainingEvents() + } + } + + // Defer the delete one tick so both `test {}` blocks have collected + // their initial snapshot before the change signal fires. + val deleter = async { + syncRuntimeInfoDao.deleteSyncRuntimeInfo(testSyncRuntimeInfo.appInstanceId) + } + + awaitAll(sub1, sub2, deleter) + } + @Test fun `getAllSyncRuntimeInfosFlow reacts to insertOrUpdateSyncInfo`() = runTest { // Collect the Flow and verify updates diff --git a/shared/src/commonMain/kotlin/com/crosspaste/db/sync/SqlSyncRuntimeInfoDao.kt b/shared/src/commonMain/kotlin/com/crosspaste/db/sync/SqlSyncRuntimeInfoDao.kt index fd7fcce83..9381c9cbb 100644 --- a/shared/src/commonMain/kotlin/com/crosspaste/db/sync/SqlSyncRuntimeInfoDao.kt +++ b/shared/src/commonMain/kotlin/com/crosspaste/db/sync/SqlSyncRuntimeInfoDao.kt @@ -5,11 +5,12 @@ import com.crosspaste.dto.sync.SyncInfo import com.crosspaste.utils.DateUtils.nowEpochMilliseconds import com.crosspaste.utils.getJsonUtils import com.crosspaste.utils.ioDispatcher -import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onSubscription import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -24,37 +25,32 @@ class SqlSyncRuntimeInfoDao( private val writeMutex = Mutex() - private val updateNotifier = Channel(Channel.UNLIMITED) - - private fun cleanUpdateNotifier() { - while (updateNotifier.tryReceive().isSuccess) { - // do nothing - } - } + /** + * Broadcast "table changed" signal — multi-subscriber safe. + * + * Previously this was a `Channel(UNLIMITED)`, which has fan-out + * (each item delivered to exactly one collector). With a single subscriber + * (`GeneralSyncManager`) that worked, but adding a second subscriber + * (e.g. `MouseDaemonManager`) caused them to race for each event — about + * half the time the wrong collector won and the UI/sync state never + * reflected the write. Switched to `MutableSharedFlow` so all + * subscribers see every signal. + * + * `Unit` is sufficient: subscribers re-read the full table on each tick; + * coalescing rapid bursts via DROP_OLDEST is therefore safe and desirable. + */ + private val changeSignal = + MutableSharedFlow( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) override fun getAllSyncRuntimeInfosFlow(): Flow> = - flow { - cleanUpdateNotifier() - val currentMap = getAllSyncRuntimeInfos().associateBy { it.appInstanceId }.toMutableMap() - emit(currentMap.values.toList()) - - updateNotifier.receiveAsFlow().collect { appInstanceId -> - val updated = - syncRuntimeInfoDatabaseQueries - .getSyncRuntimeInfo( - appInstanceId, - SyncRuntimeInfo::mapper, - ).executeAsOneOrNull() - - if (updated != null) { - currentMap[appInstanceId] = updated - } else { - currentMap.remove(appInstanceId) - } - - emit(currentMap.values.toList()) - } - }.flowOn(ioDispatcher) + changeSignal + .onSubscription { emit(Unit) } + .map { getAllSyncRuntimeInfos() } + .flowOn(ioDispatcher) override suspend fun getAllSyncRuntimeInfos(): List = withContext(ioDispatcher) { @@ -82,7 +78,7 @@ class SqlSyncRuntimeInfoDao( } } if (change) { - updateNotifier.trySend(syncRuntimeInfo.appInstanceId) + changeSignal.tryEmit(Unit) syncRuntimeInfo.appInstanceId } else { null @@ -142,7 +138,7 @@ class SqlSyncRuntimeInfoDao( } } if (deleted) { - updateNotifier.trySend(appInstanceId) + changeSignal.tryEmit(Unit) } } } @@ -276,7 +272,7 @@ class SqlSyncRuntimeInfoDao( } if (changed) { - updateNotifier.trySend(syncInfo.appInfo.appInstanceId) + changeSignal.tryEmit(Unit) } } } From 20080c37093cd144100579a12eb781924134116d Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Sun, 26 Apr 2026 14:50:21 +0800 Subject: [PATCH 33/38] :hammer: source MouseDaemonManager peers from SyncManager MouseDaemonManager was reading the paired-device list directly from SyncRuntimeInfoDao.getAllSyncRuntimeInfosFlow(). That works in isolation, but it bypasses the canonical multi-cast view exposed by SyncManager.realTimeSyncRuntimeInfos and conceptually mouse cares about "business peers managed by SyncManager", not raw table rows. Replace the SyncRuntimeInfoDao constructor dependency with a Flow> and wire it to SyncManager.realTimeSyncRuntimeInfos in DesktopMouseModule. Tests pass the same flow shape, so the fake-DAO mock is no longer needed. --- .../kotlin/com/crosspaste/DesktopMouseModule.kt | 8 +++++++- .../kotlin/com/crosspaste/mouse/MouseDaemonManager.kt | 6 +++--- .../kotlin/com/crosspaste/mouse/MouseDaemonManagerTest.kt | 7 +------ 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt b/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt index 5628c2dab..0ec44db15 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt @@ -13,6 +13,7 @@ import com.crosspaste.mouse.MouseLayoutStore import com.crosspaste.mouse.Position import com.crosspaste.mouse.asDaemonHandle import com.crosspaste.platform.Platform +import com.crosspaste.sync.SyncManager import com.crosspaste.ui.mouse.ScreenArrangementViewModel import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.flow.Flow @@ -108,7 +109,12 @@ fun desktopMouseModule(): Module = .map { it.mouseListenPort } .distinctUntilChanged(), layoutStore = get(), - syncRuntimeInfoDao = get(), + // Source the paired-device list from SyncManager's already-shared + // StateFlow rather than the raw DAO flow. The DAO flow is fine to + // observe in isolation but conceptually mouse cares about "business + // peers managed by SyncManager", not about raw table rows — and + // SyncManager's StateFlow is the canonical multi-cast view. + syncRuntimeInfosFlow = get().realTimeSyncRuntimeInfos, clientFactory = { val binary = MouseDaemonBinary.resolve() diff --git a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonManager.kt b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonManager.kt index 6f8b6c698..da6c91545 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonManager.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonManager.kt @@ -1,6 +1,6 @@ package com.crosspaste.mouse -import com.crosspaste.db.sync.SyncRuntimeInfoDao +import com.crosspaste.db.sync.SyncRuntimeInfo import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.channels.BufferOverflow @@ -50,7 +50,7 @@ class MouseDaemonManager( private val enabledFlow: Flow, private val portFlow: Flow, private val layoutStore: MouseLayoutStore, - private val syncRuntimeInfoDao: SyncRuntimeInfoDao, + private val syncRuntimeInfosFlow: Flow>, private val clientFactory: () -> MouseDaemonClient, ) { private val _state = MutableStateFlow(MouseState.Disabled) @@ -87,7 +87,7 @@ class MouseDaemonManager( enabledFlow, portFlow, layoutStore.flow(), - syncRuntimeInfoDao.getAllSyncRuntimeInfosFlow(), + syncRuntimeInfosFlow, ) { enabled, port, layout, syncs -> MergedInputs(enabled, port, MousePeerMapper.map(syncs, layout)) }.distinctUntilChanged() diff --git a/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonManagerTest.kt b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonManagerTest.kt index b3f7893a3..2d7e61b92 100644 --- a/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonManagerTest.kt +++ b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonManagerTest.kt @@ -7,10 +7,7 @@ package com.crosspaste.mouse import com.crosspaste.db.sync.SyncRuntimeInfo -import com.crosspaste.db.sync.SyncRuntimeInfoDao import com.crosspaste.platform.Platform -import io.mockk.every -import io.mockk.mockk import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -185,14 +182,12 @@ class MouseDaemonManagerTest { enabled: MutableStateFlow, port: MutableStateFlow = MutableStateFlow(4243), ): MouseDaemonManager { - val dao = mockk() - every { dao.getAllSyncRuntimeInfosFlow() } returns syncs val client = MouseDaemonClient(handle) return MouseDaemonManager( enabledFlow = enabled, portFlow = port, layoutStore = store, - syncRuntimeInfoDao = dao, + syncRuntimeInfosFlow = syncs, clientFactory = { client }, ) } From 9aeb64a7f2141d5c95192b454afaccc3733f6234 Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Sun, 26 Apr 2026 18:44:49 +0800 Subject: [PATCH 34/38] :hammer: resolve mouse binary via DevConfig (dev) and pasteAppExePath (prod) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous default candidate `$HOME/crosspaste-mouse/target/release/crosspaste-mouse` was a developer-machine convention that breaks on Windows (no `.exe` suffix) and has no defined production location. - Read the dev binary path from a new `mouseBinaryPath` key in `development.properties` (added to the tracked template). - In production, look at `pasteAppExePath / crosspaste-mouse[.exe]` — add `MouseDaemonBinary.binaryName(platform)` for the `.exe` suffix. - Move env-aware candidate assembly into the caller (`DesktopMouseModule`) so `MouseDaemonBinary` stays a generic resolver. - Make the "binary not found" error environment-aware: dev points at `development.properties`, prod surfaces the attempted bundled path and tells the user to reinstall. - Keep `-Dcrosspaste.mouse.binary` and `$CROSSPASTE_MOUSE_BIN` as per-launch / per-shell escape hatches. Closes #4303 --- .../com/crosspaste/DesktopMouseModule.kt | 36 +++++++++++++++++-- .../kotlin/com/crosspaste/config/DevConfig.kt | 2 ++ .../com/crosspaste/mouse/MouseDaemonBinary.kt | 25 +++++++------ .../resources/development.properties.template | 8 ++++- .../crosspaste/mouse/MouseDaemonBinaryTest.kt | 23 ++++++++++++ 5 files changed, 80 insertions(+), 14 deletions(-) diff --git a/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt b/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt index 0ec44db15..5a5e8e6ae 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt @@ -1,6 +1,7 @@ package com.crosspaste import com.crosspaste.config.DesktopConfigManager +import com.crosspaste.config.DevConfig import com.crosspaste.mouse.AwtLocalScreensProvider import com.crosspaste.mouse.LocalScreensProvider import com.crosspaste.mouse.MacosLocalScreensProvider @@ -12,9 +13,11 @@ import com.crosspaste.mouse.MouseIpcProtocol import com.crosspaste.mouse.MouseLayoutStore import com.crosspaste.mouse.Position import com.crosspaste.mouse.asDaemonHandle +import com.crosspaste.path.AppPathProvider import com.crosspaste.platform.Platform import com.crosspaste.sync.SyncManager import com.crosspaste.ui.mouse.ScreenArrangementViewModel +import com.crosspaste.utils.getAppEnvUtils import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged @@ -116,9 +119,38 @@ fun desktopMouseModule(): Module = // SyncManager's StateFlow is the canonical multi-cast view. syncRuntimeInfosFlow = get().realTimeSyncRuntimeInfos, clientFactory = { + val platform = get() + val pathProvider = get() + val isDevelopment = getAppEnvUtils().isDevelopment() + + // Dev: developer-supplied path from development.properties. + // Prod: bundled binary sits next to the app's runtime under pasteAppExePath. + val candidates = + if (isDevelopment) { + listOfNotNull(DevConfig.mouseBinaryPath) + } else { + listOf( + pathProvider.pasteAppExePath.resolve(MouseDaemonBinary.binaryName(platform)).toString(), + ) + } + val binary = - MouseDaemonBinary.resolve() - ?: throw IllegalStateException("crosspaste-mouse binary not found") + MouseDaemonBinary.resolve(candidatePaths = candidates) + ?: throw IllegalStateException( + buildString { + append("crosspaste-mouse binary not found. Tried: ") + append(if (candidates.isEmpty()) "(no candidates)" else candidates.joinToString()) + if (isDevelopment) { + append(". Set mouseBinaryPath in development.properties, ") + append("or set CROSSPASTE_MOUSE_BIN / -Dcrosspaste.mouse.binary.") + } else { + append( + ". The bundled binary is missing — please reinstall the app or " + + "report this issue.", + ) + } + }, + ) MouseDaemonClient(MouseDaemonProcess.spawn(binary).asDaemonHandle()) }, ) diff --git a/app/src/desktopMain/kotlin/com/crosspaste/config/DevConfig.kt b/app/src/desktopMain/kotlin/com/crosspaste/config/DevConfig.kt index 50e4c9e1a..a20495f7c 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/config/DevConfig.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/config/DevConfig.kt @@ -11,4 +11,6 @@ object DevConfig { val pasteUserPath: String? = development.getProperty("pasteUserPath") val marketingMode: Boolean = development.getProperty("marketingMode")?.toBoolean() == true + + val mouseBinaryPath: String? = development.getProperty("mouseBinaryPath")?.takeIf { it.isNotBlank() } } diff --git a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonBinary.kt b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonBinary.kt index cf4486ff6..922807409 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonBinary.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonBinary.kt @@ -1,5 +1,6 @@ package com.crosspaste.mouse +import com.crosspaste.platform.Platform import java.io.File object MouseDaemonBinary { @@ -8,27 +9,29 @@ object MouseDaemonBinary { /** * Resolution order: - * 1. -Dcrosspaste.mouse.binary= - * 2. $CROSSPASTE_MOUSE_BIN - * 3. Any path in `candidatePaths` (e.g. bundled in resources/bin/...) - * Returns null if nothing points at an existing regular file. + * 1. -Dcrosspaste.mouse.binary= (per-launch override) + * 2. $CROSSPASTE_MOUSE_BIN (per-shell override) + * 3. Any path in [candidatePaths] (caller-supplied, env-aware) + * + * The caller is responsible for assembling [candidatePaths] from the + * dev-mode `DevConfig.mouseBinaryPath` or the prod-mode + * `pasteAppExePath / binaryName(platform)`. Returns null if nothing + * points at an existing regular file. */ fun resolve( + candidatePaths: List = emptyList(), envLookup: (String) -> String? = System::getenv, - candidatePaths: List = defaultCandidatePaths(), ): File? { System.getProperty(SYSTEM_PROPERTY)?.let { File(it).takeIf { f -> f.isFile } }?.let { return it } envLookup(ENV_VAR)?.let { File(it).takeIf { f -> f.isFile } }?.let { return it } return candidatePaths .asSequence() + .filter { it.isNotBlank() } .map(::File) .firstOrNull { it.isFile } } - private fun defaultCandidatePaths(): List { - // Production bundling is a follow-up plan; for now the only - // candidate is a dev-mode symlink next to the app jar. - val home = System.getProperty("user.home") ?: return emptyList() - return listOf("$home/crosspaste-mouse/target/release/crosspaste-mouse") - } + /** crosspaste-mouse build artifact name for the given platform. */ + fun binaryName(platform: Platform): String = + if (platform.isWindows()) "crosspaste-mouse.exe" else "crosspaste-mouse" } diff --git a/app/src/desktopMain/resources/development.properties.template b/app/src/desktopMain/resources/development.properties.template index d4cee8c34..9d7cf5ae9 100644 --- a/app/src/desktopMain/resources/development.properties.template +++ b/app/src/desktopMain/resources/development.properties.template @@ -1,2 +1,8 @@ pasteAppPath=. -pasteUserPath=.user \ No newline at end of file +pasteUserPath=.user + +# Mouse daemon binary path (development only). Set to the absolute path of +# your local crosspaste-mouse build. Leave empty to fall back to +# -Dcrosspaste.mouse.binary or $CROSSPASTE_MOUSE_BIN. +# Example: mouseBinaryPath=/Users/me/crosspaste-mouse/target/release/crosspaste-mouse +mouseBinaryPath= diff --git a/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonBinaryTest.kt b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonBinaryTest.kt index 5160a28f0..d4f340f14 100644 --- a/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonBinaryTest.kt +++ b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonBinaryTest.kt @@ -1,5 +1,6 @@ package com.crosspaste.mouse +import com.crosspaste.platform.Platform import java.nio.file.Files import kotlin.test.AfterTest import kotlin.test.Test @@ -50,4 +51,26 @@ class MouseDaemonBinaryTest { System.setProperty(propKey, "/definitely/does/not/exist/mouse") assertNull(MouseDaemonBinary.resolve(envLookup = { null }, candidatePaths = emptyList())) } + + @Test + fun `binaryName appends exe on windows only`() { + val windows = Platform(name = "Windows", arch = "x64", bitMode = 64, version = "10") + val macos = Platform(name = "Macos", arch = "arm64", bitMode = 64, version = "14") + val linux = Platform(name = "Linux", arch = "x64", bitMode = 64, version = "5.15") + assertEquals("crosspaste-mouse.exe", MouseDaemonBinary.binaryName(windows)) + assertEquals("crosspaste-mouse", MouseDaemonBinary.binaryName(macos)) + assertEquals("crosspaste-mouse", MouseDaemonBinary.binaryName(linux)) + } + + @Test + fun `candidate paths are used when overrides are absent`() { + System.clearProperty(propKey) + val file = Files.createTempFile("fake-daemon", "").toFile().apply { deleteOnExit() } + val result = + MouseDaemonBinary.resolve( + candidatePaths = listOf("", "/does/not/exist", file.absolutePath), + envLookup = { null }, + ) + assertEquals(file.absolutePath, result?.absolutePath) + } } From d8c463ddf9b29354a79332e3dc3655bdc54de470 Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Sat, 13 Jun 2026 12:50:38 +0800 Subject: [PATCH 35/38] :zap: load screen seed off the UI thread to fix mouse-settings open jank MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ScreenArrangementViewModel seeded _localScreens in its constructor via LocalScreensProvider.snapshot(). On macOS that does blocking native I/O — it reads each monitor's wallpaper and re-encodes it to a PNG per display. Because the view model is a Koin factory resolved during composition, this ran synchronously on the Compose UI thread on every window open, freezing the UI with visible jank. Move the seed into observe() and run it on a background dispatcher, filling the list only while it is still empty so authoritative Initialized / LocalScreens daemon events keep precedence. Inject the dispatcher (default ioDispatcher) so tests can substitute a TestDispatcher. Use the project's com.crosspaste.utils.ioDispatcher rather than Dispatchers.IO directly, and document the dispatcher convention in CLAUDE.md. --- CLAUDE.md | 1 + .../ui/mouse/ScreenArrangementViewModel.kt | 87 +++++++++++-------- .../mouse/ScreenArrangementViewModelTest.kt | 52 +++++++---- 3 files changed, 87 insertions(+), 53 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d3a5da590..cefffbc4f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,6 +91,7 @@ SQLDelight manages database schema in `.sq` files: - **UI sizing**: When adding size/dimension constants in UI code, prefer using values defined in `com.crosspaste.ui.theme.AppUISize` instead of inline literal `dp`/`sp` values. - **Thread-safe collections**: In `commonMain` code, prefer thread-safe Map/Set from `io.ktor.util.collections` (e.g., `ConcurrentMap`, `ConcurrentSet`) over platform-specific concurrent collections. - **Coroutine `delay`**: Always pass a `Duration` to `kotlinx.coroutines.delay`, never a raw number. Write `delay(280L.milliseconds)` / `delay(2.seconds)`, not `delay(280L)` or `delay(2000)`. Add `import kotlin.time.Duration.Companion.milliseconds` (or `.seconds`) as needed. This applies to both production code and tests. +- **Coroutine dispatchers**: Never reference `kotlinx.coroutines.Dispatchers.IO` / `.Main` / `.Default` directly. Use the project's multiplatform dispatcher vals from `com.crosspaste.utils`: `ioDispatcher`, `mainDispatcher`, `cpuDispatcher` (declared as `expect` in `DispatcherUtils.kt`, with per-platform `actual`s in `DispatcherUtils.desktop.kt` / `.native.kt`). When a class needs a dispatcher for offloading blocking work, inject it as a constructor parameter defaulting to the appropriate val (e.g. `seedDispatcher: CoroutineDispatcher = ioDispatcher`) so tests can substitute a `TestDispatcher`. ## Key Technical Details diff --git a/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModel.kt b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModel.kt index e48e1a0e1..6fa9aebdd 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModel.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModel.kt @@ -5,10 +5,14 @@ import com.crosspaste.mouse.LocalScreensProvider import com.crosspaste.mouse.MouseLayoutStore import com.crosspaste.mouse.Position import com.crosspaste.mouse.ScreenInfo +import com.crosspaste.utils.ioDispatcher +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch data class RemoteDeviceInfo( val name: String, @@ -20,19 +24,24 @@ class ScreenArrangementViewModel( private val events: SharedFlow, private val layoutStore: MouseLayoutStore, private val localScreensProvider: LocalScreensProvider? = null, + private val seedDispatcher: CoroutineDispatcher = ioDispatcher, ) { /** - * Seeded synchronously from [localScreensProvider] so the canvas can - * render the user's monitors even before the daemon spawns (or when - * the daemon's [IpcEvent.Initialized] fires before [observe] starts - * collecting — `MouseDaemonManager.events` is `replay = 0`, so a - * late subscriber never sees that event again). + * Seeded lazily from [localScreensProvider] inside [observe] (off the UI + * thread) so the canvas can render the user's monitors even before the + * daemon spawns — or when the daemon's [IpcEvent.Initialized] fires before + * [observe] starts collecting (`MouseDaemonManager.events` is `replay = 0`, + * so a late subscriber never sees that event again). * - * `Initialized` / `LocalScreens` events still take precedence — the - * daemon's reading is more authoritative since it goes through the - * native APIs the daemon will actually move the cursor across. + * The seed is NOT run in the constructor: [localScreensProvider.snapshot] + * does blocking native I/O on macOS (reads each monitor's wallpaper and + * re-encodes it to a PNG), and the view model is a Koin `factory` resolved + * during composition — running it synchronously froze the UI thread on + * every window open. It now loads on [ioDispatcher] and only fills the + * still-empty list, so any authoritative `Initialized` / `LocalScreens` + * event that arrives first keeps precedence. */ - private val _localScreens = MutableStateFlow(localScreensProvider?.snapshot().orEmpty()) + private val _localScreens = MutableStateFlow>(emptyList()) val localScreens: StateFlow> = _localScreens.asStateFlow() private val _remoteDevices = MutableStateFlow>(emptyMap()) @@ -50,37 +59,47 @@ class ScreenArrangementViewModel( _remoteDevices.value + (deviceId to RemoteDeviceInfo(name, screens, position)) } - suspend fun observe() { - events.collect { ev -> - when (ev) { - is IpcEvent.Initialized -> _localScreens.value = ev.screens - is IpcEvent.LocalScreens -> _localScreens.value = ev.screens - is IpcEvent.PeerScreensLearned -> { - val existing = _remoteDevices.value[ev.deviceId] - _remoteDevices.value = - _remoteDevices.value + ( - ev.deviceId to - RemoteDeviceInfo( - name = existing?.name ?: ev.deviceId, - screens = ev.screens, - position = - existing?.position - ?: layoutStore.get(ev.deviceId) - ?: Position(0, 0), - ) - ) + suspend fun observe(): Unit = + coroutineScope { + // Seed the canvas off the UI thread; snapshot() does blocking native + // I/O (wallpaper PNG encode per monitor) on macOS. Only fills the list + // if no authoritative screen event has populated it yet. + launch(seedDispatcher) { + val seed = localScreensProvider?.snapshot().orEmpty() + if (seed.isNotEmpty() && _localScreens.value.isEmpty()) { + _localScreens.value = seed } - is IpcEvent.PeerConnected -> { - val existing = _remoteDevices.value[ev.deviceId] - if (existing != null && existing.name != ev.name) { + } + events.collect { ev -> + when (ev) { + is IpcEvent.Initialized -> _localScreens.value = ev.screens + is IpcEvent.LocalScreens -> _localScreens.value = ev.screens + is IpcEvent.PeerScreensLearned -> { + val existing = _remoteDevices.value[ev.deviceId] _remoteDevices.value = - _remoteDevices.value + (ev.deviceId to existing.copy(name = ev.name)) + _remoteDevices.value + ( + ev.deviceId to + RemoteDeviceInfo( + name = existing?.name ?: ev.deviceId, + screens = ev.screens, + position = + existing?.position + ?: layoutStore.get(ev.deviceId) + ?: Position(0, 0), + ) + ) + } + is IpcEvent.PeerConnected -> { + val existing = _remoteDevices.value[ev.deviceId] + if (existing != null && existing.name != ev.name) { + _remoteDevices.value = + _remoteDevices.value + (ev.deviceId to existing.copy(name = ev.name)) + } } + else -> Unit } - else -> Unit } } - } fun onDragDevice( deviceId: String, diff --git a/app/src/desktopTest/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModelTest.kt b/app/src/desktopTest/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModelTest.kt index 6334e8df7..079b859d8 100644 --- a/app/src/desktopTest/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModelTest.kt +++ b/app/src/desktopTest/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModelTest.kt @@ -9,6 +9,8 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlinx.coroutines.yield import kotlin.test.Test @@ -85,18 +87,30 @@ class ScreenArrangementViewModelTest { } @Test - fun `seeds local screens from provider at construction`() { - val provider = - LocalScreensProvider { - listOf( - ScreenInfo(0, 1920, 1080, 0, 0, 1.0, true), - ScreenInfo(1, 2560, 1440, 1920, 0, 2.0, false), + fun `seeds local screens from provider when observing`() = + runTest { + val provider = + LocalScreensProvider { + listOf( + ScreenInfo(0, 1920, 1080, 0, 0, 1.0, true), + ScreenInfo(1, 2560, 1440, 1920, 0, 2.0, false), + ) + } + val vm = + ScreenArrangementViewModel( + MutableSharedFlow(), + store(), + provider, + UnconfinedTestDispatcher(testScheduler), ) - } - val vm = ScreenArrangementViewModel(MutableSharedFlow(), store(), provider) - assertEquals(2, vm.localScreens.value.size) - assertEquals(2560, vm.localScreens.value[1].width) - } + // Seed is loaded off the UI thread inside observe(), not at construction. + assertEquals(0, vm.localScreens.value.size) + val job = launch { vm.observe() } + advanceUntilIdle() + assertEquals(2, vm.localScreens.value.size) + assertEquals(2560, vm.localScreens.value[1].width) + job.cancel() + } @Test fun `daemon Initialized event overrides provider seed`() = @@ -106,15 +120,15 @@ class ScreenArrangementViewModelTest { LocalScreensProvider { listOf(ScreenInfo(0, 1280, 720, 0, 0, 1.0, true)) } - val vm = ScreenArrangementViewModel(events.asSharedFlow(), store(), provider) - assertEquals( - 1280, - vm.localScreens.value - .single() - .width, - ) + val vm = + ScreenArrangementViewModel( + events.asSharedFlow(), + store(), + provider, + UnconfinedTestDispatcher(testScheduler), + ) val job = launch { vm.observe() } - yield() + advanceUntilIdle() events.emit( IpcEvent.Initialized( screens = listOf(ScreenInfo(0, 3840, 2160, 0, 0, 2.0, true)), From 595f89b6b1625e471d8261fefa65ec7160b1f152 Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Sat, 13 Jun 2026 13:02:28 +0800 Subject: [PATCH 36/38] :bug: harden mouse daemon spawn failure and preserve screen enrichment Three review fixes in the crosspaste-mouse integration: - MouseDaemonManager.spawnClient now catches clientFactory()/client.start() failures (missing or un-executable daemon binary), surfaces MouseState.Error, cleans up any launched jobs, and returns a null session instead of letting the exception terminate the combine().collect{} orchestrator. Previously a corrupt install froze the UI on "Starting" forever, unrecoverable without an app restart; now the manager stays alive and retries on the next input change. - ScreenArrangementViewModel merges daemon-reported screens with the local platform snapshot by display id instead of replacing wholesale. ScreenInfo name/wallpaperPath are @Transient and never cross the IPC boundary, so the daemon's Initialized/LocalScreens events used to wipe the macOS-enriched display names and wallpapers. Daemon geometry still wins; name/wallpaper fall back to the local enrichment. - ScreenCanvas.rememberWallpaperBitmaps decodes wallpaper PNGs in a LaunchedEffect on ioDispatcher rather than synchronously in composition, removing the UI-thread jank on window open / screen-set change. Adds MouseDaemonManagerTest coverage for the factory-failure + retry path and ScreenArrangementViewModelTest coverage for the enrichment merge. --- .../crosspaste/mouse/MouseDaemonManager.kt | 40 ++++++++++-- .../ui/mouse/ScreenArrangementViewModel.kt | 62 +++++++++++++++++-- .../com/crosspaste/ui/mouse/ScreenCanvas.kt | 29 ++++++--- .../mouse/MouseDaemonManagerTest.kt | 47 ++++++++++++++ .../mouse/ScreenArrangementViewModelTest.kt | 45 ++++++++++++++ 5 files changed, 205 insertions(+), 18 deletions(-) diff --git a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonManager.kt b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonManager.kt index da6c91545..a194f5747 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonManager.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonManager.kt @@ -1,6 +1,7 @@ package com.crosspaste.mouse import com.crosspaste.db.sync.SyncRuntimeInfo +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.channels.BufferOverflow @@ -98,6 +99,9 @@ class MouseDaemonManager( session == null -> spawnClient(inputs) else -> updateClientLayout(session!!, inputs) } + // spawnClient returns null when client creation/start failed + // (e.g. missing daemon binary). The manager stays alive so a + // later input change (toggle off→on, fixed path) re-spawns. } } @@ -113,13 +117,41 @@ class MouseDaemonManager( return null } - /** Create a new client, wire up event forwarding, send the initial `Start`. */ - private suspend fun CoroutineScope.spawnClient(inputs: MergedInputs): Session { + /** + * Create a new client, wire up event forwarding, send the initial `Start`. + * + * Returns `null` if the client could not be created or started — e.g. + * [clientFactory] throws when the bundled daemon binary is missing, or + * `MouseDaemonProcess.spawn` throws when it isn't executable. Such failures + * must NOT propagate out of the `combine().collect{}` in [run]: that would + * terminate the whole orchestrator and freeze the UI on "Starting" forever, + * unrecoverable without an app restart. Instead we surface [MouseState.Error] + * and leave the manager running so the next input change can retry. + */ + private suspend fun CoroutineScope.spawnClient(inputs: MergedInputs): Session? { _state.value = MouseState.Starting - val client = clientFactory() + val client = + try { + clientFactory() + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + _state.value = MouseState.Error(e.message ?: "Failed to create mouse daemon client") + return null + } val runJob = launch { client.run() } val eventJob = launch { forwardClientEvents(client.events) } - client.start(inputs.port, inputs.peers) + try { + client.start(inputs.port, inputs.peers) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + eventJob.cancel() + runJob.cancel() + runCatching { client.close() } + _state.value = MouseState.Error(e.message ?: "Failed to start mouse daemon client") + return null + } return Session(client, runJob, eventJob, inputs.port, inputs.peers) } diff --git a/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModel.kt b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModel.kt index 6fa9aebdd..8fbd564e0 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModel.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModel.kt @@ -44,6 +44,23 @@ class ScreenArrangementViewModel( private val _localScreens = MutableStateFlow>(emptyList()) val localScreens: StateFlow> = _localScreens.asStateFlow() + private val screensLock = Any() + + /** + * Local platform snapshot (geometry + enriched `name` / `wallpaperPath`). + * The daemon's IPC screens carry geometry only — `name` / `wallpaperPath` + * are `@Transient` on [ScreenInfo] and never cross the IPC boundary — so we + * keep this snapshot to re-attach the enrichment by display id whenever the + * daemon reports screens. Guarded by [screensLock]. + */ + private var seedScreens: List = emptyList() + + /** + * Latest daemon-reported local screens (authoritative geometry), or null + * before the daemon initializes. Guarded by [screensLock]. + */ + private var daemonScreens: List? = null + private val _remoteDevices = MutableStateFlow>(emptyMap()) val remoteDevices: StateFlow> = _remoteDevices.asStateFlow() @@ -59,21 +76,54 @@ class ScreenArrangementViewModel( _remoteDevices.value + (deviceId to RemoteDeviceInfo(name, screens, position)) } + /** + * Re-attach local `name` / `wallpaperPath` to a daemon-reported screen by + * matching display id. Falls back to the daemon screen unchanged when no + * local match exists (disjoint ids, or the AWT fallback that carries no + * names) — enrichment is best-effort and never degrades geometry. + * + * Assumes the daemon emits the same display id space as + * [com.crosspaste.mouse.MacosLocalScreensProvider] (CGDirectDisplayID on + * macOS); if they ever diverge, the match simply misses and we render the + * daemon screen as-is, same as before this enrichment existed. + */ + private fun ScreenInfo.withLocalEnrichment(): ScreenInfo { + val local = seedScreens.firstOrNull { it.id == id } ?: return this + return copy( + name = name ?: local.name, + wallpaperPath = wallpaperPath ?: local.wallpaperPath, + ) + } + + /** + * Recompute and publish the canvas's local-screen list under [screensLock]: + * daemon geometry enriched with local names/wallpaper once the daemon has + * reported, otherwise the raw local snapshot as a pre-daemon seed. + */ + private fun updateScreens(mutate: () -> Unit) { + synchronized(screensLock) { + mutate() + _localScreens.value = + daemonScreens?.map { it.withLocalEnrichment() } ?: seedScreens + } + } + suspend fun observe(): Unit = coroutineScope { // Seed the canvas off the UI thread; snapshot() does blocking native - // I/O (wallpaper PNG encode per monitor) on macOS. Only fills the list - // if no authoritative screen event has populated it yet. + // I/O (wallpaper PNG encode per monitor) on macOS. Publishing merges + // with any daemon screens already reported, so the enrichment is + // re-attached even if Initialized arrived first. launch(seedDispatcher) { val seed = localScreensProvider?.snapshot().orEmpty() - if (seed.isNotEmpty() && _localScreens.value.isEmpty()) { - _localScreens.value = seed + if (seed.isNotEmpty()) { + updateScreens { seedScreens = seed } } } events.collect { ev -> when (ev) { - is IpcEvent.Initialized -> _localScreens.value = ev.screens - is IpcEvent.LocalScreens -> _localScreens.value = ev.screens + is IpcEvent.Initialized -> updateScreens { daemonScreens = ev.screens } + is IpcEvent.LocalScreens -> updateScreens { daemonScreens = ev.screens } is IpcEvent.PeerScreensLearned -> { val existing = _remoteDevices.value[ev.deviceId] _remoteDevices.value = diff --git a/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenCanvas.kt b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenCanvas.kt index 873a8a0d1..f520b6ece 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenCanvas.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenCanvas.kt @@ -42,6 +42,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.crosspaste.mouse.Position import com.crosspaste.mouse.ScreenInfo +import com.crosspaste.utils.ioDispatcher +import kotlinx.coroutines.withContext import org.jetbrains.skia.Image import java.io.File @@ -479,19 +481,30 @@ private fun DrawScope.drawWallpaper( * recomposition. We key on the path list so adding/removing/swapping a screen * triggers a reload, but redraws (pan, zoom, drag) hit the cache. * + * Decoding runs in a [LaunchedEffect] on [ioDispatcher], never in composition: + * `File.readBytes()` + 4K PNG decode on the Compose UI thread janked the first + * frame when the settings window opened or the screen set changed. The cache + * keeps the previous bitmaps until the new ones finish decoding, so redraws + * stay smooth during the reload. + * * Skia decodes PNG/JPEG natively. HEIC is handled in Swift before we get * here — see `getDesktopWallpaperPng` in MacosApi.swift. */ @Composable private fun rememberWallpaperBitmaps(screens: List): Map { val paths = screens.mapNotNull { it.wallpaperPath }.distinct() - return remember(paths) { - paths - .mapNotNull { path -> - runCatching { - val bytes = File(path).readBytes() - path to Image.makeFromEncoded(bytes).toComposeImageBitmap() - }.getOrNull() - }.toMap() + var bitmaps by remember { mutableStateOf>(emptyMap()) } + LaunchedEffect(paths) { + bitmaps = + withContext(ioDispatcher) { + paths + .mapNotNull { path -> + runCatching { + val bytes = File(path).readBytes() + path to Image.makeFromEncoded(bytes).toComposeImageBitmap() + }.getOrNull() + }.toMap() + } } + return bitmaps } diff --git a/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonManagerTest.kt b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonManagerTest.kt index 2d7e61b92..337f21c01 100644 --- a/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonManagerTest.kt +++ b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonManagerTest.kt @@ -157,6 +157,53 @@ class MouseDaemonManagerTest { job.cancel() } + @Test + fun `clientFactory failure surfaces Error and keeps manager alive for retry`() = + runBlocking { + val store = inMemoryStore() + val enabled = MutableStateFlow(true) + val handle = FakeHandle() + var attempts = 0 + val mgr = + MouseDaemonManager( + enabledFlow = enabled, + portFlow = MutableStateFlow(4243), + layoutStore = store, + syncRuntimeInfosFlow = MutableStateFlow(emptyList()), + clientFactory = { + attempts++ + // First spawn fails like a missing/un-executable daemon binary. + if (attempts == 1) { + throw IllegalStateException("crosspaste-mouse binary not found") + } + MouseDaemonClient(handle) + }, + ) + val job = launch { mgr.run() } + delay(50) + // The thrown exception must NOT terminate run(): manager stays alive, + // surfacing Error instead of freezing forever on Starting. + assertTrue( + mgr.state.value is MouseState.Error, + "expected Error after factory failure, got ${mgr.state.value}", + ) + assertEquals(1, attempts) + + // Toggle off→on: the next distinct input re-enters spawnClient and retries. + enabled.value = false + delay(50) + enabled.value = true + delay(50) + assertEquals(2, attempts) + handle.emit(IpcEvent.Initialized(emptyList(), 2)) + delay(50) + assertTrue( + mgr.state.value is MouseState.Running, + "expected Running after successful retry, got ${mgr.state.value}", + ) + job.cancel() + } + // --- test helpers --- private fun inMemoryStore(): MouseLayoutStore { val backing = diff --git a/app/src/desktopTest/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModelTest.kt b/app/src/desktopTest/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModelTest.kt index 079b859d8..051e8a9d6 100644 --- a/app/src/desktopTest/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModelTest.kt +++ b/app/src/desktopTest/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModelTest.kt @@ -144,4 +144,49 @@ class ScreenArrangementViewModelTest { ) job.cancel() } + + @Test + fun `daemon screens keep local name and wallpaper enrichment`() = + runTest { + val events = MutableSharedFlow(replay = 0, extraBufferCapacity = 16) + val provider = + LocalScreensProvider { + listOf( + ScreenInfo( + id = 7, + width = 1920, + height = 1080, + x = 0, + y = 0, + scaleFactor = 1.0, + isPrimary = true, + name = "DELL U2725QE", + wallpaperPath = "/tmp/wp-7.png", + ), + ) + } + val vm = + ScreenArrangementViewModel( + events.asSharedFlow(), + store(), + provider, + UnconfinedTestDispatcher(testScheduler), + ) + val job = launch { vm.observe() } + advanceUntilIdle() + // Daemon reports the same display id with authoritative geometry but + // null name/wallpaper (both @Transient, never cross the IPC boundary). + events.emit( + IpcEvent.Initialized( + screens = listOf(ScreenInfo(7, 3840, 2160, 0, 0, 2.0, true)), + protocolVersion = 2, + ), + ) + advanceUntilIdle() + val screen = vm.localScreens.value.single() + assertEquals(3840, screen.width) // daemon geometry wins + assertEquals("DELL U2725QE", screen.name) // local enrichment preserved + assertEquals("/tmp/wp-7.png", screen.wallpaperPath) + job.cancel() + } } From 6447614a9d5456f50d8e9094e0597a3f2375ba27 Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Sat, 13 Jun 2026 15:54:40 +0800 Subject: [PATCH 37/38] :bug: wrap mouse settings window in the app theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MouseSettingsWindow was the only Compose window not wrapped in the app theme (Main/Search/Bubble all go through DesktopContext). Without it, MaterialTheme.colorScheme fell back to the unthemed Material default, so the header Box painted a black background with dark, unreadable text while the ScreenCanvas (which paints its own background) looked fine — a jarring split. Add DesktopContext.MouseSettingsWindowContext (mirrors BubbleWindowContext) and wrap the window content so colors track the real light/dark theme. --- .../com/crosspaste/ui/DesktopContext.kt | 7 +++++++ .../ui/mouse/MouseSettingsWindow.kt | 20 ++++++++++++------- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/app/src/desktopMain/kotlin/com/crosspaste/ui/DesktopContext.kt b/app/src/desktopMain/kotlin/com/crosspaste/ui/DesktopContext.kt index 2a357174f..9091b6f94 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/ui/DesktopContext.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/ui/DesktopContext.kt @@ -87,4 +87,11 @@ object DesktopContext { content() } } + + @Composable + fun MouseSettingsWindowContext(content: @Composable () -> Unit) { + BaseContext { + content() + } + } } diff --git a/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MouseSettingsWindow.kt b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MouseSettingsWindow.kt index 8513bd628..789530d10 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MouseSettingsWindow.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MouseSettingsWindow.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.rememberWindowState import com.crosspaste.app.DesktopAppWindowManager import com.crosspaste.i18n.GlobalCopywriter +import com.crosspaste.ui.DesktopContext import org.koin.compose.koinInject import java.awt.Dimension @@ -45,13 +46,18 @@ fun MouseSettingsWindow(windowIcon: Painter?) { onDispose {} } - Box( - modifier = - Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - ) { - MouseSettingsScreen() + // Like every other Compose window (Main/Search/Bubble), wrap content in + // the app theme. Without it MaterialTheme.colorScheme falls back to the + // unthemed default, painting the header black with dark, unreadable text. + DesktopContext.MouseSettingsWindowContext { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + ) { + MouseSettingsScreen() + } } } } From 9d9dd994b2db15b6dc2ea5e1cdf6375387ee3f89 Mon Sep 17 00:00:00 2001 From: Yiqun Zhang Date: Sat, 13 Jun 2026 16:10:29 +0800 Subject: [PATCH 38/38] :bug: use Surface for mouse settings window so header text is readable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrapping the window in the app theme fixed the background color but not the text: the content root was a bare Box, which paints a background but does not set LocalContentColor. The header Text composables therefore fell back to the default Color.Black — black-on-black against the dark-mode background. Swap the Box for a Material3 Surface (color = background). Surface sets the content color to onBackground, so the header text follows the theme and stays readable in both light and dark mode. --- .../ui/mouse/MouseSettingsWindow.kt | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MouseSettingsWindow.kt b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MouseSettingsWindow.kt index 789530d10..a69afb46d 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MouseSettingsWindow.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MouseSettingsWindow.kt @@ -1,9 +1,8 @@ package com.crosspaste.ui.mouse -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Alignment @@ -46,15 +45,15 @@ fun MouseSettingsWindow(windowIcon: Painter?) { onDispose {} } - // Like every other Compose window (Main/Search/Bubble), wrap content in - // the app theme. Without it MaterialTheme.colorScheme falls back to the - // unthemed default, painting the header black with dark, unreadable text. + // Wrap content in the app theme (like Main/Search/Bubble) so colors track + // the real light/dark scheme. Use a Surface, not a bare Box: Surface sets + // LocalContentColor to onBackground, so the header text is readable. A + // plain Box only paints the background and leaves text at the default + // Color.Black — black-on-black in dark mode. DesktopContext.MouseSettingsWindowContext { - Box( - modifier = - Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, ) { MouseSettingsScreen() }