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/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..5a5e8e6ae --- /dev/null +++ b/app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt @@ -0,0 +1,165 @@ +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 +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 +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 +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. + * + * `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. + * + * 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, +) : 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) { + synchronized(updateLock) { + val current = decode(configManager.config.value.mouseLayout) + val next = updater(current) + configManager.updateConfig( + "mouseLayout", + MouseIpcProtocol.json.encodeToString(mapSerializer, next), + ) + } + } + + override fun flow(): Flow> = + configManager.config + .map { decode(it.mouseLayout) } + .distinctUntilChanged() + + private fun decode(json: String): Map = + runCatching { MouseIpcProtocol.json.decodeFromString(mapSerializer, json) } + .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 = + module { + single { + val platform = get() + if (platform.isMacos()) MacosLocalScreensProvider() else AwtLocalScreensProvider() + } + 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(), + // 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 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(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()) + }, + ) + } + factory { + ScreenArrangementViewModel( + events = get().events, + layoutStore = get(), + localScreensProvider = get(), + ) + } + } 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/config/DesktopAppConfig.kt b/app/src/desktopMain/kotlin/com/crosspaste/config/DesktopAppConfig.kt index eeb3e4e70..9c55ac79d 100644 --- a/app/src/desktopMain/kotlin/com/crosspaste/config/DesktopAppConfig.kt +++ b/app/src/desktopMain/kotlin/com/crosspaste/config/DesktopAppConfig.kt @@ -66,6 +66,13 @@ 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, + // 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, @@ -180,5 +187,8 @@ data class DesktopAppConfig( } else { lastSeenChangelogVersion }, + mouseEnabled = if (key == "mouseEnabled") toBoolean(value) else mouseEnabled, + mouseListenPort = if (key == "mouseListenPort") toInt(value) else mouseListenPort, + mouseLayout = if (key == "mouseLayout") toString(value) else mouseLayout, ) } 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/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/MouseDaemonBinary.kt b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonBinary.kt new file mode 100644 index 000000000..922807409 --- /dev/null +++ b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonBinary.kt @@ -0,0 +1,37 @@ +package com.crosspaste.mouse + +import com.crosspaste.platform.Platform +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= (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, + ): 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 } + } + + /** 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/kotlin/com/crosspaste/mouse/MouseDaemonClient.kt b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonClient.kt new file mode 100644 index 000000000..5f99e8b27 --- /dev/null +++ b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonClient.kt @@ -0,0 +1,138 @@ +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, +) { + 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() + + /** + * 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, + ) + + /** + * 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 = + 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). The fallback's transient `Stopped` is + * hidden from [events] via [restartInProgress]. + */ + suspend fun updateLayout( + port: Int, + peers: List, + ) { + 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. + } + } + + 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/MouseDaemonManager.kt b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonManager.kt new file mode 100644 index 000000000..a194f5747 --- /dev/null +++ b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonManager.kt @@ -0,0 +1,205 @@ +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 +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 +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 syncRuntimeInfosFlow: Flow>, + private val clientFactory: () -> MouseDaemonClient, +) { + 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() + + /** 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 session: Session? = null + combine( + enabledFlow, + portFlow, + layoutStore.flow(), + syncRuntimeInfosFlow, + ) { enabled, port, layout, syncs -> + MergedInputs(enabled, port, MousePeerMapper.map(syncs, layout)) + }.distinctUntilChanged() + .collect { inputs -> + session = + when { + !inputs.enabled -> teardownClient(session) + 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. + } + } + + /** 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 + } + + /** + * 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 = + 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) } + 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) + } + + /** 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) { + // 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) + } + 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, + val port: Int, + val peers: List, + ) +} 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..3cd19ad54 --- /dev/null +++ b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseDaemonProcess.kt @@ -0,0 +1,188 @@ +package com.crosspaste.mouse + +import io.github.oshai.kotlinlogging.KotlinLogging +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.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runInterruptible +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.InterruptedIOException +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. + * + * 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, + 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)) + private val writeLock = Mutex() + + private val _events = + MutableSharedFlow( + replay = 0, + 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 { + // `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. 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 + // 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) + }.getOrElse { + IpcEvent.Error("malformed daemon output: ${it.message}") + } + _events.tryEmit(event) + } + } catch (_: InterruptedIOException) { + // expected on close() + } + } + } + } + + /** + * 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 { + withContext(NonCancellable + Dispatchers.IO) { + writer.write(line) + writer.write("\n") + writer.flush() + } + } + } + + override fun 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 { writer.close() } + onClose() + } + + companion object { + /** + * 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 = + 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() } + }, + ) + } + } +} + +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/desktopMain/kotlin/com/crosspaste/mouse/MouseIpcProtocol.kt b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseIpcProtocol.kt new file mode 100644 index 000000000..84d2ced33 --- /dev/null +++ b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseIpcProtocol.kt @@ -0,0 +1,192 @@ +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 + +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, + /** + * 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 +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") 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/desktopMain/kotlin/com/crosspaste/mouse/MouseLayoutStore.kt b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseLayoutStore.kt new file mode 100644 index 000000000..699acd7a2 --- /dev/null +++ b/app/src/desktopMain/kotlin/com/crosspaste/mouse/MouseLayoutStore.kt @@ -0,0 +1,57 @@ +package com.crosspaste.mouse + +import kotlinx.coroutines.flow.Flow + +/** + * 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). + * + * 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 update(updater: (Map) -> Map) + + /** + * 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() + + fun get(deviceId: String): Position? = backing.snapshot()[deviceId] + + fun upsert( + deviceId: String, + position: Position, + ) { + backing.update { it + (deviceId to position) } + } + + fun remove(deviceId: String) { + backing.update { it - deviceId } + } + + fun flow(): Flow> = backing.flow() +} 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/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/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/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/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/MousePermissionDialog.kt b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MousePermissionDialog.kt new file mode 100644 index 000000000..eb433c8e7 --- /dev/null +++ b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MousePermissionDialog.kt @@ -0,0 +1,34 @@ +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.i18n.GlobalCopywriter +import com.crosspaste.mouse.IpcEvent +import org.koin.compose.koinInject + +/** + * 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 + val copywriter = koinInject() + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(warning.code) }, + text = { Text(warning.message) }, + 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 new file mode 100644 index 000000000..6eee93eae --- /dev/null +++ b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MouseSettingsScreen.kt @@ -0,0 +1,100 @@ +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.fillMaxSize +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.i18n.GlobalCopywriter +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() + val copywriter = 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.fillMaxSize().padding(AppUISize.medium), + verticalArrangement = Arrangement.spacedBy(AppUISize.medium), + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text( + copywriter.getText("mouse_settings.switch"), + 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 -> 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, + ) + Spacer(Modifier.height(AppUISize.small)) + ScreenCanvas( + viewModel = viewModel, + modifier = Modifier.fillMaxWidth().weight(1f), + ) + } +} 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..a69afb46d --- /dev/null +++ b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/MouseSettingsWindow.kt @@ -0,0 +1,62 @@ +package com.crosspaste.ui.mouse + +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 +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 com.crosspaste.ui.DesktopContext +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 {} + } + + // 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 { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + MouseSettingsScreen() + } + } + } +} 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..8fbd564e0 --- /dev/null +++ b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModel.kt @@ -0,0 +1,171 @@ +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 +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, + val screens: List, + val position: Position, +) + +class ScreenArrangementViewModel( + private val events: SharedFlow, + private val layoutStore: MouseLayoutStore, + private val localScreensProvider: LocalScreensProvider? = null, + private val seedDispatcher: CoroutineDispatcher = ioDispatcher, +) { + /** + * 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). + * + * 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>(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() + + /** Test-only: inject a remote device without going through the event stream. */ + internal 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)) + } + + /** + * 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. 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()) { + updateScreens { seedScreens = seed } + } + } + events.collect { ev -> + when (ev) { + 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 = + _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/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..f520b6ece --- /dev/null +++ b/app/src/desktopMain/kotlin/com/crosspaste/ui/mouse/ScreenCanvas.kt @@ -0,0 +1,510 @@ +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.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.utils.ioDispatcher +import kotlinx.coroutines.withContext +import org.jetbrains.skia.Image +import java.io.File + +/** + * 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, + modifier: Modifier = Modifier, +) { + 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) } + + // 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 + .fillMaxSize() + .background(Color(0xFFF2F2F2)) + // 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 id = draggingDeviceId ?: return@detectDragGesturesAfterLongPress + viewModel.onDragDevice( + deviceId = id, + dx = (drag.x / scale).toInt(), + dy = (drag.y / scale).toInt(), + ) + }, + onDragEnd = { + 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 + } + }, + ) { + // 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) + } + // 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, pan, 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), + ) + drawScreenLabel(measurer, info.name, rect) + } + } + } + } +} + +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) +} + +/** + * 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, + pan: Offset, + scale: Float, +): RectFloats { + val worldX = position.x + screen.x + val worldY = position.y + screen.y + return RectFloats( + topLeft = Offset(pan.x + worldX * scale, pan.y + worldY * scale), + size = Size(screen.width * scale, screen.height * scale), + ) +} + +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. + * + * 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() + 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/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/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 0a295d01e..df55fccf0 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=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 +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/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 ee4e06e8d..23cb07ab2 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=附近的设备 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=附近的裝置 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/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/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..d4f340f14 --- /dev/null +++ b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonBinaryTest.kt @@ -0,0 +1,76 @@ +package com.crosspaste.mouse + +import com.crosspaste.platform.Platform +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())) + } + + @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) + } +} 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..31a733e25 --- /dev/null +++ b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonClientTest.kt @@ -0,0 +1,149 @@ +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.advanceUntilIdle +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() + } + + @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() + } +} 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..337f21c01 --- /dev/null +++ b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonManagerTest.kt @@ -0,0 +1,241 @@ +// 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.platform.Platform +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 `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 { + 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 + 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 = + object : MouseLayoutStore.Backing { + private val flow = MutableStateFlow>(emptyMap()) + + override fun snapshot() = flow.value + + @Synchronized + override fun update(updater: (Map) -> Map) { + flow.value = updater(flow.value) + } + + override fun flow(): kotlinx.coroutines.flow.Flow> = flow + } + return MouseLayoutStore(backing) + } + + private fun newManager( + handle: FakeHandle, + store: MouseLayoutStore, + syncs: MutableStateFlow> = MutableStateFlow(emptyList()), + enabled: MutableStateFlow, + port: MutableStateFlow = MutableStateFlow(4243), + ): MouseDaemonManager { + val client = MouseDaemonClient(handle) + return MouseDaemonManager( + enabledFlow = enabled, + portFlow = port, + layoutStore = store, + syncRuntimeInfosFlow = syncs, + clientFactory = { client }, + ) + } +} 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..6d2ad0bd4 --- /dev/null +++ b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseDaemonProcessTest.kt @@ -0,0 +1,142 @@ +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.assertFalse +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 `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 = + MouseDaemonProcess( + stdout = source, + 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() + + 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() + 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() + } +} 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") } + } +} 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..cdfb1b6cb --- /dev/null +++ b/app/src/desktopTest/kotlin/com/crosspaste/mouse/MouseLayoutStoreTest.kt @@ -0,0 +1,90 @@ +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 +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"]) + } + + @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, + ) : MouseLayoutStore.Backing { + private val flow = MutableStateFlow(backing.toMap()) + + override fun snapshot() = backing.toMap() + + @Synchronized + override fun update(updater: (Map) -> Map) { + val next = updater(backing.toMap()) + backing.clear() + backing.putAll(next) + flow.value = next + } + + override fun flow(): kotlinx.coroutines.flow.Flow> = flow + } +} 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) + } +} 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..051e8a9d6 --- /dev/null +++ b/app/src/desktopTest/kotlin/com/crosspaste/ui/mouse/ScreenArrangementViewModelTest.kt @@ -0,0 +1,192 @@ +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 +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 +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 + + @Synchronized + override fun update(updater: (Map) -> Map) { + flow.value = updater(flow.value) + } + + override fun flow(): kotlinx.coroutines.flow.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")) + } + + @Test + 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), + ) + // 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`() = + 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, + UnconfinedTestDispatcher(testScheduler), + ) + val job = launch { vm.observe() } + advanceUntilIdle() + 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() + } + + @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() + } +} 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). 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) } } }