diff --git a/README.md b/README.md index 4e5b32d..5ab12ae 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ - [Icon Limitations](#icon-limitations) - [Theme Behavior](#theme-behavior) - [ProGuard / R8](#proguard--r8) -- [🧪 TrayApp (Experimental)](#-trayapp-experimental) +- [🧪 TrayApp (Alpha)](#-trayapp-alpha) - [📱 Apps Using Compose Native Tray](#-apps-using-compose-native-tray) - [📄 License](#-license) - [🤝 Contribution](#-contribution) @@ -565,9 +565,11 @@ Add the following to your ProGuard rules file: -keep class com.kdroid.composetray.** { *; } ``` -# 🧪 TrayApp (Experimental) +# 🧪 TrayApp (Alpha) -`TrayApp` gives your desktop app a **system‑tray/menu‑bar icon** and a **tiny popup window** for quick actions. It’s perfect for quick toggles, mini dashboards, and “control center” UIs. +> **Status: Alpha** — The core API is functional on Windows, macOS, and Linux, but breaking changes may still occur. Feedback and bug reports are welcome! + +`TrayApp` gives your desktop app a **system‑tray/menu‑bar icon** and a **tiny popup window** for quick actions. It's perfect for quick toggles, mini dashboards, and "control center" UIs. **Works on Windows, macOS, and Linux.** Smooth fade animations, smart positioning near the tray, and a simple API so you stay productive. diff --git a/src/commonMain/kotlin/com/kdroid/composetray/lib/mac/MacOsWindowManager.kt b/src/commonMain/kotlin/com/kdroid/composetray/lib/mac/MacOsWindowManager.kt index 95cf522..c411a0e 100644 --- a/src/commonMain/kotlin/com/kdroid/composetray/lib/mac/MacOsWindowManager.kt +++ b/src/commonMain/kotlin/com/kdroid/composetray/lib/mac/MacOsWindowManager.kt @@ -203,24 +203,164 @@ class MacOSWindowManager { if (!isMacOs) return false val localObjc = objc ?: return false return try { + // Try direct approach via Native.getComponentID val viewPtr = Native.getComponentID(awtWindow) - if (viewPtr == 0L) return false + debugln { "[MacOSWindowManager] setMoveToActiveSpace: viewPtr=$viewPtr" } + if (viewPtr != 0L) { + val nsView = Pointer(viewPtr) + val windowSel = localObjc.sel_registerName("window") + val nsWindow = localObjc.objc_msgSend(nsView, windowSel) + if (nsWindow != Pointer.NULL) { + applySpaceBehavior(localObjc, nsWindow) + return true + } + } + + // Fallback: iterate NSApp windows and set on floating-level windows + // (tray popup uses alwaysOnTop=true which sets a floating window level) + debugln { "[MacOSWindowManager] Fallback: searching NSApp windows for floating window..." } + val nsApp = getNSApplication() ?: return false + + val windowsSel = localObjc.sel_registerName("windows") + val windowsArray = localObjc.objc_msgSend(nsApp, windowsSel) + val countSel = localObjc.sel_registerName("count") + val count = Pointer.nativeValue(localObjc.objc_msgSend(windowsArray, countSel)).toInt() + debugln { "[MacOSWindowManager] Found $count NSWindows" } + + val objectAtIndexSel = localObjc.sel_registerName("objectAtIndex:") + val levelSel = localObjc.sel_registerName("level") + + var applied = false + for (i in 0 until count) { + val nsWindow = localObjc.objc_msgSend(windowsArray, objectAtIndexSel, i.toLong()) + val level = Pointer.nativeValue(localObjc.objc_msgSend(nsWindow, levelSel)) + debugln { "[MacOSWindowManager] Window[$i]: level=$level" } + // Floating windows have level > 0 (NSFloatingWindowLevel = 3) + if (level > 0) { + applySpaceBehavior(localObjc, nsWindow) + applied = true + } + } + applied + } catch (e: Throwable) { + debugln { "Failed to set moveToActiveSpace: ${e.message}" } + false + } + } + + private fun applySpaceBehavior(localObjc: ObjectiveC, nsWindow: Pointer) { + val getCollSel = localObjc.sel_registerName("collectionBehavior") + val current = Pointer.nativeValue(localObjc.objc_msgSend(nsWindow, getCollSel)) + // Ensure moveToActiveSpace is set (moves window to active Space when ordered front) + val desired = (current and NSWindowCollectionBehaviorCanJoinAllSpaces.inv()) or NSWindowCollectionBehaviorMoveToActiveSpace + if (current != desired) { + debugln { "[MacOSWindowManager] collectionBehavior before=$current, desired=$desired" } + val setCollSel = localObjc.sel_registerName("setCollectionBehavior:") + localObjc.objc_msgSend(nsWindow, setCollSel, desired) + val after = Pointer.nativeValue(localObjc.objc_msgSend(nsWindow, getCollSel)) + debugln { "[MacOSWindowManager] collectionBehavior after=$after" } + } + debugln { "Window configured with moveToActiveSpace" } + } + + /** + * Check if any floating-level NSWindow is on the active Space. + * Uses NSApp.windows iteration (same fallback as setMoveToActiveSpace). + * Returns true if on active Space or if check fails (fail-open). + */ + fun isFloatingWindowOnActiveSpace(): Boolean { + if (!isMacOs) return true + val localObjc = objc ?: return true + return try { + val nsApp = getNSApplication() ?: return true + + val windowsSel = localObjc.sel_registerName("windows") + val windowsArray = localObjc.objc_msgSend(nsApp, windowsSel) + val countSel = localObjc.sel_registerName("count") + val count = Pointer.nativeValue(localObjc.objc_msgSend(windowsArray, countSel)).toInt() + + val objectAtIndexSel = localObjc.sel_registerName("objectAtIndex:") + val levelSel = localObjc.sel_registerName("level") + val isOnActiveSpaceSel = localObjc.sel_registerName("isOnActiveSpace") + + for (i in 0 until count) { + val nsWindow = localObjc.objc_msgSend(windowsArray, objectAtIndexSel, i.toLong()) + val level = Pointer.nativeValue(localObjc.objc_msgSend(nsWindow, levelSel)) + if (level > 0) { + val onActiveSpace = Pointer.nativeValue(localObjc.objc_msgSend(nsWindow, isOnActiveSpaceSel)) != 0L + debugln { "[MacOSWindowManager] Floating window level=$level, isOnActiveSpace=$onActiveSpace" } + return onActiveSpace + } + } + true // No floating window found, assume on active Space + } catch (e: Throwable) { + debugln { "Failed to check isOnActiveSpace: ${e.message}" } + true + } + } + + /** + * Check if an AWT window is currently on the active macOS Space. + * Returns true if on the active Space, false if on another Space. + * Returns true by default if the check cannot be performed (fail-open). + */ + fun isOnActiveSpace(awtWindow: java.awt.Window): Boolean { + if (!isMacOs) return true + val localObjc = objc ?: return true + return try { + val viewPtr = Native.getComponentID(awtWindow) + if (viewPtr == 0L) return true val nsView = Pointer(viewPtr) val windowSel = localObjc.sel_registerName("window") val nsWindow = localObjc.objc_msgSend(nsView, windowSel) - if (nsWindow == Pointer.NULL) return false + if (nsWindow == Pointer.NULL) return true - // Read current collectionBehavior and add moveToActiveSpace (1 << 1) - val getCollSel = localObjc.sel_registerName("collectionBehavior") - val current = Pointer.nativeValue(localObjc.objc_msgSend(nsWindow, getCollSel)) - val setCollSel = localObjc.sel_registerName("setCollectionBehavior:") - localObjc.objc_msgSend(nsWindow, setCollSel, current or NSWindowCollectionBehaviorMoveToActiveSpace) + val isOnActiveSpaceSel = localObjc.sel_registerName("isOnActiveSpace") + val result = Pointer.nativeValue(localObjc.objc_msgSend(nsWindow, isOnActiveSpaceSel)) + result != 0L + } catch (e: Throwable) { + debugln { "Failed to check isOnActiveSpace: ${e.message}" } + true // fail-open: assume on active Space + } + } - debugln { "Window configured to move to active Space" } - true + /** + * Bring the floating-level NSWindow to the front on the active Space. + * With moveToActiveSpace collection behavior, this physically moves the window. + * Also activates the application to ensure focus is gained. + */ + fun bringFloatingWindowToFront(): Boolean { + if (!isMacOs) return false + val localObjc = objc ?: return false + return try { + val nsApp = getNSApplication() ?: return false + + // Activate the app so it can receive focus + val activateSel = localObjc.sel_registerName("activateIgnoringOtherApps:") + localObjc.objc_msgSend(nsApp, activateSel, 1L) + + val windowsSel = localObjc.sel_registerName("windows") + val windowsArray = localObjc.objc_msgSend(nsApp, windowsSel) + val countSel = localObjc.sel_registerName("count") + val count = Pointer.nativeValue(localObjc.objc_msgSend(windowsArray, countSel)).toInt() + + val objectAtIndexSel = localObjc.sel_registerName("objectAtIndex:") + val levelSel = localObjc.sel_registerName("level") + val makeKeyAndOrderFrontSel = localObjc.sel_registerName("makeKeyAndOrderFront:") + + for (i in 0 until count) { + val nsWindow = localObjc.objc_msgSend(windowsArray, objectAtIndexSel, i.toLong()) + val level = Pointer.nativeValue(localObjc.objc_msgSend(nsWindow, levelSel)) + if (level > 0) { + debugln { "[MacOSWindowManager] bringFloatingWindowToFront: level=$level" } + localObjc.objc_msgSend(nsWindow, makeKeyAndOrderFrontSel, Pointer.NULL) + return true + } + } + false } catch (e: Throwable) { - debugln { "Failed to set moveToActiveSpace: ${e.message}" } + debugln { "Failed to bringFloatingWindowToFront: ${e.message}" } false } } @@ -237,6 +377,7 @@ class MacOSWindowManager { const val NSModalPanelWindowLevel = 8L // NSWindowCollectionBehavior + const val NSWindowCollectionBehaviorCanJoinAllSpaces = 1L // 1 << 0 const val NSWindowCollectionBehaviorMoveToActiveSpace = 2L // 1 << 1 } diff --git a/src/commonMain/kotlin/com/kdroid/composetray/tray/api/TrayApp.kt b/src/commonMain/kotlin/com/kdroid/composetray/tray/api/TrayApp.kt index 762b8f5..478de4c 100644 --- a/src/commonMain/kotlin/com/kdroid/composetray/tray/api/TrayApp.kt +++ b/src/commonMain/kotlin/com/kdroid/composetray/tray/api/TrayApp.kt @@ -48,6 +48,7 @@ import org.jetbrains.compose.resources.painterResource import java.awt.EventQueue.invokeLater import java.awt.event.WindowEvent import java.awt.event.WindowFocusListener +import java.util.concurrent.atomic.AtomicLong // --------------------- Public API (defaults) --------------------- @@ -500,12 +501,12 @@ private fun ApplicationScope.TrayAppImplOriginal( var shouldShowWindow by remember { mutableStateOf(false) } var lastPrimaryActionAt by remember { mutableStateOf(0L) } - val toggleDebounceMs = 280L + val toggleDebounceMs = 150L var lastShownAt by remember { mutableStateOf(0L) } var lastHiddenAt by remember { mutableStateOf(0L) } - val minVisibleDurationMs = 350L - val minHiddenDurationMs = 250L + val minVisibleDurationMs = 200L + val minHiddenDurationMs = 100L var lastFocusLostAt by remember { mutableStateOf(0L) } var autoHideEnabledAt by remember { mutableStateOf(0L) } @@ -522,53 +523,69 @@ private fun ApplicationScope.TrayAppImplOriginal( // Visibility controller for exit-finish observation; content will NOT be disposed. val visibleState = remember { MutableTransitionState(false) } + // Thread-safe timestamps for cross-thread communication (IO thread reads, EDT writes) + val lastFocusLostAtMs = remember { java.util.concurrent.atomic.AtomicLong(0L) } + val lastHiddenAtMs = remember { java.util.concurrent.atomic.AtomicLong(0L) } + val lastShownAtMs = remember { java.util.concurrent.atomic.AtomicLong(0L) } + val lastPrimaryActionAtMs = remember { java.util.concurrent.atomic.AtomicLong(0L) } + val requestHideExplicit: () -> Unit = { val now = System.currentTimeMillis() - val sinceShow = now - lastShownAt + val sinceShow = now - lastShownAtMs.get() + debugln { "[TrayApp] requestHideExplicit called, sinceShow=${sinceShow}ms, thread=${Thread.currentThread().name}" } if (sinceShow >= minVisibleDurationMs) { trayAppState.hide() - lastHiddenAt = System.currentTimeMillis() + lastHiddenAtMs.set(System.currentTimeMillis()) + lastHiddenAt = lastHiddenAtMs.get() } else { val wait = minVisibleDurationMs - sinceShow CoroutineScope(Dispatchers.IO).launch { delay(wait) trayAppState.hide() - lastHiddenAt = System.currentTimeMillis() + lastHiddenAtMs.set(System.currentTimeMillis()) + lastHiddenAt = lastHiddenAtMs.get() } } } val internalPrimaryAction: () -> Unit = { val now = System.currentTimeMillis() - if (now - lastPrimaryActionAt >= toggleDebounceMs) { + // Read directly from StateFlow for thread-safe cross-thread access + val isVisibleNow = trayAppState.isVisible.value + val timeSinceLastAction = now - lastPrimaryActionAtMs.get() + debugln { "[TrayApp] primaryAction: isVisibleNow=$isVisibleNow, isVisible(compose)=$isVisible, timeSinceLastAction=${timeSinceLastAction}ms, thread=${Thread.currentThread().name}" } + if (timeSinceLastAction >= toggleDebounceMs) { + lastPrimaryActionAtMs.set(now) lastPrimaryActionAt = now - if (isVisible) { - // On macOS, check if window has focus before hiding - // If it doesn't have focus (e.g., on another Space), bring it to front instead - if (getOperatingSystem() == MACOS && windowRef != null) { - val hasFocus = runCatching { windowRef!!.isFocused() }.getOrElse { false } - if (!hasFocus) { - // Window is not focused (likely on another Space), bring it to current Space + if (isVisibleNow) { + // On macOS, check if the window is on another Space + if (getOperatingSystem() == MACOS) { + val onActiveSpace = runCatching { MacOSWindowManager().isFloatingWindowOnActiveSpace() }.getOrElse { true } + debugln { "[TrayApp] primaryAction: onActiveSpace=$onActiveSpace" } + if (!onActiveSpace) { + // Window is on another Space → move it to current Space via native API + debugln { "[TrayApp] primaryAction -> MOVE TO CURRENT SPACE" } invokeLater { - runCatching { MacTrayLoader.lib.tray_set_windows_move_to_active_space() } - runCatching { MacOSWindowManager().setMoveToActiveSpace(windowRef!!) } - runCatching { - windowRef!!.toFront() - windowRef!!.requestFocus() - windowRef!!.requestFocusInWindow() - } + runCatching { MacOSWindowManager().bringFloatingWindowToFront() } } } else { + debugln { "[TrayApp] primaryAction -> HIDE" } requestHideExplicit() } } else { + debugln { "[TrayApp] primaryAction -> HIDE" } requestHideExplicit() } } else { - if (now - lastHiddenAt >= minHiddenDurationMs) { - if (getOperatingSystem() == WINDOWS && (now - lastFocusLostAt) < 300) { - // ignore immediate re-show after focus loss on Windows + val hiddenAgo = now - lastHiddenAtMs.get() + val focusLostAgo = now - lastFocusLostAtMs.get() + debugln { "[TrayApp] primaryAction SHOW path: hiddenAgo=${hiddenAgo}ms, focusLostAgo=${focusLostAgo}ms" } + if (hiddenAgo >= minHiddenDurationMs) { + if ((getOperatingSystem() == WINDOWS || getOperatingSystem() == MACOS) && focusLostAgo < 150) { + // ignore immediate re-show after focus loss on Windows/macOS + debugln { "[TrayApp] primaryAction -> SHOW BLOCKED (recent focus loss)" } } else { + debugln { "[TrayApp] primaryAction -> SHOW" } // Pre-compute position at click time: the native status item // geometry is guaranteed to be available right now. runCatching { @@ -580,8 +597,12 @@ private fun ApplicationScope.TrayAppImplOriginal( } trayAppState.show() } + } else { + debugln { "[TrayApp] primaryAction -> SHOW BLOCKED (too soon after hide: ${hiddenAgo}ms < ${minHiddenDurationMs}ms)" } } } + } else { + debugln { "[TrayApp] primaryAction -> DEBOUNCED (${timeSinceLastAction}ms < ${toggleDebounceMs}ms)" } } } @@ -629,22 +650,25 @@ private fun ApplicationScope.TrayAppImplOriginal( dialogState.position = position // Wait for Compose to apply the position before showing the window - // This prevents the window from flashing at the wrong position - delay(150) // Give Compose time to recompose with new position + delay(30) if (getOperatingSystem() == WINDOWS) { autoHideEnabledAt = System.currentTimeMillis() + 1000 } debugln { "[TrayApp] Now showing window" } shouldShowWindow = true - lastShownAt = System.currentTimeMillis() + val showTime = System.currentTimeMillis() + lastShownAt = showTime + lastShownAtMs.set(showTime) } } else { // Wait for exit animation to finish, then actually hide the window if (shouldShowWindow) { snapshotFlow { visibleState.isIdle && !visibleState.currentState }.first { it } shouldShowWindow = false - lastHiddenAt = System.currentTimeMillis() + val hideTime = System.currentTimeMillis() + lastHiddenAt = hideTime + lastHiddenAtMs.set(hideTime) } } } @@ -694,8 +718,11 @@ private fun ApplicationScope.TrayAppImplOriginal( invokeLater { // Move the popup to the current Space before bringing it to front (macOS) if (getOperatingSystem() == MACOS) { - runCatching { MacTrayLoader.lib.tray_set_windows_move_to_active_space() } - runCatching { MacOSWindowManager().setMoveToActiveSpace(window) } + debugln { "[TrayApp] Setting up macOS Space behavior on window..." } + val nativeResult = runCatching { MacTrayLoader.lib.tray_set_windows_move_to_active_space() } + debugln { "[TrayApp] tray_set_windows_move_to_active_space: ${nativeResult.exceptionOrNull()?.message ?: "OK"}" } + val jnaResult = runCatching { MacOSWindowManager().setMoveToActiveSpace(window) } + debugln { "[TrayApp] setMoveToActiveSpace result=${jnaResult.getOrNull()}, error=${jnaResult.exceptionOrNull()?.message}" } } debugln { "[TrayApp] After invokeLater: window at x=${window.x}, y=${window.y}" } runCatching { @@ -708,8 +735,18 @@ private fun ApplicationScope.TrayAppImplOriginal( val focusListener = object : WindowFocusListener { override fun windowGainedFocus(e: WindowEvent?) = Unit override fun windowLostFocus(e: WindowEvent?) { - lastFocusLostAt = System.currentTimeMillis() - if (getOperatingSystem() == WINDOWS && lastFocusLostAt < autoHideEnabledAt) return + val now = System.currentTimeMillis() + lastFocusLostAtMs.set(now) + lastFocusLostAt = now + debugln { "[TrayApp] windowLostFocus at $now, dismissMode=$dismissMode, thread=${Thread.currentThread().name}" } + if (getOperatingSystem() == WINDOWS && now < autoHideEnabledAt) return + // On macOS, don't auto-hide if the window is not on the active Space. + // A Space switch caused the focus loss — let the primary action handle it. + if (getOperatingSystem() == MACOS) { + val onActiveSpace = runCatching { MacOSWindowManager().isFloatingWindowOnActiveSpace() }.getOrElse { true } + debugln { "[TrayApp] windowLostFocus: onActiveSpace=$onActiveSpace" } + if (!onActiveSpace) return + } if (dismissMode == TrayWindowDismissMode.AUTO) requestHideExplicit() } }