用SwiftUI打造macOS菜单栏原生应用#
在做Funny Day Calendar——一款介绍各种冷门有趣节日的macOS应用时,我很快意识到"只有一个日历窗口"根本算不上一个完整的产品。今天的节日是那种你想瞥一眼就知道的东西,而不是需要专门打开一个应用去查的东西。这意味着它应该出现在菜单栏里。
于是这个项目最终有了三个展示同一内容的入口:带有日历和节日详情的完整窗口、显示今日节日的菜单栏图标(MenuBarExtra),以及一个桌面小组件。以下是我在用SwiftUI搭建这一切、并把它发布到Mac App Store(id 6773287898)过程中学到的东西。
为什么值得再做一个菜单栏应用#
macOS的菜单栏不是"一个小窗口",而是应用存在的另一种模式。用户不会从Dock启动你,也不会用Cmd+Tab切换到你——他们只是在余光中注意到那个图标。这就改变了应该放在那里的内容:恰好是一眼能看完的信息量,多一个字节都不行。
对Funny Day Calendar来说,这意味着菜单栏图标只用一行文字显示今日节日,而节日历史、搜索、主题和背景设置全都留在主窗口里。这个划分比任何实现细节都更重要,不过实现本身也并不简单,我们就从这里说起。
MenuBarExtra:从图标到内容#
在SwiftUI之前,往菜单栏放图标的唯一方式是AppKit的NSStatusItem——能用,但很啰嗦,需要手动管理NSPopover或NSMenu。从macOS 13开始,Apple加入了一个声明式的场景类型MenuBarExtra,省去了大部分这类样板代码。
一个基础实现只需要几行:
import SwiftUI
@main
struct FunnyDayCalendarApp: App {
@StateObject private var holidayStore = HolidayStore()
var body: some Scene {
WindowGroup {
CalendarWindowView()
.environmentObject(holidayStore)
}
MenuBarExtra {
MenuBarContentView()
.environmentObject(holidayStore)
} label: {
MenuBarLabelView(holiday: holidayStore.todayHoliday)
}
.menuBarExtraStyle(.window)
}
}关键点在于:MenuBarExtra可以和普通的WindowGroup一起声明在同一个App里。这是两个独立的场景类型,共享同一个EnvironmentObject,所以只加载一次到HolidayStore里的今日节日,能够同步出现在主窗口和菜单栏中——不需要重复写加载逻辑。
菜单栏标签应该避免使用很长的文字——空间不够时macOS会截断菜单栏项目,用一个SF Symbol配上简短文字看起来会干净得多。
struct MenuBarLabelView: View {
let holiday: Holiday?
var body: some View {
if let holiday {
Label(holiday.shortTitle, systemImage: "calendar.badge.clock")
} else {
Image(systemName: "calendar")
}
}
}.window 与 .menu:该选哪个#
MenuBarExtra有两种内容样式,二者的差异不是外观上的,而是架构层面的。
| 对比项 | .menu(默认) | .window |
|---|---|---|
| 渲染的内容 | 真正的NSMenu菜单项 | 弹出窗口里的任意SwiftUI内容 |
| 可交互性 | 仅限Button、Toggle、Divider、子菜单 | 任意view:List、滑块、图片、自定义布局 |
| 是否阻塞runloop | 是,菜单打开期间应用内的动画和定时器会暂停 | 否,窗口的行为和普通SwiftUI场景一样 |
| 菜单栏自动隐藏 | 表现正常 | 在全屏应用上方打开弹窗时,菜单栏重新出现存在已知bug |
| 适用场景 | 简单命令:"刷新""设置""退出" | 丰富的预览:日历、节日卡片、主题 |
对Funny Day Calendar而言,选择很明确:今日节日不是一条命令,而是一张带插图和说明文字的小卡片,所以.window更合适。代价是:点击窗口外部关闭这件事要自己实现,还要留意全屏模式下菜单栏自动隐藏的问题——这在Apple开发者论坛上是个尚未解决的话题,截至2026年也没有干净的系统级方案;如果这个行为对体验很关键,就得通过NSWindow的delegate手动处理。
Settings场景与Dock图标的生命周期细节#
一旦应用有了MenuBarExtra,就会出现一个问题:这个应用到底要不要出现在Dock里?对Funny Day Calendar来说答案是"通常需要",因为它不是一个纯后台工具,而是有完整窗口的应用。但偏爱极简的用户,会希望能隐藏Dock图标、只靠菜单栏生活。
技术上这通过NSApplication.ActivationPolicy来解决:
import AppKit
enum DockVisibility {
static func setHidden(_ hidden: Bool) {
NSApp.setActivationPolicy(hidden ? .accessory : .regular)
}
}.accessory会移除Dock图标和应用切换器里的条目,效果和Info.plist里的LSUIElement键一样,但它是以编程方式完成的——这样"在Dock中显示"这个开关就可以放进应用的UI里,而不是在构建时就一锤定音。
另一个麻烦点是Settings场景。SwiftUI提供了声明式API:
Settings {
SettingsView()
.environmentObject(holidayStore)
}以及用来打开它的系统按钮SettingsLink。但实际情况是,当应用以.accessory模式运行时,从MenuBarExtra窗口调用SettingsLink,并不总能把设置窗口带到最前面——菜单栏那边仍然保持活跃,设置窗口可能开在其他应用后面。一个可行的变通办法是在打开设置之前,先显式激活应用:
Button("设置…") {
NSApp.activate(ignoringOtherApps: true)
NSApp.sendAction(
Selector(("showSettingsWindow:")),
to: nil,
from: nil
)
}这不是我写过最优雅的代码,但在我测试过Funny Day Calendar的所有macOS版本上都稳定生效。
桌面小组件:不用再做一个应用,直接用WidgetKit#
从macOS Sonoma开始,WidgetKit小组件可以直接放在桌面上,而不只是通知中心里,macOS Tahoe还给它们加上了会随壁纸透出的Liquid Glass背景。对Funny Day Calendar来说,这意味着可以直接复用和iOS上一样的widget target:同样的WidgetKind、TimelineProvider,以及节日卡片的SwiftUI布局——只是多了桌面上的可用性。
真正的工程难点不在渲染,而在数据的传递。widget扩展是一个独立进程,无法直接访问主应用的HolidayStore。数据要通过App Group跨越这道边界:
struct HolidayProvider: TimelineProvider {
func getTimeline(
in context: Context,
completion: @escaping (Timeline<HolidayEntry>) -> Void
) {
let holiday = SharedHolidayStore.shared.todayHoliday()
let entry = HolidayEntry(date: .now, holiday: holiday)
// 在下一个午夜刷新——今日节日每天只变化一次
let midnight = Calendar.current.startOfDay(
for: .now.addingTimeInterval(86_400)
)
completion(Timeline(entries: [entry], policy: .after(midnight)))
}
func placeholder(in context: Context) -> HolidayEntry { .placeholder }
func getSnapshot(
in context: Context,
completion: @escaping (HolidayEntry) -> Void
) {
completion(.placeholder)
}
}当主应用更新了数据(比如在设置里换了主题或背景),必须显式地请求系统重建时间线:
import WidgetKit
WidgetCenter.shared.reloadAllTimelines()没有这次调用,小组件会一直显示过时的快照,直到下一次计划内的刷新——WidgetKit的刷新预算是故意受限的,为了省电,所以不能指望它"自己就会更新"。
App Sandbox与通往Mac App Store之路#
这里有必要澄清一个常见的误解:公证(notarytool)是给通过Developer ID、在Mac App Store之外分发的应用准备的流程。通过Mac App Store发布的应用不会经历公证这个独立步骤——取而代之的是,它必须启用App Sandbox,并通过App Review。
对Funny Day Calendar来说,App Sandbox其实相当宽松:应用不需要网络访问(节日数据是本地的),所以需要的entitlements非常精简:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.pro.dodecaidr.funnydaycalendar</string>
</array>
</dict>
</plist>这里的App Group条目必不可少——沙盒里彼此的文件是相互隔离的,主应用和widget扩展正是通过它来交换数据的。
还有一点值得反复检查:主target和widget扩展必须属于同一个App Group、同一个Team ID——否则UserDefaults(suiteName:)会悄无声息地返回nil,小组件会一片空白,控制台里连一行解释原因的日志都没有。
菜单栏应用的App Review:真正重要的地方#
从Mac App Store审核的角度看,MenuBarExtra应用并不是什么特殊类别,但正因为部分功能存在于主窗口之外,有几个细节就凸显了出来。
- 最低功能性要求(第4.2条准则)。 如果Funny Day Calendar只有一个菜单栏图标、没有实质性的主窗口,那看起来就是教科书式的被拒理由——"功能不足以支撑一个独立应用"。一个带日历、节日历史和设置的完整窗口,不只是一个体验上的决定,也是应对审核意见的一道保险。
- 截图仍然要围绕主窗口。 Mac App Store商品页的营销截图需要在系统要求的屏幕尺寸下展示核心用户体验——菜单栏图标本身无法替代真实的界面截图。
- 给审核人员的备注从来不会是多余的。 对于不在主窗口里出现的功能(菜单栏图标、桌面小组件),在审核备注里明确说明是值得的,这样苹果的审核人员就不用去猜测首次启动时不太明显的东西。
这些做法都不是在规则边缘打擦边球,而是确保审核人员看到的产品面貌,和普通用户看到的一样。
结语#
SwiftUI的MenuBarExtra省去了过去需要手写AppKit的大部分样板代码,但它并没有省去架构层面的决策:该选哪种样式、如何在主窗口、菜单栏和小组件之间同步数据、如何处理Dock图标和设置。对Funny Day Calendar来说,窗口、菜单栏、小组件这三者的组合,最终不是一份为了凑数的功能清单,而是三种不同的方式,去呈现同一个简单的事实:今天是什么节日。
如果想看看最终成品,这款应用叫Funny Day Calendar,已经上架Mac App Store。



