跳转到内容

解决 macOS 菜单栏应用设置窗口被隐藏的问题

Published: at 02:01

问题背景

在开发 macOS 菜单栏应用时,我遇到了一个令人困惑的问题:点击菜单栏的设置按钮后,设置窗口并没有显示出来,而是被其他应用的窗口覆盖在后面,造成了「点击没有反应」的假象。

这个问题的根源在于,当应用的 Info.plist 中设置了 Application is agent (UIElement)YES 时,应用不会在 Dock 栏显示图标。而 macOS 系统对于没有 Dock 图标的应用,默认不允许其窗口置顶显示——这是系统对菜单栏工具类应用的特殊处理逻辑。

问题分析

为什么会被隐藏?

macOS 将菜单栏应用视为后台工具(background utilities),而非前台应用(foreground applications)。这种设计影响了窗口的层级顺序和事件接收机制。当应用的激活策略(Activation Policy)设置为 .accessory 时,系统不会主动将其窗口置于最前方。

从 macOS 14 Sonoma 开始,Apple 完全移除了使用 NSApp.sendAction() 方法通过 showSettingsWindow:showPreferencesWindow: 选择器打开设置窗口的能力。官方推荐使用 SwiftUI 的 SettingsLink 组件。

然而,SettingsLink 在菜单栏应用中存在明显的局限性:

  1. 无法在打开设置窗口前后执行自定义代码
  2. 无法程序化地控制窗口的激活状态
  3. 对于没有 Dock 图标的应用,窗口容易被其他应用覆盖

解决方案

经过研究多个开源项目和社区讨论,我找到了一个简洁有效的解决方案,来自 Hacking with Swift 论坛

核心思路

  1. 打开设置时:临时将应用的激活策略改为 .regular(显示 Dock 图标),并激活应用
  2. 关闭设置时:恢复激活策略为 .accessory(隐藏 Dock 图标),并取消激活

代码实现

1. 在菜单栏视图中打开设置

Button(action: {
    // 使用 EnvironmentValues 直接调用 openSettings
    let environment = EnvironmentValues()
    environment.openSettings()
    NSApp.setActivationPolicy(.regular)
    NSApp.activate(ignoringOtherApps: true)
}) {
    Image(systemName: "gearshape")
        .font(.system(size: 14))
        .frame(width: 24, height: 24)
}
.buttonStyle(.plain)
.foregroundColor(.secondary)
.help("Settings")

这里的关键是:

2. 在应用主结构中监听设置窗口关闭

@main
struct LinkSetApp: App {
    var body: some Scene {
        // ... 其他 Scene
        
        Settings {
            SettingsView()
                .onDisappear {
                    NSApp.setActivationPolicy(.accessory)
                    NSApp.deactivate()
                }
        }
    }
}

当设置窗口关闭时:

注意事项

开发环境的特殊情况

在从 Xcode 直接运行应用时,如果 Xcode 处于活动状态,这个方案可能不会完全正常工作。这是因为系统可能将 Xcode 视为应用的一部分,导致窗口激活逻辑出现偏差。

解决办法:使用 Archive 构建正式版本进行测试,或者在运行时切换到其他应用再测试设置按钮。

用户体验考虑

这个方案会让 Dock 图标在打开设置时短暂出现,然后在关闭设置后消失。这实际上是一个合理的用户体验设计:

其他方案对比

在解决这个问题的过程中,我也尝试了其他几种方案:

方案一:使用 simultaneousGesture

SettingsLink { ... }
    .simultaneousGesture(TapGesture().onEnded {
        NSApp.activate(ignoringOtherApps: true)
    })

问题:手势执行时机不确定,窗口仍然可能被隐藏。

方案二:延迟激活 + 窗口遍历

DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
    NSApp.activate(ignoringOtherApps: true)
    for window in NSApp.windows where window.title.contains("Settings") {
        window.orderFrontRegardless()
    }
}

问题:依赖延迟时间不可靠,且 SettingsLink 的点击事件被覆盖后无法正常打开窗口。

方案三:使用第三方库 SettingsAccess

SettingsAccess 是一个专门解决这个问题的开源库,提供了更完善的 API。

优点:功能完整,支持 pre/post action 缺点:需要引入额外依赖

对于简单的使用场景,本文介绍的原生方案已经足够。

总结

macOS 菜单栏应用的窗口管理一直是 SwiftUI 开发中的痛点。Apple 在推进 SwiftUI 现代化的同时,也移除了一些传统的 AppKit 解决方案,导致开发者需要寻找新的变通方法。

本文介绍的方案通过动态切换应用的激活策略,巧妙地解决了设置窗口被隐藏的问题。虽然需要一些额外的代码,但实现简洁,不依赖第三方库,是目前较为理想的解决方案。

希望这篇文章能帮助到正在开发 macOS 菜单栏应用的你。如果你有更好的解决方案,欢迎交流讨论。


参考资料