From 64cab9c968c91bc5b76d5235ad132ce5bb306265 Mon Sep 17 00:00:00 2001 From: "Elie G." Date: Tue, 1 Sep 2026 02:09:54 +0300 Subject: [PATCH 1/2] fix: make CheckableItem reactive and serialize tray updates CheckableItem clicks captured the checked value at menu-build time, so repeated clicks without a rebuild kept sending the same toggle (Linux's live lookup read the builder's own never-updated list, so it had the same bug). Each checkable item now keeps a live AtomicBoolean toggled on click, reports the new value to onCheckedChange, and patches the native checkmark immediately via updateMenuItemCheckedState (added to WindowsTrayManager, made submenu-aware on macOS). NativeTray.updateComposable was fire-and-forget on trayScope, letting a stale icon/menu update land after a fresher one when the parent recomposes frequently. Updates now go through a conflated channel with a single worker: applied in order, one at a time, superseded updates dropped. dispose() cancels the scope so an in-flight update cannot re-create a disposed tray. Fixes #432 --- build.gradle.kts | 27 ++++++++ .../lib/mac/MacTrayManager.kt | 23 +++++-- .../lib/windows/WindowsTrayManager.kt | 62 ++++++++++++++++++ .../menu/impl/LinuxTrayMenuBuilderImpl.kt | 35 +++++----- .../menu/impl/MacTrayMenuBuilderImpl.kt | 21 +++--- .../menu/impl/WindowsTrayMenuBuilderImpl.kt | 21 ++++-- .../composenativetray/tray/api/NativeTray.kt | 64 ++++++++++++++++--- .../tray/impl/WindowsTrayInitializer.kt | 13 ++-- 8 files changed, 215 insertions(+), 51 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 46a9307a..8b955f85 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -204,3 +204,30 @@ mavenPublishing { signAllPublications() } } + +val publishAllToMavenLocal by tasks.registering { + group = "publishing" + description = "Publishes all library modules to Maven Local." + + dependsOn(tasks.named("publishToMavenLocal")) +} + +gradle.projectsEvaluated { + publishAllToMavenLocal.configure { + dependsOn(subprojects.mapNotNull { it.tasks.findByName("publishToMavenLocal") }) + } +} + +tasks.register("publishDevToMavenLocal") { + group = "publishing" + description = "Publishes all library modules to Maven Local with version 'dev'." + + workingDir = rootDir + // The publish version is resolved from GITHUB_REF at configuration time, so + // re-invoke the build with it set to force version "dev" everywhere. + environment("GITHUB_REF", "refs/tags/vdev") + + val gradlew = + if (Os.isFamily(Os.FAMILY_WINDOWS)) listOf("cmd", "/c", "gradlew.bat") else listOf("./gradlew") + commandLine(gradlew + listOf("publishAllToMavenLocal", "--no-configuration-cache")) +} diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/mac/MacTrayManager.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/mac/MacTrayManager.kt index 2f2e9e38..3138ee25 100644 --- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/mac/MacTrayManager.kt +++ b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/mac/MacTrayManager.kt @@ -52,21 +52,36 @@ internal class MacTrayManager( } } - // Update a menu item's checked state + // Update a menu item's checked state (including items nested in submenus) fun updateMenuItemCheckedState( label: String, isChecked: Boolean, ) { lock.withLock { - val index = menuItems.indexOfFirst { it.text == label } - if (index != -1) { - menuItems[index] = menuItems[index].copy(isChecked = isChecked) + val patched = patchCheckedState(menuItems.toList(), label, isChecked) + if (patched != menuItems) { + menuItems.clear() + menuItems.addAll(patched) // Recreate the menu to reflect changes recreateMenu() } } } + private fun patchCheckedState( + items: List, + label: String, + checked: Boolean, + ): List = + items.map { item -> + when { + item.isCheckable && item.text == label -> item.copy(isChecked = checked) + item.subMenuItems.isNotEmpty() -> + item.copy(subMenuItems = patchCheckedState(item.subMenuItems, label, checked)) + else -> item + } + } + // Update the tray with new properties and menu items fun update( newIconPath: String, diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/windows/WindowsTrayManager.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/windows/WindowsTrayManager.kt index 6db6ffc8..671f20dd 100644 --- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/windows/WindowsTrayManager.kt +++ b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/windows/WindowsTrayManager.kt @@ -39,6 +39,10 @@ internal class WindowsTrayManager( private val updateQueue = mutableListOf() private val updateQueueLock = Object() + // Last applied menu structure + pending checked-state patches (processed on the tray thread) + private var currentMenuItems: List = emptyList() + private val checkedUpdateQueue = mutableListOf>() + companion object { private fun log(message: String) { debugln { "[WindowsTrayManager] $message" } @@ -75,6 +79,7 @@ internal class WindowsTrayManager( } running.set(true) + currentMenuItems = menuItems // Create coroutine scopes mainScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) @@ -167,6 +172,20 @@ internal class WindowsTrayManager( } } + /** + * Patches the checked state of a checkable item (matched by its full menu text) without + * requiring a full rebuild from the caller. Processed on the tray thread. + */ + fun updateMenuItemCheckedState( + text: String, + isChecked: Boolean, + ) { + synchronized(updateQueueLock) { + checkedUpdateQueue.add(text to isChecked) + updateQueueLock.notify() + } + } + private fun runMessageLoop() { log("Entering message loop on tray thread") var consecutiveErrors = 0 @@ -188,6 +207,7 @@ internal class WindowsTrayManager( // Check for pending updates processUpdateQueue() + processCheckedUpdateQueue() // Process Windows messages with non-blocking call val result = WindowsNativeBridge.nativeLoopTray(0) @@ -295,12 +315,54 @@ internal class WindowsTrayManager( } } + private fun processCheckedUpdateQueue() { + val updates = + synchronized(updateQueueLock) { + if (checkedUpdateQueue.isEmpty()) return + val copy = checkedUpdateQueue.toList() + checkedUpdateQueue.clear() + copy + } + + val handle = trayHandle + if (handle == 0L) return + + currentMenuItems = + updates.fold(currentMenuItems) { items, (text, checked) -> + patchCheckedState(items, text, checked) + } + + log("Applying ${updates.size} checked-state patch(es)") + freeMenuHandles() + setupMenu(handle, currentMenuItems) + try { + WindowsNativeBridge.nativeUpdateTray(handle) + } catch (e: Throwable) { + log("Failed to apply checked-state patch: ${e.message}") + } + } + + private fun patchCheckedState( + items: List, + text: String, + checked: Boolean, + ): List = + items.map { item -> + when { + item.isCheckable && item.text == text -> item.copy(isChecked = checked) + item.subMenuItems.isNotEmpty() -> + item.copy(subMenuItems = patchCheckedState(item.subMenuItems, text, checked)) + else -> item + } + } + private fun performUpdate(update: UpdateRequest) { // Update properties iconPath = update.iconPath tooltip = update.tooltip onLeftClick = update.onLeftClick onMenuOpened = update.onMenuOpened + currentMenuItems = update.menuItems val handle = trayHandle if (handle == 0L) return diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/menu/impl/LinuxTrayMenuBuilderImpl.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/menu/impl/LinuxTrayMenuBuilderImpl.kt index de0a08df..db347612 100644 --- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/menu/impl/LinuxTrayMenuBuilderImpl.kt +++ b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/menu/impl/LinuxTrayMenuBuilderImpl.kt @@ -16,6 +16,7 @@ import dev.nucleusframework.composenativetray.utils.IconRenderProperties import dev.nucleusframework.composenativetray.utils.isMenuBarInDarkMode import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource +import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock @@ -148,24 +149,22 @@ internal class LinuxTrayMenuBuilderImpl( shortcut: KeyShortcut?, ) { lock.withLock { - val initialChecked = checked + // Live state: clicking must toggle the current value, not the one captured + // when the menu was built (issue #432). + val liveChecked = AtomicBoolean(checked) val menuItem = LinuxTrayManager.MenuItem( text = label, isEnabled = isEnabled, isCheckable = true, - isChecked = initialChecked, + isChecked = checked, shortcut = shortcut, onClick = { - lock.withLock { - val currentMenuItem = menuItems.find { it.text == label } - val currentChecked = currentMenuItem?.isChecked ?: initialChecked - val newChecked = !currentChecked - - onCheckedChange(newChecked) - trayManager?.updateMenuItemCheckedState(label, newChecked) - } + val newChecked = !liveChecked.get() + liveChecked.set(newChecked) + onCheckedChange(newChecked) + trayManager?.updateMenuItemCheckedState(label, newChecked) }, ) menuItems.add(menuItem) @@ -185,25 +184,21 @@ internal class LinuxTrayMenuBuilderImpl( lock.withLock { val iconPath = ComposableIconUtils.renderComposableToPngFile(iconRenderProperties, iconContent) - val initialChecked = checked + val liveChecked = AtomicBoolean(checked) val menuItem = LinuxTrayManager.MenuItem( text = label, isEnabled = isEnabled, isCheckable = true, - isChecked = initialChecked, + isChecked = checked, iconPath = iconPath, shortcut = shortcut, onClick = { - lock.withLock { - val currentMenuItem = menuItems.find { it.text == label } - val currentChecked = currentMenuItem?.isChecked ?: initialChecked - val newChecked = !currentChecked - - onCheckedChange(newChecked) - trayManager?.updateMenuItemCheckedState(label, newChecked) - } + val newChecked = !liveChecked.get() + liveChecked.set(newChecked) + onCheckedChange(newChecked) + trayManager?.updateMenuItemCheckedState(label, newChecked) }, ) menuItems.add(menuItem) diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/menu/impl/MacTrayMenuBuilderImpl.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/menu/impl/MacTrayMenuBuilderImpl.kt index 7a5e5277..e9a5d995 100644 --- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/menu/impl/MacTrayMenuBuilderImpl.kt +++ b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/menu/impl/MacTrayMenuBuilderImpl.kt @@ -16,6 +16,7 @@ import dev.nucleusframework.composenativetray.utils.IconRenderProperties import dev.nucleusframework.darkmodedetector.isSystemInDarkMode import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource +import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock @@ -148,6 +149,9 @@ internal class MacTrayMenuBuilderImpl( shortcut: KeyShortcut?, ) { lock.withLock { + // Live state: clicking must toggle the current value, not the one captured + // when the menu was built (issue #432). + val liveChecked = AtomicBoolean(checked) val menuItem = MacTrayManager.MenuItem( text = label, @@ -157,10 +161,10 @@ internal class MacTrayMenuBuilderImpl( isChecked = checked, shortcut = shortcut, onClick = { - lock.withLock { - val newChecked = !checked - onCheckedChange(newChecked) - } + val newChecked = !liveChecked.get() + liveChecked.set(newChecked) + onCheckedChange(newChecked) + trayManager?.updateMenuItemCheckedState(label, newChecked) }, ) menuItems.add(menuItem) @@ -180,6 +184,7 @@ internal class MacTrayMenuBuilderImpl( lock.withLock { val iconPath = ComposableIconUtils.renderComposableToPngFile(iconRenderProperties, iconContent) + val liveChecked = AtomicBoolean(checked) val menuItem = MacTrayManager.MenuItem( text = label, @@ -189,10 +194,10 @@ internal class MacTrayMenuBuilderImpl( isChecked = checked, shortcut = shortcut, onClick = { - lock.withLock { - val newChecked = !checked - onCheckedChange(newChecked) - } + val newChecked = !liveChecked.get() + liveChecked.set(newChecked) + onCheckedChange(newChecked) + trayManager?.updateMenuItemCheckedState(label, newChecked) }, ) menuItems.add(menuItem) diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/menu/impl/WindowsTrayMenuBuilderImpl.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/menu/impl/WindowsTrayMenuBuilderImpl.kt index 18470520..cea1c363 100644 --- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/menu/impl/WindowsTrayMenuBuilderImpl.kt +++ b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/menu/impl/WindowsTrayMenuBuilderImpl.kt @@ -16,6 +16,7 @@ import dev.nucleusframework.composenativetray.utils.IconRenderProperties import dev.nucleusframework.composenativetray.utils.isMenuBarInDarkMode import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource +import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock @@ -23,6 +24,7 @@ internal class WindowsTrayMenuBuilderImpl( private val iconPath: String, private val tooltip: String = "", private val onLeftClick: (() -> Unit)?, + private val trayManager: WindowsTrayManager? = null, ) : TrayMenuBuilder { private val menuItems = mutableListOf() private val lock = ReentrantLock() @@ -146,16 +148,22 @@ internal class WindowsTrayMenuBuilderImpl( shortcut: KeyShortcut?, ) { lock.withLock { + val text = label.withShortcut(shortcut) + // Live state: clicking must toggle the current value, not the one captured + // when the menu was built (issue #432). + val liveChecked = AtomicBoolean(checked) val menuItem = WindowsTrayManager.MenuItem( - text = label.withShortcut(shortcut), + text = text, iconPath = null, isEnabled = isEnabled, isCheckable = true, isChecked = checked, onClick = { - val newChecked = !checked + val newChecked = !liveChecked.get() + liveChecked.set(newChecked) onCheckedChange(newChecked) + trayManager?.updateMenuItemCheckedState(text, newChecked) }, ) menuItems.add(menuItem) @@ -175,16 +183,20 @@ internal class WindowsTrayMenuBuilderImpl( lock.withLock { val iconPath = ComposableIconUtils.renderComposableToIcoFile(iconRenderProperties, iconContent) + val text = label.withShortcut(shortcut) + val liveChecked = AtomicBoolean(checked) val menuItem = WindowsTrayManager.MenuItem( - text = label.withShortcut(shortcut), + text = text, iconPath = iconPath, isEnabled = isEnabled, isCheckable = true, isChecked = checked, onClick = { - val newChecked = !checked + val newChecked = !liveChecked.get() + liveChecked.set(newChecked) onCheckedChange(newChecked) + trayManager?.updateMenuItemCheckedState(text, newChecked) }, ) menuItems.add(menuItem) @@ -356,6 +368,7 @@ internal class WindowsTrayMenuBuilderImpl( this.iconPath, tooltip, onLeftClick = onLeftClick, + trayManager = trayManager, ).apply(submenuContent) subMenuItems.addAll(subMenuImpl.menuItems) } diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/NativeTray.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/NativeTray.kt index bc928908..b560f347 100644 --- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/NativeTray.kt +++ b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/NativeTray.kt @@ -32,6 +32,8 @@ import dev.nucleusframework.darkmodedetector.isSystemInDarkMode import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.jetbrains.compose.resources.DrawableResource @@ -50,6 +52,32 @@ class NativeTray { private val instanceId: String = "tray-" + System.identityHashCode(this) private var initialized = false + private data class ComposableUpdate( + val iconContent: @Composable () -> Unit, + val iconRenderProperties: IconRenderProperties, + val tooltip: String, + val primaryAction: (() -> Unit)?, + val menuContent: (TrayMenuBuilder.() -> Unit)?, + val maxAttempts: Int, + val backoffMs: Long, + val lightIconContent: (@Composable () -> Unit)?, + val darkIconContent: (@Composable () -> Unit)?, + val onMenuOpened: (() -> Unit)?, + ) + + // Conflated so updates are applied one at a time, in submission order, and a + // newer update replaces any queued-but-not-yet-applied one. This prevents a + // stale render/menu from landing after a fresher one (issue #432). + private val composableUpdates = Channel(Channel.CONFLATED) + + init { + trayScope.launch { + for (update in composableUpdates) { + applyComposableUpdate(update) + } + } + } + // Expose the unique instance key so UI code (TrayApp) can compute per-instance positions fun instanceKey(): String = instanceId @@ -123,14 +151,31 @@ class NativeTray { darkIconContent: (@Composable () -> Unit)? = null, onMenuOpened: (() -> Unit)? = null, ) { - trayScope.launch { + composableUpdates.trySend( + ComposableUpdate( + iconContent = iconContent, + iconRenderProperties = iconRenderProperties, + tooltip = tooltip, + primaryAction = primaryAction, + menuContent = menuContent, + maxAttempts = maxAttempts, + backoffMs = backoffMs, + lightIconContent = lightIconContent, + darkIconContent = darkIconContent, + onMenuOpened = onMenuOpened, + ), + ) + } + + private suspend fun applyComposableUpdate(update: ComposableUpdate) { + with(update) { val rendered = renderIconsWithRetry(iconContent, iconRenderProperties, maxAttempts, backoffMs) if (rendered == null) { errorln { "[NativeTray] Icon rendering failed after $maxAttempts attempts. " + "Tray will not be created/updated." } - return@launch + return } val (pngIconPath, windowsIconPath) = rendered @@ -178,14 +223,12 @@ class NativeTray { } // On macOS, pre-render light/dark variants for instant appearance switching - if (os == MacOS && lightIconContent != null && darkIconContent != null) { + val light = lightIconContent + val dark = darkIconContent + if (os == MacOS && light != null && dark != null) { try { - val lightPath = - ComposableIconUtils.renderComposableToPngFile( - iconRenderProperties, - lightIconContent, - ) - val darkPath = ComposableIconUtils.renderComposableToPngFile(iconRenderProperties, darkIconContent) + val lightPath = ComposableIconUtils.renderComposableToPngFile(iconRenderProperties, light) + val darkPath = ComposableIconUtils.renderComposableToPngFile(iconRenderProperties, dark) MacTrayInitializer.setAppearanceIcons(instanceId, lightPath, darkPath) } catch (th: Throwable) { errorln { "[NativeTray] Failed to render appearance icons: $th" } @@ -244,6 +287,9 @@ class NativeTray { } fun dispose() { + // Stop the update worker and drop any pending update so a stale in-flight + // render cannot re-create the tray after disposal. + trayScope.cancel() when (os) { Linux -> LinuxTrayInitializer.dispose(instanceId) Windows -> WindowsTrayInitializer.dispose(instanceId) diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/impl/WindowsTrayInitializer.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/impl/WindowsTrayInitializer.kt index c5ab2cc1..0e0379be 100644 --- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/impl/WindowsTrayInitializer.kt +++ b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/impl/WindowsTrayInitializer.kt @@ -28,16 +28,17 @@ object WindowsTrayInitializer { menuContent: (TrayMenuBuilder.() -> Unit)? = null, onMenuOpened: (() -> Unit)? = null, ) { + val existing = trayManagers[id] + val manager = existing ?: WindowsTrayManager(id, iconPath, tooltip, onLeftClick, onMenuOpened) + trayManagers[id] = manager + val menuItems = - WindowsTrayMenuBuilderImpl(iconPath, tooltip, onLeftClick).apply { + WindowsTrayMenuBuilderImpl(iconPath, tooltip, onLeftClick, trayManager = manager).apply { menuContent?.let { it() } }.build() - val manager = trayManagers[id] - if (manager == null) { - val windowsTrayManager = WindowsTrayManager(id, iconPath, tooltip, onLeftClick, onMenuOpened) - trayManagers[id] = windowsTrayManager - windowsTrayManager.initialize(menuItems) + if (existing == null) { + manager.initialize(menuItems) } else { manager.update(iconPath, tooltip, onLeftClick, onMenuOpened, menuItems) } From 0c734320611aaa5f8afe5d6f99a2f507672df50c Mon Sep 17 00:00:00 2001 From: "Elie G." Date: Tue, 1 Sep 2026 02:13:12 +0300 Subject: [PATCH 2/2] fix(build): use absolute gradlew path in publishDevToMavenLocal --- build.gradle.kts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 8b955f85..b1754ce9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -228,6 +228,10 @@ tasks.register("publishDevToMavenLocal") { environment("GITHUB_REF", "refs/tags/vdev") val gradlew = - if (Os.isFamily(Os.FAMILY_WINDOWS)) listOf("cmd", "/c", "gradlew.bat") else listOf("./gradlew") + if (Os.isFamily(Os.FAMILY_WINDOWS)) { + listOf("cmd", "/c", rootDir.resolve("gradlew.bat").absolutePath) + } else { + listOf(rootDir.resolve("gradlew").absolutePath) + } commandLine(gradlew + listOf("publishAllToMavenLocal", "--no-configuration-cache")) }