Skip to content
mac-system-tray

打造完美的 macOS 系统托盘

引言

在现代桌面应用程序中,系统托盘已经成为不可或缺的一部分。它为用户提供了一种快捷、高效的方式来与应用程序进行交互,而无需打开主窗口。系统托盘的优势主要体现在以下几个方面:

  1. 便捷访问:用户可以快速查看应用状态、执行常用操作。
  2. 减少视觉干扰:不会占用宝贵的屏幕空间。
  3. 后台运行:对于需要在后台持续运行的应用来说,系统托盘是理想的界面选择。

本文将以 macOS 平台为例,详细讲解如何使用 Kotlin 和 Compose Multiplatform 框架创建一个功能完善、符合 macOS 设计规范的系统托盘应用。

基础实现: 创建简单的系统托盘

让我们从最基础的实现开始。使用 Compose Multiplatform 框架,只需几行代码就可以在 macOS 上创建一个简单的系统托盘:

kotlin
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)
        }
    )
}

这段代码创建了一个基本的系统托盘,包含一个图标和一个简单的退出菜单项。运行后,效果如下:

mac-tray-b-1

然而,这个基础实现还存在一些问题:

  1. 图标显示: 托盘图标显示原本的颜色,而不是根据系统主题自动切换黑白颜色,这不符合 macOS 的设计规范
  2. 交互限制:
    • 点击托盘图标只能弹出菜单,无法支持显示自定义窗口
    • 默认 API 不支持监听各种点击事件(如单击、双击等)
    • 框架未提供 API 获取托盘图标的准确位置,这限制了我们在图标附近显示自定义窗口的能力

接下来,我们将逐步解决这些问题,打造一个完美的 macOS 系统托盘应用。

解决方案

1. 适配 macOS 设计规范: 实现自动切换图标颜色

macOS 的设计规范要求系统托盘图标能够根据系统主题自动切换颜色。要实现这一点,我们需要将图标设置为"模板图像"(Template Image)。在 Java/Kotlin 中,可以通过设置系统属性来实现:

kotlin
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.

这个设置告诉系统将我们的图标视为模板图像。模板图像的特点是:

  • 所有的颜色信息都会被丢弃。
  • 系统会自动根据当前主题调整图像,使其在不同背景下保持可见。

虽然可以在代码中直接设置这个属性,但更好的做法是在构建配置中指定。这样可以确保属性在应用启动时就被正确设置。在 Gradle 构建文件中,可以这样配置:

kotlin
compose.desktop {
    application {
        mainClass = "com.crosspaste.CrossPaste"
        nativeDistributions {
            targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
            macOS {
                jvmArgs("-Dapple.awt.enableTemplateImages=true")
            }
        }
    }
}

这个配置确保了在 macOS 平台上,应用启动时会自动启用模板图像功能。

2. 增强交互能力: 自定义点击事件和获取图标位置

2.1 支持自定义点击事件

Compose Multiplatform 的默认 Tray 组件在 macOS 上只支持右键点击事件。为了实现更丰富的交互,我们需要自定义 Tray 组件。

首先,让我们看看 compose multiplatform 源码 的源码:

kotlin
// @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 = {}
)

为了支持更多的鼠标事件,我们可以创建一个自定义的 CrossPasteTray 组件。这个组件将允许我们添加自定义的 MouseListener:

kotlin
fun CrossPasteTray(
    icon: Painter,
    state: CrossPasteTrayState = rememberTrayState(),
    tooltip: String? = null,
    onAction: (ActionEvent) -> Unit = {},
    mouseListener: MouseListener,
    menu: @Composable (MenuScope.() -> Unit) = {},
) {
   // ... 其他代码 ...

    val tray =
        remember {
            TrayIcon(awtIcon).apply {
                isImageAutoSize = true

                addActionListener { e ->
                    currentOnAction(e)
                }
                // 添加鼠标监听器
                addMouseListener(mouseListener)
            }
        }

    // ... 其他代码 ...    
}

这个自定义组件允许我们监听各种鼠标事件,如单击、双击、右键点击等,大大增强了交互的灵活性。

另外源码还有一缺陷

kotlin
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 的 iconSize 设置为 22x22, 如果提供的图标分辨率很高,将会导致图标锯齿化,我们可以自定义 iconSize 来避免

2.2 获取托盘图标位置

要在托盘图标附近显示自定义窗口,我们需要知道图标的准确位置。不幸的是,Compose Multiplatform 并没有提供直接的 API 来获取这个信息。但是,我们可以利用 macOS 的系统 API 来实现这个功能。

首先,我们需要添加 JNA (Java Native Access) 相关的依赖,以便调用 macOS 的系统 API:

libs.versions.toml

toml
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
jna-platform = { module = "net.java.dev.jna:jna-platform", version.ref = "jna" }

然后,我们可以使用 Swift 编写一个本地库,利用 macOS 的 CGWindowListCopyWindowInfo API 来获取托盘图标的位置信息:

swift
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)
}

这段 Swift 代码做了以下几件事:

  1. 定义了 WindowInfo 和 WindowInfoArray 结构体来存储窗口信息。
  2. 使用 CGWindowListCopyWindowInfo 获取所有窗口的信息。
  3. 筛选出属于我们应用的托盘图标窗口。
  4. 收集每个托盘图标的位置、大小和所在显示器的信息。
  5. 将收集到的信息打包成 C 可用的格式返回。

接下来,我们需要在 Kotlin 中使用 JNA 调用这个 Swift 函数。我们可以定义一个 MacosApi 接口来表示这个本地库:

MacosApi

kotlin
interface MacosApi : Library {
    fun getTrayWindowInfos(pid: Long): Pointer?
    
    companion object {
        val INSTANCE: MacosApi = Native.load("MacosApi", MacosApi::class.java)
    }
}

然后,我们需要定义与 Swift 结构体对应的 Kotlin 类:

kotlin
@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()
    }
}

这里使用 @Structure.FieldOrder 注解来确保字段顺序与 Swift 结构体一致,这对于正确解析内存中的数据至关重要。

最后,为了防止内存泄漏,我们需要在使用完窗口信息后手动释放内存:

kotlin
fun List<WindowInfo>.useAll(block: (List<WindowInfo>) -> Unit) {
    try {
        block(this)
    } finally {
        this.forEach { it.close() }
    }
}

这个扩展函数允许我们安全地使用窗口信息,并在使用完毕后自动释放资源。

结语

通过以上步骤,我们成功解决了初始实现中的主要问题:

  1. 使用模板图像使托盘图标能够根据系统主题自动调整颜色。
  2. 自定义 Tray 组件,支持更多的鼠标事件。
  3. 利用 macOS 系统 API 获取托盘图标的准确位置。

这些改进使我们的系统托盘应用不仅符合 macOS 的设计规范,还具备了更强大的交互能力。现在,我们可以根据需要在托盘图标附近显示自定义窗口,实现更复杂的功能。

通过这些优化,我们的 macOS 系统托盘应用现在不仅功能强大,还能提供出色的用户体验。希望这篇教程能够帮助你在开发 macOS 桌面应用时,充分利用系统托盘的潜力。