diff --git a/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/DynamicIconsDemo.kt b/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/DynamicIconsDemo.kt index 7d00759..7b5d970 100644 --- a/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/DynamicIconsDemo.kt +++ b/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/DynamicIconsDemo.kt @@ -56,16 +56,19 @@ fun main() = application { // Basic state for tray icon var currentTrayIcon by remember { mutableStateOf(Icons.Default.Notifications) } - + // States for menu item icons var weatherIcon by remember { mutableStateOf(Icons.Default.WbSunny) } var musicIcon by remember { mutableStateOf(Icons.Default.MusicNote) } var settingsIcon by remember { mutableStateOf(Icons.Default.Settings) } - + // States for theme and features var isDarkTheme by remember { mutableStateOf(false) } var isWeatherEnabled by remember { mutableStateOf(true) } var isMusicEnabled by remember { mutableStateOf(true) } + + // Counter to demonstrate onMenuOpened callback + var menuOpenCount by remember { mutableIntStateOf(0) } // Always create the Tray composable, but make it conditional on visibility val showTray = alwaysShowTray || !isWindowVisible @@ -77,7 +80,11 @@ fun main() = application { primaryAction = { isWindowVisible = true println("$logTag: Primary action clicked") - } + }, + onMenuOpened = { + menuOpenCount++ + println("$logTag: Menu opened (count: $menuOpenCount)") + }, ) { // Weather submenu with dynamic icon SubMenu(label = "Weather", icon = weatherIcon) { @@ -249,6 +256,12 @@ fun main() = application { Text( "This demo showcases dynamic icon changes in the system tray menu.", + modifier = Modifier.padding(bottom = 8.dp) + ) + + Text( + "Tray menu opened $menuOpenCount times", + style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(bottom = 24.dp) ) diff --git a/src/jvmMain/kotlin/com/kdroid/composetray/lib/linux/LinuxNativeBridge.kt b/src/jvmMain/kotlin/com/kdroid/composetray/lib/linux/LinuxNativeBridge.kt index 05a31c7..eb6581a 100644 --- a/src/jvmMain/kotlin/com/kdroid/composetray/lib/linux/LinuxNativeBridge.kt +++ b/src/jvmMain/kotlin/com/kdroid/composetray/lib/linux/LinuxNativeBridge.kt @@ -65,6 +65,12 @@ internal object LinuxNativeBridge { callback: Runnable?, ) + /** Register a callback invoked when the menu is about to be shown. */ + @JvmStatic external fun nativeSetMenuOpenedCallback( + handle: Long, + callback: Runnable?, + ) + // -- Click position ---------------------------------------------------------- /** Writes [x, y] into outXY. */ diff --git a/src/jvmMain/kotlin/com/kdroid/composetray/lib/linux/LinuxTrayManager.kt b/src/jvmMain/kotlin/com/kdroid/composetray/lib/linux/LinuxTrayManager.kt index 883d868..6b45938 100644 --- a/src/jvmMain/kotlin/com/kdroid/composetray/lib/linux/LinuxTrayManager.kt +++ b/src/jvmMain/kotlin/com/kdroid/composetray/lib/linux/LinuxTrayManager.kt @@ -23,6 +23,7 @@ internal class LinuxTrayManager( private var iconPath: String, private var tooltip: String = "", private var onLeftClick: (() -> Unit)? = null, + private var onMenuOpened: (() -> Unit)? = null, ) { companion object { // Ensures only one systray runtime is active at a time @@ -99,6 +100,7 @@ internal class LinuxTrayManager( newTooltip: String, newOnLeftClick: (() -> Unit)?, newMenuItems: List?, + newOnMenuOpened: (() -> Unit)? = null, ) { val iconChanged: Boolean val tooltipChanged: Boolean @@ -109,6 +111,7 @@ internal class LinuxTrayManager( iconPath = newIconPath tooltip = newTooltip onLeftClick = newOnLeftClick + onMenuOpened = newOnMenuOpened if (newMenuItems != null) { menuItems.clear() menuItems.addAll(newMenuItems) @@ -174,6 +177,12 @@ internal class LinuxTrayManager( }, ) + // Set menu-opened callback + native.nativeSetMenuOpenedCallback( + trayHandle, + JniRunnable { onMenuOpened?.invoke() }, + ) + // Build menu before starting the loop rebuildMenu() diff --git a/src/jvmMain/kotlin/com/kdroid/composetray/lib/mac/MacNativeBridge.kt b/src/jvmMain/kotlin/com/kdroid/composetray/lib/mac/MacNativeBridge.kt index 1e32d0d..f8ea8c1 100644 --- a/src/jvmMain/kotlin/com/kdroid/composetray/lib/mac/MacNativeBridge.kt +++ b/src/jvmMain/kotlin/com/kdroid/composetray/lib/mac/MacNativeBridge.kt @@ -35,6 +35,11 @@ internal object MacNativeBridge { callback: Runnable?, ) + @JvmStatic external fun nativeSetMenuOpenedCallback( + handle: Long, + callback: Runnable?, + ) + @JvmStatic external fun nativeSetTrayMenu( trayHandle: Long, menuHandle: Long, diff --git a/src/jvmMain/kotlin/com/kdroid/composetray/lib/mac/MacTrayManager.kt b/src/jvmMain/kotlin/com/kdroid/composetray/lib/mac/MacTrayManager.kt index f46aaea..05bef08 100644 --- a/src/jvmMain/kotlin/com/kdroid/composetray/lib/mac/MacTrayManager.kt +++ b/src/jvmMain/kotlin/com/kdroid/composetray/lib/mac/MacTrayManager.kt @@ -15,6 +15,7 @@ internal class MacTrayManager( private var iconPath: String, private var tooltip: String = "", onLeftClick: (() -> Unit)? = null, + onMenuOpened: (() -> Unit)? = null, ) { private var trayHandle: Long = 0L private var menuHandle: Long = 0L @@ -33,6 +34,7 @@ internal class MacTrayManager( private val submenuHandles: MutableList> = mutableListOf() private val onLeftClickCallback = mutableStateOf(onLeftClick) + private var onMenuOpenedCallback: (() -> Unit)? = onMenuOpened // Top level MenuItem class data class MenuItem( @@ -72,6 +74,7 @@ internal class MacTrayManager( newTooltip: String, newOnLeftClick: (() -> Unit)?, newMenuItems: List? = null, + newOnMenuOpened: (() -> Unit)? = null, ) { lock.withLock { if (!running.get() || trayHandle == 0L) return @@ -80,11 +83,13 @@ internal class MacTrayManager( val iconChanged = this.iconPath != newIconPath val tooltipChanged = this.tooltip != newTooltip val onLeftClickChanged = this.onLeftClickCallback.value != newOnLeftClick + val onMenuOpenedChanged = this.onMenuOpenedCallback != newOnMenuOpened // Update icon path and tooltip this.iconPath = newIconPath this.tooltip = newTooltip this.onLeftClickCallback.value = newOnLeftClick + this.onMenuOpenedCallback = newOnMenuOpened if (iconChanged) { MacNativeBridge.nativeSetTrayIcon(trayHandle, newIconPath) @@ -95,6 +100,9 @@ internal class MacTrayManager( if (onLeftClickChanged) { initializeOnLeftClickCallback() } + if (onMenuOpenedChanged) { + initializeOnMenuOpenedCallback() + } // Update menu items if provided if (newMenuItems != null) { @@ -167,6 +175,9 @@ internal class MacTrayManager( throw IllegalStateException("Failed to initialize tray: $initResult") } + // Set menu-opened callback after init (TrayContext must exist) + initializeOnMenuOpenedCallback() + // Signal that initialization is complete initLatch.countDown() @@ -218,6 +229,26 @@ internal class MacTrayManager( } } + private fun initializeOnMenuOpenedCallback() { + if (trayHandle == 0L) return + + val callback = onMenuOpenedCallback + if (callback != null) { + MacNativeBridge.nativeSetMenuOpenedCallback( + trayHandle, + Runnable { + mainScope?.launch { + ioScope?.launch { + callback() + } + } + }, + ) + } else { + MacNativeBridge.nativeSetMenuOpenedCallback(trayHandle, null) + } + } + private fun initializeTrayMenu() { if (trayHandle == 0L) return diff --git a/src/jvmMain/kotlin/com/kdroid/composetray/lib/windows/WindowsNativeBridge.kt b/src/jvmMain/kotlin/com/kdroid/composetray/lib/windows/WindowsNativeBridge.kt index 1696aa4..ce5c590 100644 --- a/src/jvmMain/kotlin/com/kdroid/composetray/lib/windows/WindowsNativeBridge.kt +++ b/src/jvmMain/kotlin/com/kdroid/composetray/lib/windows/WindowsNativeBridge.kt @@ -36,6 +36,11 @@ internal object WindowsNativeBridge { callback: Runnable?, ) + @JvmStatic external fun nativeSetMenuOpenedCallback( + handle: Long, + callback: Runnable?, + ) + @JvmStatic external fun nativeSetTrayMenu( trayHandle: Long, menuHandle: Long, diff --git a/src/jvmMain/kotlin/com/kdroid/composetray/lib/windows/WindowsTrayManager.kt b/src/jvmMain/kotlin/com/kdroid/composetray/lib/windows/WindowsTrayManager.kt index e4e112a..ee84db9 100644 --- a/src/jvmMain/kotlin/com/kdroid/composetray/lib/windows/WindowsTrayManager.kt +++ b/src/jvmMain/kotlin/com/kdroid/composetray/lib/windows/WindowsTrayManager.kt @@ -18,6 +18,7 @@ internal class WindowsTrayManager( private var iconPath: String, private var tooltip: String = "", private var onLeftClick: (() -> Unit)? = null, + private var onMenuOpened: (() -> Unit)? = null, ) { private var trayHandle: Long = 0L private val running = AtomicBoolean(false) @@ -50,6 +51,7 @@ internal class WindowsTrayManager( val iconPath: String, val tooltip: String, val onLeftClick: (() -> Unit)?, + val onMenuOpened: (() -> Unit)?, val menuItems: List, ) @@ -69,7 +71,7 @@ internal class WindowsTrayManager( updateLock.withLock { if (initialized.get()) { log("Already initialized, delegating to update()") - update(iconPath, tooltip, onLeftClick, menuItems) + update(iconPath, tooltip, onLeftClick, onMenuOpened, menuItems) return } @@ -103,6 +105,9 @@ internal class WindowsTrayManager( throw RuntimeException("Failed to initialize tray: $initResult") } + // Set menu-opened callback after init (TrayContext must exist) + setupMenuOpenedCallback(handle) + initialized.set(true) // Signal that initialization is complete before entering the loop @@ -141,6 +146,7 @@ internal class WindowsTrayManager( newIconPath: String, newTooltip: String, newOnLeftClick: (() -> Unit)?, + newOnMenuOpened: (() -> Unit)?, newMenuItems: List, ) { log("update() called - icon: $newIconPath, tooltip: $newTooltip, menuItems: ${newMenuItems.size}") @@ -150,13 +156,14 @@ internal class WindowsTrayManager( iconPath = newIconPath tooltip = newTooltip onLeftClick = newOnLeftClick + onMenuOpened = newOnMenuOpened initialize(newMenuItems) return } // Queue the update to be processed on the tray thread synchronized(updateQueueLock) { - updateQueue.add(UpdateRequest(newIconPath, newTooltip, newOnLeftClick, newMenuItems)) + updateQueue.add(UpdateRequest(newIconPath, newTooltip, newOnLeftClick, newOnMenuOpened, newMenuItems)) updateQueueLock.notify() } } @@ -357,6 +364,7 @@ internal class WindowsTrayManager( iconPath = update.iconPath tooltip = update.tooltip onLeftClick = update.onLeftClick + onMenuOpened = update.onMenuOpened val handle = trayHandle if (handle == 0L) return @@ -371,6 +379,7 @@ internal class WindowsTrayManager( // Set up new callbacks and menu setupLeftClickCallback(handle) setupMenu(handle, update.menuItems) + setupMenuOpenedCallback(handle) // Update the native tray log("Calling nativeUpdateTray()") @@ -416,6 +425,32 @@ internal class WindowsTrayManager( } } + private fun setupMenuOpenedCallback(handle: Long) { + val callback = onMenuOpened + if (callback != null) { + log("Setting up menu opened callback") + WindowsNativeBridge.nativeSetMenuOpenedCallback( + handle, + Runnable { + log("Menu opened callback invoked") + try { + mainScope?.launch { + ioScope?.launch { + callback() + } + } + } catch (e: Exception) { + log("Error in menu opened callback: ${e.message}") + e.printStackTrace() + } + }, + ) + } else { + log("No menu opened callback set") + WindowsNativeBridge.nativeSetMenuOpenedCallback(handle, null) + } + } + private fun setupMenu( handle: Long, menuItems: List, diff --git a/src/jvmMain/kotlin/com/kdroid/composetray/tray/api/NativeTray.kt b/src/jvmMain/kotlin/com/kdroid/composetray/tray/api/NativeTray.kt index 3932566..62d7459 100644 --- a/src/jvmMain/kotlin/com/kdroid/composetray/tray/api/NativeTray.kt +++ b/src/jvmMain/kotlin/com/kdroid/composetray/tray/api/NativeTray.kt @@ -56,6 +56,7 @@ internal class NativeTray { tooltip: String, primaryAction: (() -> Unit)?, menuContent: (TrayMenuBuilder.() -> Unit)?, + onMenuOpened: (() -> Unit)? = null, ) { if (!initialized) { initializeTray(iconPath, windowsIconPath, tooltip, primaryAction, menuContent) @@ -65,7 +66,15 @@ internal class NativeTray { try { when (os) { - LINUX -> LinuxTrayInitializer.update(instanceId, iconPath, tooltip, primaryAction, menuContent) + LINUX -> + LinuxTrayInitializer.update( + instanceId, + iconPath, + tooltip, + primaryAction, + menuContent, + onMenuOpened, + ) WINDOWS -> WindowsTrayInitializer.update( instanceId, @@ -73,8 +82,17 @@ internal class NativeTray { tooltip, primaryAction, menuContent, + onMenuOpened, + ) + MACOS -> + MacTrayInitializer.update( + instanceId, + iconPath, + tooltip, + primaryAction, + menuContent, + onMenuOpened, ) - MACOS -> MacTrayInitializer.update(instanceId, iconPath, tooltip, primaryAction, menuContent) UNKNOWN -> { AwtTrayInitializer.update(iconPath, tooltip, primaryAction, menuContent) awtTrayUsed.set(true) @@ -103,6 +121,7 @@ internal class NativeTray { backoffMs: Long = 200, lightIconContent: (@Composable () -> Unit)? = null, darkIconContent: (@Composable () -> Unit)? = null, + onMenuOpened: (() -> Unit)? = null, ) { trayScope.launch { val rendered = renderIconsWithRetry(iconContent, iconRenderProperties, maxAttempts, backoffMs) @@ -117,7 +136,7 @@ internal class NativeTray { val (pngIconPath, windowsIconPath) = rendered if (!initialized) { - initializeTray(pngIconPath, windowsIconPath, tooltip, primaryAction, menuContent) + initializeTray(pngIconPath, windowsIconPath, tooltip, primaryAction, menuContent, onMenuOpened) initialized = true } else { try { @@ -129,6 +148,7 @@ internal class NativeTray { tooltip, primaryAction, menuContent, + onMenuOpened, ) WINDOWS -> WindowsTrayInitializer.update( @@ -137,8 +157,17 @@ internal class NativeTray { tooltip, primaryAction, menuContent, + onMenuOpened, + ) + MACOS -> + MacTrayInitializer.update( + instanceId, + pngIconPath, + tooltip, + primaryAction, + menuContent, + onMenuOpened, ) - MACOS -> MacTrayInitializer.update(instanceId, pngIconPath, tooltip, primaryAction, menuContent) UNKNOWN -> { AwtTrayInitializer.update(pngIconPath, tooltip, primaryAction, menuContent) awtTrayUsed.set(true) @@ -241,6 +270,7 @@ internal class NativeTray { tooltip: String = "", primaryAction: (() -> Unit)?, menuContent: (TrayMenuBuilder.() -> Unit)? = null, + onMenuOpened: (() -> Unit)? = null, ) { trayScope.launch { var trayInitialized = false @@ -249,7 +279,14 @@ internal class NativeTray { when (os) { LINUX -> { debugln { "[NativeTray] Initializing Linux tray with icon path: $iconPath" } - LinuxTrayInitializer.initialize(instanceId, iconPath, tooltip, primaryAction, menuContent) + LinuxTrayInitializer.initialize( + instanceId, + iconPath, + tooltip, + primaryAction, + menuContent, + onMenuOpened, + ) trayInitialized = true } WINDOWS -> { @@ -260,12 +297,20 @@ internal class NativeTray { tooltip, primaryAction, menuContent, + onMenuOpened, ) trayInitialized = true } MACOS -> { debugln { "[NativeTray] Initializing macOS tray with icon path: $iconPath" } - MacTrayInitializer.initialize(instanceId, iconPath, tooltip, primaryAction, menuContent) + MacTrayInitializer.initialize( + instanceId, + iconPath, + tooltip, + primaryAction, + menuContent, + onMenuOpened, + ) trayInitialized = true } else -> {} @@ -301,6 +346,7 @@ internal class NativeTray { tooltip: String = "", primaryAction: (() -> Unit)?, menuContent: (TrayMenuBuilder.() -> Unit)? = null, + onMenuOpened: (() -> Unit)? = null, ) { updateComposable( iconContent = iconContent, @@ -308,6 +354,7 @@ internal class NativeTray { tooltip = tooltip, primaryAction = primaryAction, menuContent = menuContent, + onMenuOpened = onMenuOpened, ) } } @@ -325,6 +372,7 @@ fun ApplicationScope.Tray( windowsIconPath: String = iconPath, tooltip: String, primaryAction: (() -> Unit)? = null, + onMenuOpened: (() -> Unit)? = null, menuContent: (TrayMenuBuilder.() -> Unit)? = null, ) { val absoluteIconPath = remember(iconPath) { extractToTempIfDifferent(iconPath)?.absolutePath.orEmpty() } @@ -344,7 +392,7 @@ fun ApplicationScope.Tray( // Update when params change, including menuHash LaunchedEffect(absoluteIconPath, absoluteWindowsIconPath, tooltip, primaryAction, menuContent, menuHash) { - tray.update(absoluteIconPath, absoluteWindowsIconPath, tooltip, primaryAction, menuContent) + tray.update(absoluteIconPath, absoluteWindowsIconPath, tooltip, primaryAction, menuContent, onMenuOpened) } // Dispose only when Tray is removed from composition @@ -362,6 +410,7 @@ fun ApplicationScope.Tray( iconRenderProperties: IconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(), tooltip: String, primaryAction: (() -> Unit)? = null, + onMenuOpened: (() -> Unit)? = null, menuContent: (TrayMenuBuilder.() -> Unit)? = null, ) { val isDark = isMenuBarInDarkMode() // Observe menu bar theme to trigger recomposition on changes @@ -384,6 +433,7 @@ fun ApplicationScope.Tray( menuContent = menuContent, maxAttempts = 3, backoffMs = 200, + onMenuOpened = onMenuOpened, ) } @@ -402,6 +452,7 @@ fun ApplicationScope.Tray( iconRenderProperties: IconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(), tooltip: String, primaryAction: (() -> Unit)? = null, + onMenuOpened: (() -> Unit)? = null, menuContent: (TrayMenuBuilder.() -> Unit)? = null, ) { val isDark = isMenuBarInDarkMode() @@ -477,6 +528,7 @@ fun ApplicationScope.Tray( backoffMs = 200, lightIconContent = lightIconContent, darkIconContent = darkIconContent, + onMenuOpened = onMenuOpened, ) } @@ -494,6 +546,7 @@ fun ApplicationScope.Tray( iconRenderProperties: IconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(), tooltip: String, primaryAction: (() -> Unit)? = null, + onMenuOpened: (() -> Unit)? = null, menuContent: (TrayMenuBuilder.() -> Unit)? = null, ) { val isDark = isMenuBarInDarkMode() // Included for consistency, even if not used in rendering @@ -527,6 +580,7 @@ fun ApplicationScope.Tray( menuContent = menuContent, maxAttempts = 3, backoffMs = 200, + onMenuOpened = onMenuOpened, ) } @@ -549,6 +603,7 @@ fun ApplicationScope.Tray( iconRenderProperties: IconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(), tooltip: String, primaryAction: (() -> Unit)? = null, + onMenuOpened: (() -> Unit)? = null, menuContent: (TrayMenuBuilder.() -> Unit)? = null, ) { val os = getOperatingSystem() @@ -560,6 +615,7 @@ fun ApplicationScope.Tray( iconRenderProperties = iconRenderProperties, tooltip = tooltip, primaryAction = primaryAction, + onMenuOpened = onMenuOpened, menuContent = menuContent, ) } else { @@ -570,6 +626,7 @@ fun ApplicationScope.Tray( iconRenderProperties = iconRenderProperties, tooltip = tooltip, primaryAction = primaryAction, + onMenuOpened = onMenuOpened, menuContent = menuContent, ) } @@ -584,6 +641,7 @@ fun ApplicationScope.Tray( iconRenderProperties: IconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(), tooltip: String, primaryAction: (() -> Unit)? = null, + onMenuOpened: (() -> Unit)? = null, menuContent: (TrayMenuBuilder.() -> Unit)? = null, ) { // Convert DrawableResource to Painter and delegate to the Painter overload @@ -593,6 +651,7 @@ fun ApplicationScope.Tray( iconRenderProperties = iconRenderProperties, tooltip = tooltip, primaryAction = primaryAction, + onMenuOpened = onMenuOpened, menuContent = menuContent, ) } @@ -605,6 +664,7 @@ fun ApplicationScope.Tray( iconRenderProperties: IconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(), tooltip: String, primaryAction: (() -> Unit)? = null, + onMenuOpened: (() -> Unit)? = null, menuContent: (TrayMenuBuilder.() -> Unit)? = null, ) { val os = getOperatingSystem() @@ -617,6 +677,7 @@ fun ApplicationScope.Tray( iconRenderProperties = iconRenderProperties, tooltip = tooltip, primaryAction = primaryAction, + onMenuOpened = onMenuOpened, menuContent = menuContent, ) } else { @@ -627,6 +688,7 @@ fun ApplicationScope.Tray( iconRenderProperties = iconRenderProperties, tooltip = tooltip, primaryAction = primaryAction, + onMenuOpened = onMenuOpened, menuContent = menuContent, ) } diff --git a/src/jvmMain/kotlin/com/kdroid/composetray/tray/impl/LinuxTrayInitializer.kt b/src/jvmMain/kotlin/com/kdroid/composetray/tray/impl/LinuxTrayInitializer.kt index 7ec0c8e..6285b3e 100644 --- a/src/jvmMain/kotlin/com/kdroid/composetray/tray/impl/LinuxTrayInitializer.kt +++ b/src/jvmMain/kotlin/com/kdroid/composetray/tray/impl/LinuxTrayInitializer.kt @@ -20,11 +20,12 @@ object LinuxTrayInitializer { tooltip: String, onLeftClick: (() -> Unit)? = null, menuContent: (TrayMenuBuilder.() -> Unit)? = null, + onMenuOpened: (() -> Unit)? = null, ) { lock.withLock { val existing = linuxTrayManagers[id] if (existing == null) { - val manager = LinuxTrayManager(id, iconPath, tooltip, onLeftClick) + val manager = LinuxTrayManager(id, iconPath, tooltip, onLeftClick, onMenuOpened) linuxTrayManagers[id] = manager val menuImpl = @@ -43,7 +44,7 @@ object LinuxTrayInitializer { manager.startTray() } else { - update(id, iconPath, tooltip, onLeftClick, menuContent) + update(id, iconPath, tooltip, onLeftClick, menuContent, onMenuOpened) } } } @@ -55,11 +56,12 @@ object LinuxTrayInitializer { tooltip: String, onLeftClick: (() -> Unit)? = null, menuContent: (TrayMenuBuilder.() -> Unit)? = null, + onMenuOpened: (() -> Unit)? = null, ) { lock.withLock { val manager = linuxTrayManagers[id] if (manager == null) { - initialize(id, iconPath, tooltip, onLeftClick, menuContent) + initialize(id, iconPath, tooltip, onLeftClick, menuContent, onMenuOpened) return } @@ -76,7 +78,7 @@ object LinuxTrayInitializer { null } - manager.update(iconPath, tooltip, onLeftClick, newMenuItems) + manager.update(iconPath, tooltip, onLeftClick, newMenuItems, onMenuOpened) } } @@ -114,14 +116,16 @@ object LinuxTrayInitializer { tooltip: String, onLeftClick: (() -> Unit)? = null, menuContent: (TrayMenuBuilder.() -> Unit)? = null, - ) = initialize(DEFAULT_ID, iconPath, tooltip, onLeftClick, menuContent) + onMenuOpened: (() -> Unit)? = null, + ) = initialize(DEFAULT_ID, iconPath, tooltip, onLeftClick, menuContent, onMenuOpened) fun update( iconPath: String, tooltip: String, onLeftClick: (() -> Unit)? = null, menuContent: (TrayMenuBuilder.() -> Unit)? = null, - ) = update(DEFAULT_ID, iconPath, tooltip, onLeftClick, menuContent) + onMenuOpened: (() -> Unit)? = null, + ) = update(DEFAULT_ID, iconPath, tooltip, onLeftClick, menuContent, onMenuOpened) fun dispose() = dispose(DEFAULT_ID) } diff --git a/src/jvmMain/kotlin/com/kdroid/composetray/tray/impl/MacTrayInitializer.kt b/src/jvmMain/kotlin/com/kdroid/composetray/tray/impl/MacTrayInitializer.kt index 02e1c37..e657de8 100644 --- a/src/jvmMain/kotlin/com/kdroid/composetray/tray/impl/MacTrayInitializer.kt +++ b/src/jvmMain/kotlin/com/kdroid/composetray/tray/impl/MacTrayInitializer.kt @@ -25,11 +25,12 @@ object MacTrayInitializer { tooltip: String, onLeftClick: (() -> Unit)? = null, menuContent: (TrayMenuBuilder.() -> Unit)? = null, + onMenuOpened: (() -> Unit)? = null, ) { var manager = trayManagers[id] if (manager == null) { // Create a new manager for this ID - manager = MacTrayManager(iconPath, tooltip, onLeftClick) + manager = MacTrayManager(iconPath, tooltip, onLeftClick, onMenuOpened) trayManagers[id] = manager // Build menu for this manager @@ -54,7 +55,7 @@ object MacTrayInitializer { manager.startTray() } else { // Existing manager: delegate to update with the provided content - update(id, iconPath, tooltip, onLeftClick, menuContent) + update(id, iconPath, tooltip, onLeftClick, menuContent, onMenuOpened) } } @@ -65,11 +66,12 @@ object MacTrayInitializer { tooltip: String, onLeftClick: (() -> Unit)? = null, menuContent: (TrayMenuBuilder.() -> Unit)? = null, + onMenuOpened: (() -> Unit)? = null, ) { val manager = trayManagers[id] if (manager == null) { // If manager doesn't exist, initialize it - initialize(id, iconPath, tooltip, onLeftClick, menuContent) + initialize(id, iconPath, tooltip, onLeftClick, menuContent, onMenuOpened) return } @@ -92,7 +94,7 @@ object MacTrayInitializer { null } - manager.update(iconPath, tooltip, onLeftClick, newMenuItems) + manager.update(iconPath, tooltip, onLeftClick, newMenuItems, onMenuOpened) } @Synchronized @@ -116,14 +118,16 @@ object MacTrayInitializer { tooltip: String, onLeftClick: (() -> Unit)? = null, menuContent: (TrayMenuBuilder.() -> Unit)? = null, - ) = initialize(DEFAULT_ID, iconPath, tooltip, onLeftClick, menuContent) + onMenuOpened: (() -> Unit)? = null, + ) = initialize(DEFAULT_ID, iconPath, tooltip, onLeftClick, menuContent, onMenuOpened) fun update( iconPath: String, tooltip: String, onLeftClick: (() -> Unit)? = null, menuContent: (TrayMenuBuilder.() -> Unit)? = null, - ) = update(DEFAULT_ID, iconPath, tooltip, onLeftClick, menuContent) + onMenuOpened: (() -> Unit)? = null, + ) = update(DEFAULT_ID, iconPath, tooltip, onLeftClick, menuContent, onMenuOpened) fun dispose() = dispose(DEFAULT_ID) } diff --git a/src/jvmMain/kotlin/com/kdroid/composetray/tray/impl/WindowsTrayInitializer.kt b/src/jvmMain/kotlin/com/kdroid/composetray/tray/impl/WindowsTrayInitializer.kt index a467721..5ff73a8 100644 --- a/src/jvmMain/kotlin/com/kdroid/composetray/tray/impl/WindowsTrayInitializer.kt +++ b/src/jvmMain/kotlin/com/kdroid/composetray/tray/impl/WindowsTrayInitializer.kt @@ -17,6 +17,7 @@ object WindowsTrayInitializer { tooltip: String, onLeftClick: (() -> Unit)? = null, menuContent: (TrayMenuBuilder.() -> Unit)? = null, + onMenuOpened: (() -> Unit)? = null, ) { val menuItems = WindowsTrayMenuBuilderImpl(iconPath, tooltip, onLeftClick).apply { @@ -25,11 +26,11 @@ object WindowsTrayInitializer { val manager = trayManagers[id] if (manager == null) { - val windowsTrayManager = WindowsTrayManager(id, iconPath, tooltip, onLeftClick) + val windowsTrayManager = WindowsTrayManager(id, iconPath, tooltip, onLeftClick, onMenuOpened) trayManagers[id] = windowsTrayManager windowsTrayManager.initialize(menuItems) } else { - manager.update(iconPath, tooltip, onLeftClick, menuItems) + manager.update(iconPath, tooltip, onLeftClick, onMenuOpened, menuItems) } } @@ -40,9 +41,10 @@ object WindowsTrayInitializer { tooltip: String, onLeftClick: (() -> Unit)? = null, menuContent: (TrayMenuBuilder.() -> Unit)? = null, + onMenuOpened: (() -> Unit)? = null, ) { // Same as initialize - it will handle both cases per ID - initialize(id, iconPath, tooltip, onLeftClick, menuContent) + initialize(id, iconPath, tooltip, onLeftClick, menuContent, onMenuOpened) } @Synchronized @@ -65,14 +67,16 @@ object WindowsTrayInitializer { tooltip: String, onLeftClick: (() -> Unit)? = null, menuContent: (TrayMenuBuilder.() -> Unit)? = null, - ) = initialize(DEFAULT_ID, iconPath, tooltip, onLeftClick, menuContent) + onMenuOpened: (() -> Unit)? = null, + ) = initialize(DEFAULT_ID, iconPath, tooltip, onLeftClick, menuContent, onMenuOpened) fun update( iconPath: String, tooltip: String, onLeftClick: (() -> Unit)? = null, menuContent: (TrayMenuBuilder.() -> Unit)? = null, - ) = update(DEFAULT_ID, iconPath, tooltip, onLeftClick, menuContent) + onMenuOpened: (() -> Unit)? = null, + ) = update(DEFAULT_ID, iconPath, tooltip, onLeftClick, menuContent, onMenuOpened) fun dispose() = dispose(DEFAULT_ID) } diff --git a/src/native/linux/build.sh b/src/native/linux/build.sh index deb70c2..24681bd 100755 --- a/src/native/linux/build.sh +++ b/src/native/linux/build.sh @@ -82,5 +82,13 @@ strip --strip-unneeded "$OUTPUT_DIR/$PLATFORM_DIR/libLinuxTray.so" # Clean up object files rm -f "$SCRIPT_DIR/sni.o" "$SCRIPT_DIR/jni_bridge.o" +# Invalidate runtime cache (NativeLibraryLoader validates by size only, +# so a same-size rebuild would serve the stale cached copy) +CACHE_FILE="$HOME/.cache/composetray/native/$PLATFORM_DIR/libLinuxTray.so" +if [ -f "$CACHE_FILE" ]; then + rm -f "$CACHE_FILE" + echo "Cleared cached library: $CACHE_FILE" +fi + echo "Build completed: $OUTPUT_DIR/$PLATFORM_DIR/libLinuxTray.so" ls -lh "$OUTPUT_DIR/$PLATFORM_DIR/libLinuxTray.so" diff --git a/src/native/linux/jni_bridge.c b/src/native/linux/jni_bridge.c index 57159e6..afe39f5 100644 --- a/src/native/linux/jni_bridge.c +++ b/src/native/linux/jni_bridge.c @@ -54,6 +54,7 @@ typedef struct CallbackEntry { static CallbackEntry *g_clickCallback = NULL; static CallbackEntry *g_rclickCallback = NULL; static CallbackEntry *g_menuCallbacks = NULL; +static CallbackEntry *g_menuOpenedCallback = NULL; static void storeCallback(CallbackEntry **list, uintptr_t key, JNIEnv *env, jobject callback) { /* Remove existing entry for this key */ @@ -147,6 +148,12 @@ static void menu_item_trampoline(uint32_t id, void *userdata) { if (runnable) invokeRunnable(runnable); } +static void menu_opened_trampoline(void *userdata) { + uintptr_t key = (uintptr_t)userdata; + jobject runnable = findCallback(g_menuOpenedCallback, key); + if (runnable) invokeRunnable(runnable); +} + /* ========================================================================== */ /* JNI exports */ /* ========================================================================== */ @@ -212,6 +219,7 @@ Java_com_kdroid_composetray_lib_linux_LinuxNativeBridge_nativeDestroy( uintptr_t key = (uintptr_t)tray; storeCallback(&g_clickCallback, key, env, NULL); storeCallback(&g_rclickCallback, key, env, NULL); + storeCallback(&g_menuOpenedCallback, key, env, NULL); /* Menu callbacks are keyed by item id, clear all */ clearAllCallbacks(&g_menuCallbacks); @@ -300,6 +308,20 @@ Java_com_kdroid_composetray_lib_linux_LinuxNativeBridge_nativeSetMenuItemCallbac sni_tray_set_menu_callback(tray, menu_item_trampoline, (void *)(uintptr_t)tray); } +JNIEXPORT void JNICALL +Java_com_kdroid_composetray_lib_linux_LinuxNativeBridge_nativeSetMenuOpenedCallback( + JNIEnv *env, jclass clazz, jlong handle, jobject callback) +{ + (void)clazz; + sni_tray *tray = (sni_tray *)(uintptr_t)handle; + if (!tray) return; + uintptr_t key = (uintptr_t)tray; + storeCallback(&g_menuOpenedCallback, key, env, callback); + sni_tray_set_menu_opened_callback(tray, + callback ? menu_opened_trampoline : NULL, + (void *)key); +} + /* ── Click position ─────────────────────────────────────────────────── */ JNIEXPORT void JNICALL diff --git a/src/native/linux/sni.c b/src/native/linux/sni.c index d255f62..d9e92e9 100644 --- a/src/native/linux/sni.c +++ b/src/native/linux/sni.c @@ -16,6 +16,7 @@ #include #include +#include #include /* stb_image for PNG/JPG decoding */ @@ -155,6 +156,9 @@ struct sni_tray { void *on_rclick_data; sni_menu_item_cb on_menu_item; void *on_menu_item_data; + sni_menu_opened_cb on_menu_opened; + void *on_menu_opened_data; + int64_t last_layout_updated_ms; /* suppress AboutToShow triggered by LayoutUpdated */ /* Desktop environment */ desktop_env de; @@ -309,6 +313,9 @@ static void emit_new_title(sni_tray *tray) { static void emit_layout_updated(sni_tray *tray) { if (!tray->bus) return; tray->menu_version++; + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + tray->last_layout_updated_ms = (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000; sd_bus_emit_signal(tray->bus, MENU_PATH, MENU_IFACE, "LayoutUpdated", "ui", tray->menu_version, (int32_t)0); /* Also emit properties changed for Version */ @@ -825,9 +832,21 @@ static int menu_event_group(sd_bus_message *msg, void *userdata, sd_bus_error *e } static int menu_about_to_show(sd_bus_message *msg, void *userdata, sd_bus_error *error) { - (void)userdata; (void)error; + (void)error; + sni_tray *tray = userdata; int32_t id; sd_bus_message_read(msg, "i", &id); + + if (id == 0 && tray->on_menu_opened) { + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + int64_t now_ms = (int64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000; + /* Only fire on genuine user-initiated opens, not on AboutToShow + calls triggered by a recent LayoutUpdated from a menu rebuild. */ + if (now_ms - tray->last_layout_updated_ms > 300) { + tray->on_menu_opened(tray->on_menu_opened_data); + } + } return sd_bus_reply_method_return(msg, "b", 0); } @@ -920,6 +939,9 @@ sni_tray *sni_tray_create(const uint8_t *icon_data, size_t icon_len, pthread_mutex_init(&tray->click_lock, NULL); tray->next_id = 1; tray->menu_version = 1; + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + tray->last_layout_updated_ms = (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000; tray->de = detect_desktop(); tray->current_menu_path = no_menu_path(tray->de); @@ -1100,6 +1122,12 @@ void sni_tray_set_menu_callback(sni_tray *tray, sni_menu_item_cb cb, void *userd tray->on_menu_item_data = userdata; } +void sni_tray_set_menu_opened_callback(sni_tray *tray, sni_menu_opened_cb cb, void *userdata) { + if (!tray) return; + tray->on_menu_opened = cb; + tray->on_menu_opened_data = userdata; +} + void sni_tray_get_last_click_xy(sni_tray *tray, int32_t *x, int32_t *y) { if (!tray) return; pthread_mutex_lock(&tray->click_lock); diff --git a/src/native/linux/sni.h b/src/native/linux/sni.h index fd85f2d..2bfb878 100644 --- a/src/native/linux/sni.h +++ b/src/native/linux/sni.h @@ -20,6 +20,7 @@ typedef struct sni_tray sni_tray; /* Callback types */ typedef void (*sni_click_cb)(int32_t x, int32_t y, void *userdata); typedef void (*sni_menu_item_cb)(uint32_t id, void *userdata); +typedef void (*sni_menu_opened_cb)(void *userdata); /* ── Lifecycle ─────────────────────────────────────────────────────── */ @@ -51,6 +52,7 @@ void sni_tray_set_tooltip(sni_tray *tray, const char *tooltip); void sni_tray_set_click_callback(sni_tray *tray, sni_click_cb cb, void *userdata); void sni_tray_set_rclick_callback(sni_tray *tray, sni_click_cb cb, void *userdata); void sni_tray_set_menu_callback(sni_tray *tray, sni_menu_item_cb cb, void *userdata); +void sni_tray_set_menu_opened_callback(sni_tray *tray, sni_menu_opened_cb cb, void *userdata); /* Get last click coordinates (from Activate/ContextMenu). */ void sni_tray_get_last_click_xy(sni_tray *tray, int32_t *x, int32_t *y); diff --git a/src/native/macos/MacTrayBridge.m b/src/native/macos/MacTrayBridge.m index b42ccaa..a474c8c 100644 --- a/src/native/macos/MacTrayBridge.m +++ b/src/native/macos/MacTrayBridge.m @@ -61,6 +61,7 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) { static CallbackEntry *g_trayCallbacks = NULL; /* tray left-click */ static CallbackEntry *g_menuCallbacks = NULL; /* menu item click */ +static CallbackEntry *g_menuOpenedCallbacks = NULL; /* menu opened */ static CallbackEntry *g_themeCallback = NULL; /* theme change (single) */ static void storeCallback(CallbackEntry **list, void *key, JNIEnv *env, jobject callback) { @@ -177,6 +178,18 @@ static void menuItemCbTrampoline(struct tray_menu_item *item) { if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); } +/* Called by the Swift click handler when the menu is about to open */ +static void menuOpenedCbTrampoline(struct tray *t) { + JNIEnv *env = getJNIEnv(); + if (!env) return; + jobject runnable = findCallback(g_menuOpenedCallbacks, t); + if (!runnable) return; + jmethodID run = getRunnableRunMethod(env); + if (!run) return; + (*env)->CallVoidMethod(env, runnable, run); + if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); +} + /* Called by the Swift appearance observer when the theme changes */ static void themeCbTrampoline(int isDark) { JNIEnv *env = getJNIEnv(); @@ -260,6 +273,16 @@ JNIEXPORT void JNICALL Java_com_kdroid_composetray_lib_mac_MacNativeBridge_nativ t->cb = (callback != NULL) ? trayCbTrampoline : NULL; } +JNIEXPORT void JNICALL Java_com_kdroid_composetray_lib_mac_MacNativeBridge_nativeSetMenuOpenedCallback( + JNIEnv *env, jclass clazz, jlong handle, jobject callback) +{ + (void)clazz; + struct tray *t = (struct tray *)(uintptr_t)handle; + if (!t) return; + storeCallback(&g_menuOpenedCallbacks, t, env, callback); + tray_set_menu_opened_callback(t, (callback != NULL) ? menuOpenedCbTrampoline : NULL); +} + JNIEXPORT void JNICALL Java_com_kdroid_composetray_lib_mac_MacNativeBridge_nativeSetTrayMenu( JNIEnv *env, jclass clazz, jlong trayHandle, jlong menuHandle) { @@ -312,6 +335,7 @@ JNIEXPORT void JNICALL Java_com_kdroid_composetray_lib_mac_MacNativeBridge_nativ tray_dispose(t); /* Clean up callback refs for this tray */ removeCallback(&g_trayCallbacks, t); + removeCallback(&g_menuOpenedCallbacks, t); /* Free the struct and its strings */ free((void *)t->icon_filepath); free((void *)t->tooltip); @@ -325,6 +349,7 @@ JNIEXPORT void JNICALL Java_com_kdroid_composetray_lib_mac_MacNativeBridge_nativ tray_exit(); clearAllCallbacks(&g_trayCallbacks); clearAllCallbacks(&g_menuCallbacks); + clearAllCallbacks(&g_menuOpenedCallbacks); } /* ========================================================================== */ diff --git a/src/native/macos/build.sh b/src/native/macos/build.sh index 5266f5e..b6fff45 100755 --- a/src/native/macos/build.sh +++ b/src/native/macos/build.sh @@ -68,4 +68,14 @@ build_arch() { build_arch "arm64" "arm64-apple-macosx11.0" "$OUTPUT_DIR/darwin-aarch64" build_arch "x86_64" "x86_64-apple-macosx11.0" "$OUTPUT_DIR/darwin-x86-64" +# Invalidate runtime cache (NativeLibraryLoader validates by size only, +# so a same-size rebuild would serve the stale cached copy) +for PLATFORM_DIR in darwin-aarch64 darwin-x86-64; do + CACHE_FILE="$HOME/.cache/composetray/native/$PLATFORM_DIR/libMacTray.dylib" + if [ -f "$CACHE_FILE" ]; then + rm -f "$CACHE_FILE" + echo "Cleared cached library: $CACHE_FILE" + fi +done + echo "Build completed successfully." diff --git a/src/native/macos/tray.h b/src/native/macos/tray.h index cdc1170..59abf63 100644 --- a/src/native/macos/tray.h +++ b/src/native/macos/tray.h @@ -65,6 +65,7 @@ TRAY_API void tray_exit (void); /* free everything and exit */ /* Additional options / information */ /* -------------------------------------------------------------------------- */ TRAY_API void tray_set_theme_callback(theme_callback cb); +TRAY_API void tray_set_menu_opened_callback(struct tray *tray, tray_callback cb); TRAY_API int tray_is_menu_dark(void); /* 1 = dark mode */ /* Windows: corner and coordinates of notification area */ diff --git a/src/native/macos/tray.swift b/src/native/macos/tray.swift index 17b8f29..789363e 100644 --- a/src/native/macos/tray.swift +++ b/src/native/macos/tray.swift @@ -21,6 +21,7 @@ private class TrayContext { let appearanceObserver: MenuBarAppearanceObserver var lightImage: NSImage? var darkImage: NSImage? + var menuOpenedCallback: TrayCallback? init(statusItem: NSStatusItem, clickHandler: InstanceButtonClickHandler, appearanceObserver: MenuBarAppearanceObserver) { self.statusItem = statusItem self.clickHandler = clickHandler @@ -51,6 +52,7 @@ private class MenuDelegate: NSObject, NSMenuDelegate { if event.type == .rightMouseUp || event.modifierFlags.contains(.control) { if let menu = ctx.contextMenu { + ctx.menuOpenedCallback?(trayPtr) let menuLocation = NSPoint( x: sender.frame.minX, y: sender.frame.minY - 5 @@ -384,6 +386,19 @@ public func tray_exit() { menuDelegate = nil } +@_cdecl("tray_set_menu_opened_callback") +public func tray_set_menu_opened_callback( + _ tray: UnsafeMutableRawPointer?, + _ cb: TrayCallback? +) { + let doWork = { + guard let tray = tray, let ctx = contexts[tray] else { return } + ctx.menuOpenedCallback = cb + } + if Thread.isMainThread { doWork() } + else { DispatchQueue.main.sync { doWork() } } +} + @_cdecl("tray_set_theme_callback") public func tray_set_theme_callback(_ cb: @escaping ThemeCallback) { themeCallback = cb diff --git a/src/native/windows/jni_bridge.c b/src/native/windows/jni_bridge.c index cb196bd..50c4cfb 100644 --- a/src/native/windows/jni_bridge.c +++ b/src/native/windows/jni_bridge.c @@ -53,6 +53,7 @@ typedef struct CallbackEntry { static CallbackEntry *g_trayCallbacks = NULL; static CallbackEntry *g_menuCallbacks = NULL; +static CallbackEntry *g_menuOpenedCallbacks = NULL; static void storeCallback(CallbackEntry **list, uintptr_t key, JNIEnv *env, jobject callback) { CallbackEntry **pp = list; @@ -137,6 +138,12 @@ static void menu_item_cb_trampoline(struct tray_menu_item *item) { if (runnable) invokeRunnable(runnable); } +static void menu_opened_cb_trampoline(struct tray *t) { + uintptr_t key = (uintptr_t)t; + jobject runnable = findCallback(g_menuOpenedCallbacks, key); + if (runnable) invokeRunnable(runnable); +} + /* ========================================================================== */ /* Helper: duplicate UTF-8 string from JNI */ /* ========================================================================== */ @@ -176,8 +183,9 @@ Java_com_kdroid_composetray_lib_windows_WindowsNativeBridge_nativeFreeTray( struct tray *t = (struct tray *)(uintptr_t)handle; if (!t) return; - /* Remove tray callback */ + /* Remove tray callbacks */ storeCallback(&g_trayCallbacks, (uintptr_t)t, env, NULL); + storeCallback(&g_menuOpenedCallbacks, (uintptr_t)t, env, NULL); free((void *)t->icon_filepath); free((void *)t->tooltip); @@ -218,6 +226,17 @@ Java_com_kdroid_composetray_lib_windows_WindowsNativeBridge_nativeSetTrayCallbac t->cb = callback ? tray_cb_trampoline : NULL; } +JNIEXPORT void JNICALL +Java_com_kdroid_composetray_lib_windows_WindowsNativeBridge_nativeSetMenuOpenedCallback( + JNIEnv *env, jclass clazz, jlong handle, jobject callback) +{ + (void)clazz; + struct tray *t = (struct tray *)(uintptr_t)handle; + if (!t) return; + storeCallback(&g_menuOpenedCallbacks, (uintptr_t)t, env, callback); + tray_set_menu_opened_callback(t, callback ? menu_opened_cb_trampoline : NULL); +} + JNIEXPORT void JNICALL Java_com_kdroid_composetray_lib_windows_WindowsNativeBridge_nativeSetTrayMenu( JNIEnv *env, jclass clazz, jlong trayHandle, jlong menuHandle) @@ -271,6 +290,7 @@ Java_com_kdroid_composetray_lib_windows_WindowsNativeBridge_nativeExitTray( (void)env; (void)clazz; tray_exit(); clearAllCallbacks(&g_menuCallbacks); + clearAllCallbacks(&g_menuOpenedCallbacks); } /* ========================================================================== */ diff --git a/src/native/windows/tray.h b/src/native/windows/tray.h index d9008dc..a1ceb02 100644 --- a/src/native/windows/tray.h +++ b/src/native/windows/tray.h @@ -60,6 +60,9 @@ TRAY_EXPORT void tray_exit (void); /* Free all resources TRAY_EXPORT int tray_get_notification_icons_position(int *x, int *y); TRAY_EXPORT const char *tray_get_notification_icons_region(void); +/* Menu-opened callback: invoked just before the popup menu is shown */ +TRAY_EXPORT void tray_set_menu_opened_callback(struct tray *tray, void (*cb)(struct tray *)); + #ifdef __cplusplus } /* extern "C" */ #endif diff --git a/src/native/windows/tray_windows.c b/src/native/windows/tray_windows.c index 26267e9..b3201d6 100644 --- a/src/native/windows/tray_windows.c +++ b/src/native/windows/tray_windows.c @@ -99,6 +99,7 @@ typedef struct TrayContext { UINT uID; /* unique id for Shell_NotifyIcon */ DWORD threadId; /* thread that owns this context */ BOOL exiting; /* exit requested for this context */ + void (*menu_opened_cb)(struct tray *); /* called before menu popup */ struct TrayContext *next; /* linked list */ } TrayContext; @@ -337,6 +338,12 @@ static LRESULT CALLBACK tray_wnd_proc(HWND h, UINT msg, WPARAM w, LPARAM l) GetCursorPos(&p); SetForegroundWindow(h); + /* Invoke menu-opened callback outside the critical section + * to avoid deadlocks with JNI calls */ + if (ctx && ctx->menu_opened_cb && ctx->tray) { + ctx->menu_opened_cb(ctx->tray); + } + EnterCriticalSection(&tray_cs); if (ctx && ctx->hmenu) { WORD cmd = TrackPopupMenu(ctx->hmenu, @@ -655,6 +662,20 @@ void tray_exit(void) LeaveCriticalSection(&tray_cs); } +/* -------------------------------------------------------------------------- */ +/* Set/clear the menu-opened callback for a given tray */ +/* -------------------------------------------------------------------------- */ +void tray_set_menu_opened_callback(struct tray *tray, void (*cb)(struct tray *)) +{ + if (!tray) return; + ensure_critical_section(); + EnterCriticalSection(&tray_cs); + TrayContext *ctx = find_ctx_by_tray(tray); + if (!ctx) ctx = find_ctx_by_thread(GetCurrentThreadId()); + if (ctx) ctx->menu_opened_cb = cb; + LeaveCriticalSection(&tray_cs); +} + static BOOL get_tray_icon_rect(RECT *r) { /* Use per-thread context to identify the correct tray icon */