Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
ab74acd
:sparkles: add MouseIpcProtocol types for crosspaste-mouse plugin IPC
guiyanakuang Apr 20, 2026
53ee622
:bug: drop misleading @EncodeDefault on IpcEvent.Initialized.protocol…
guiyanakuang Apr 20, 2026
71f49f1
:sparkles: add MouseDaemonBinary resolver with env/sysprop/bundled fa…
guiyanakuang Apr 20, 2026
a0f9942
:sparkles: add MouseDaemonProcess wrapper with stdin/stdout JSON Lines
guiyanakuang Apr 20, 2026
508cf58
:bug: tighten MouseDaemonProcess shutdown, write atomicity, and docs
guiyanakuang Apr 20, 2026
38e3e4c
:bug: tighten MouseDaemonProcess shutdown, write atomicity, and docs
guiyanakuang Apr 20, 2026
01e6f62
:sparkles: map paired SyncRuntimeInfo rows to mouse daemon peers
guiyanakuang Apr 20, 2026
53c8482
:sparkles: persist mouse arrangement layout in DesktopAppConfig
guiyanakuang Apr 20, 2026
6e606e5
:sparkles: add MouseDaemonClient with capability negotiation and stop…
guiyanakuang Apr 20, 2026
2379724
:sparkles: add MouseDaemonManager lifecycle — enables daemon driven b…
guiyanakuang Apr 20, 2026
1715d82
:sparkles: wire MouseDaemonManager into Koin and start on app launch
guiyanakuang Apr 20, 2026
a4316ed
:sparkles: add ScreenArrangementViewModel for mouse layout UI
guiyanakuang Apr 20, 2026
248aff3
:sparkles: add drag-to-arrange ScreenCanvas for mouse layout
guiyanakuang Apr 20, 2026
f4e9f94
:sparkles: add MouseSettings screen with arrangement canvas and permi…
guiyanakuang Apr 20, 2026
ea5dee7
:memo: add i18n strings for mouse settings screen
guiyanakuang Apr 20, 2026
b597d43
:bug: swallow expected InterruptedIOException in MouseDaemonProcess r…
guiyanakuang Apr 20, 2026
4d86e45
:memo: add implementation plan for crosspaste-mouse plugin integration
guiyanakuang Apr 20, 2026
b1bfce3
:hammer: store mouseLayout as JSON string, matching blacklist/sourceE…
guiyanakuang Apr 20, 2026
f9251b8
:bug: close read-modify-write race in MouseLayoutStore
guiyanakuang Apr 21, 2026
314981f
:bug: drop runBlocking from MouseDaemonProcess.close() to avoid corou…
guiyanakuang Apr 21, 2026
b7ba930
:bug: derive MouseLayoutStore flow from DesktopConfigManager, elimina…
guiyanakuang Apr 21, 2026
3a9eba8
:bug: suppress transient Stopped from updateLayout fallback at the cl…
guiyanakuang Apr 21, 2026
e2eef7c
:memo: warn on mouseLayout JSON decode failure instead of silently dr…
guiyanakuang Apr 21, 2026
e450923
:hammer: split MouseDaemonManager.run() into teardownClient / spawnCl…
guiyanakuang Apr 21, 2026
ddc7929
:hammer: restrict ScreenArrangementViewModel.seedRemote visibility to…
guiyanakuang Apr 21, 2026
5310a39
:memo: document intentional NonCancellable trade-off on MouseDaemonPr…
guiyanakuang Apr 21, 2026
19ad714
:sparkles: enrich ScreenInfo with display name + wallpaper via LocalS…
guiyanakuang Apr 26, 2026
173fcf6
:bug: filter daemon stdout log spillover and treat Initialized/Ready …
guiyanakuang Apr 26, 2026
da383e1
:sparkles: open keyboard & mouse settings in a dedicated window
guiyanakuang Apr 26, 2026
773f958
:sparkles: stretch screen canvas, clip overflow, and clamp pan/zoom
guiyanakuang Apr 26, 2026
0a978c4
:sparkles: rename mouse_settings to "Keyboard & Mouse" and complete l…
guiyanakuang Apr 26, 2026
0fa07c3
:bug: broadcast SyncRuntimeInfo changes via SharedFlow
guiyanakuang Apr 26, 2026
20080c3
:hammer: source MouseDaemonManager peers from SyncManager
guiyanakuang Apr 26, 2026
9aeb64a
:hammer: resolve mouse binary via DevConfig (dev) and pasteAppExePath…
guiyanakuang Apr 26, 2026
d8c463d
:zap: load screen seed off the UI thread to fix mouse-settings open jank
guiyanakuang Jun 13, 2026
595f89b
:bug: harden mouse daemon spawn failure and preserve screen enrichment
guiyanakuang Jun 13, 2026
6447614
:bug: wrap mouse settings window in the app theme
guiyanakuang Jun 13, 2026
9d9dd99
:bug: use Surface for mouse settings window so header text is readable
guiyanakuang Jun 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions app/src/desktopMain/kotlin/com/crosspaste/CrossPaste.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -174,6 +175,11 @@ class CrossPaste {
if (configManager.getCurrentConfig().enableMcpServer) {
ioCoroutineDispatcher.launch { koin.get<McpServer>().start() }
}
if (!headless) {
ioCoroutineDispatcher.launch {
koin.get<MouseDaemonManager>().run()
}
}
koin.get<PasteClient>()
koin.get<PasteBonjourService>()
koin.get<CleanScheduler>().start()
Expand Down
1 change: 1 addition & 0 deletions app/src/desktopMain/kotlin/com/crosspaste/DesktopModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ class DesktopModule(
pasteComponentModule(),
uiModule(),
viewModelModule(),
desktopMouseModule(),
)
}
}
Expand Down
165 changes: 165 additions & 0 deletions app/src/desktopMain/kotlin/com/crosspaste/DesktopMouseModule.kt
Original file line number Diff line number Diff line change
@@ -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<String, Position>` 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<String, Position> = decode(configManager.config.value.mouseLayout)

override fun update(updater: (Map<String, Position>) -> Map<String, Position>) {
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<Map<String, Position>> =
configManager.config
.map { decode(it.mouseLayout) }
.distinctUntilChanged()

private fun decode(json: String): Map<String, Position> =
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<LocalScreensProvider> {
val platform = get<Platform>()
if (platform.isMacos()) MacosLocalScreensProvider() else AwtLocalScreensProvider()
}
single<MouseLayoutStore> {
MouseLayoutStore(DesktopAppConfigMouseLayoutBacking(get<DesktopConfigManager>()))
}
single<MouseDaemonManager> {
val configManager = get<DesktopConfigManager>()
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<SyncManager>().realTimeSyncRuntimeInfos,
clientFactory = {
val platform = get<Platform>()
val pathProvider = get<AppPathProvider>()
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> {
ScreenArrangementViewModel(
events = get<MouseDaemonManager>().events,
layoutStore = get(),
localScreensProvider = get(),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,17 @@ abstract class DesktopAppWindowManager(

fun isBubbleWindowVisible(): Boolean = _bubbleWindowInfo.value.show

private val _mouseSettingsWindowVisible = MutableStateFlow(false)
val mouseSettingsWindowVisible: StateFlow<Boolean> = _mouseSettingsWindowVisible

fun showMouseSettingsWindow() {
_mouseSettingsWindowVisible.value = true
}

fun hideMouseSettingsWindow() {
_mouseSettingsWindowVisible.value = false
}

abstract suspend fun focusBubbleWindow()

abstract fun startWindowService()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Position> (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,
Expand Down Expand Up @@ -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,
)
}
2 changes: 2 additions & 0 deletions app/src/desktopMain/kotlin/com/crosspaste/config/DevConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
}
Original file line number Diff line number Diff line change
@@ -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<ScreenInfo>
}

class AwtLocalScreensProvider : LocalScreensProvider {
override fun snapshot(): List<ScreenInfo> {
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,
)
}
}
}
Original file line number Diff line number Diff line change
@@ -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<ScreenInfo> {
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() },
)
}
}
Loading