Creating the Perfect macOS System Tray
Introduction
In modern desktop applications, the system tray has become an indispensable component. It provides users with a quick and efficient way to interact with applications without opening the main window. The advantages of system tray are mainly reflected in the following aspects:
- Convenient access: Users can quickly check application status and perform common operations.
- Reduced visual interference: Doesn't occupy valuable screen space.
- Background operation: Ideal interface choice for applications that need to run continuously in the background.
This article will use macOS as an example to detail how to create a fully functional system tray application that complies with macOS design specifications using Kotlin and Compose Multiplatform framework.
Basic Implementation: Creating a Simple System Tray
Let's start with the most basic implementation. Using the Compose Multiplatform framework, you can create a simple system tray on macOS with just a few lines of code:
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.window.Tray
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberTrayState
fun main() = application {
val trayState = rememberTrayState()
val trayIcon = painterResource("crosspaste.tray.mac.png")
Tray(
state = trayState,
icon = trayIcon,
menu = {
Item("Exit", onClick = ::exitApplication)
}
)
}
This code creates a basic system tray with an icon and a simple exit menu item. After running, the effect is as follows:
However, this basic implementation still has some issues:
- Icon display: The tray icon shows its original colors instead of automatically switching between black and white based on the system theme, which doesn't comply with macOS design specifications
- Interaction limitations:
- Clicking the tray icon can only pop up a menu, cannot support displaying custom windows
- Default API doesn't support monitoring various click events (such as single click, double click, etc.)
- The framework doesn't provide API to get the exact position of the tray icon, limiting our ability to display custom windows near the icon :::
Next, we'll solve these problems step by step to create a perfect macOS system tray application.
Solutions
1. Adapting to macOS Design Specifications: Implementing Automatic Icon Color Switching
macOS design specifications require system tray icons to automatically switch colors based on the system theme. To achieve this, we need to set the icon as a "Template Image". In Java/Kotlin, this can be achieved by setting a system property:
System.setProperty("apple.awt.enableTemplateImages", "true")
https://docs.oracle.com/en/java/javase/17/docs/api/java.desktop/java/awt/TrayIcon.html
When the apple.awt.enableTemplateImages property is set, all images associated with instances of this class are treated as template images by the native desktop system. This means all color information is discarded, and the image is adapted automatically to be visible when desktop theme and/or colors change. This property only affects MacOSX.
This setting tells the system to treat our icon as a template image. The characteristics of template images are:
- All color information is discarded.
- The system automatically adjusts the image based on the current theme to maintain visibility on different backgrounds.
While you can set this property directly in the code, it's better to specify it in the build configuration. This ensures the property is set correctly when the application starts. In the Gradle build file, you can configure it like this:
compose.desktop {
application {
mainClass = "com.crosspaste.CrossPaste"
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
macOS {
jvmArgs("-Dapple.awt.enableTemplateImages=true")
}
}
}
}
This configuration ensures that the template image feature is automatically enabled when the application starts on macOS.
2. Enhancing Interaction Capabilities: Custom Click Events and Icon Position Access
2.1 Supporting Custom Click Events
The default Tray
component in Compose Multiplatform only supports right-click events on macOS. To implement richer interactions, we need to customize the Tray
component.
First, let's look at the compose multiplatform source code:
// @param onAction Action performed when user clicks on the tray icon (double click on Windows, right click on macOs)
@Composable
fun ApplicationScope.Tray(
icon: Painter,
state: TrayState = rememberTrayState(),
tooltip: String? = null,
onAction: () -> Unit = {},
menu: @Composable MenuScope.() -> Unit = {}
)
To support more mouse events, we can create a custom CrossPasteTray component. This component will allow us to add custom MouseListener:
fun CrossPasteTray(
icon: Painter,
state: CrossPasteTrayState = rememberTrayState(),
tooltip: String? = null,
onAction: (ActionEvent) -> Unit = {},
mouseListener: MouseListener,
menu: @Composable (MenuScope.() -> Unit) = {},
) {
// ... other code ...
val tray =
remember {
TrayIcon(awtIcon).apply {
isImageAutoSize = true
addActionListener { e ->
currentOnAction(e)
}
// Add mouse listener
addMouseListener(mouseListener)
}
}
// ... other code ...
}
This custom component allows us to monitor various mouse events, such as single click, double click, right click, etc., greatly enhancing interaction flexibility.
The source code also has another deficiency:
private val iconSize = when (DesktopPlatform.Current) {
// https://doc.qt.io/qt-5/qtwidgets-desktop-systray-example.html (search 22x22)
DesktopPlatform.Linux -> Size(22f, 22f)
// https://doc.qt.io/qt-5/qtwidgets-desktop-systray-example.html (search 16x16)
DesktopPlatform.Windows -> Size(16f, 16f)
// https://medium.com/@acwrightdesign/creating-a-macos-menu-bar-application-using-swiftui-54572a5d5f87
DesktopPlatform.MacOS -> Size(22f, 22f)
DesktopPlatform.Unknown -> Size(32f, 32f)
}
macOS's iconSize is set to 22x22, which will cause icon aliasing if the provided icon has a high resolution. We can customize iconSize to avoid this.
2.2 Getting Tray Icon Position
To display custom windows near the tray icon, we need to know the exact position of the icon. Unfortunately, Compose Multiplatform doesn't provide a direct API to get this information. However, we can use macOS system APIs to achieve this functionality.
First, we need to add JNA (Java Native Access) related dependencies to call macOS system APIs:
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
jna-platform = { module = "net.java.dev.jna:jna-platform", version.ref = "jna" }
Then, we can write a native library in Swift to use macOS's CGWindowListCopyWindowInfo
API to get the tray icon's position information:
public struct WindowInfo {
var x: Float
var y: Float
var width: Float
var height: Float
var displayID: UInt32
}
public struct WindowInfoArray {
var count: Int32
var windowInfos: UnsafeMutableRawPointer
}
@_cdecl("getTrayWindowInfos")
public func getTrayWindowInfos(pid: Int) -> UnsafeMutableRawPointer? {
var trayWindows: [WindowInfo] = []
let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements]
if let windowInfo = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] {
for info in windowInfo {
if let bounds = info[kCGWindowBounds as String] as? [String: Any],
let layer = info[kCGWindowLayer as String] as? Int,
layer == 25, // Menu bar/status item layer
let ownerPID = info[kCGWindowOwnerPID as String] as? Int,
ownerPID == pid,
let x = bounds["X"] as? CGFloat,
let y = bounds["Y"] as? CGFloat,
let width = bounds["Width"] as? CGFloat,
let height = bounds["Height"] as? CGFloat,
width < 50 && height < 50 {
var displayID = CGMainDisplayID() // Default to main display
for screen in NSScreen.screens {
if let screenNumber = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber,
screen.frame.contains(CGPoint(x: x, y: y)) {
displayID = screenNumber.uint32Value
break
}
}
let windowInfo = WindowInfo(
x: Float(x),
y: Float(y),
width: Float(width),
height: Float(height),
displayID: displayID
)
trayWindows.append(windowInfo)
}
}
}
let arrayPtr = UnsafeMutablePointer<WindowInfoArray>.allocate(capacity: 1)
// Allocate memory for the array of WindowInfo structs
let count = trayWindows.count
let bufferPtr = UnsafeMutableBufferPointer<WindowInfo>.allocate(capacity: count)
// Copy the WindowInfo structs into the allocated memory
for (index, window) in trayWindows.enumerated() {
bufferPtr[index] = window
}
arrayPtr.pointee = WindowInfoArray(count: Int32(count), windowInfos: UnsafeMutableRawPointer(bufferPtr.baseAddress!))
return UnsafeMutableRawPointer(arrayPtr)
}
This Swift code does the following:
- Defines WindowInfo and WindowInfoArray structures to store window information.
- Uses CGWindowListCopyWindowInfo to get information about all windows.
- Filters out the tray icon windows belonging to our application.
- Collects position, size, and display information for each tray icon.
- Packages the collected information in a C-compatible format.
Next, we need to use JNA in Kotlin to call this Swift function. We can define a MacosApi interface to represent this native library:
interface MacosApi : Library {
fun getTrayWindowInfos(pid: Long): Pointer?
companion object {
val INSTANCE: MacosApi = Native.load("MacosApi", MacosApi::class.java)
}
}
Then, we need to define Kotlin classes corresponding to the Swift structures:
@Structure.FieldOrder("x", "y", "width", "height", "displayID")
class WindowInfo : Structure, AutoCloseable {
@JvmField var x: Float = 0f
@JvmField var y: Float = 0f
@JvmField var width: Float = 0f
@JvmField var height: Float = 0f
@JvmField var displayID: Int = 0
constructor() : super()
constructor(p: Pointer) : super(p) {
read()
}
override fun close() {
clear()
}
}
@Structure.FieldOrder("count", "windowInfos")
class WindowInfoArray(p: Pointer) : Structure(p) {
@JvmField var count: Int = 0
@JvmField var windowInfos: Pointer? = null
init {
read()
}
}
Here we use the @Structure.FieldOrder annotation to ensure field order matches the Swift structure, which is crucial for correctly parsing data in memory.
Finally, to prevent memory leaks, we need to manually release memory after using the window information:
fun List<WindowInfo>.useAll(block: (List<WindowInfo>) -> Unit) {
try {
block(this)
} finally {
this.forEach { it.close() }
}
}
This extension function allows us to safely use window information and automatically release resources when finished.
Conclusion
Through the above steps, we've successfully solved the main issues in the initial implementation:
- Using template images to make tray icons automatically adjust colors based on system theme.
- Customizing the Tray component to support more mouse events.
- Using macOS system APIs to get the exact position of tray icons.
These improvements make our system tray application not only comply with macOS design specifications but also possess more powerful interaction capabilities. Now, we can display custom windows near the tray icon as needed to implement more complex functionality.
With these optimizations, our macOS system tray application now not only provides powerful functionality but also delivers an excellent user experience. I hope this tutorial helps you fully utilize the potential of system tray when developing macOS desktop applications.