Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand All @@ -237,6 +377,7 @@ class MacOSWindowManager {
const val NSModalPanelWindowLevel = 8L

// NSWindowCollectionBehavior
const val NSWindowCollectionBehaviorCanJoinAllSpaces = 1L // 1 << 0
const val NSWindowCollectionBehaviorMoveToActiveSpace = 2L // 1 << 1
}

Expand Down
Loading
Loading