Go 程序在 Mac 菜单栏里的一个图标 左边是三行 Go 代码:init 里调用 runtime.LockOSThread,以及 NSStatusItem 和 LSUIElement 两个关键字。右边是一块 Mac 桌面:顶部菜单栏右侧有一个高亮的仪表盘图标,图标正下方弹出一个面板,面板里有一行计时和一个按钮;下方还有一个带红黄绿三个按钮的原生窗口。 func init() { runtime. LockOSThread() } NSStatusItem LSUIElement = true

用 Go 写了一个小工具,想给它加个界面,最省事的办法几乎人人都用过:

Go
http.HandleFunc("/", index)
exec.Command("open", "http://localhost:8080").Run()
log.Fatal(http.ListenAndServe(":8080", nil))

界面是有了,但它是浏览器里的一个标签页。关掉标签页程序还在后台跑,再打开要记得那个端口;本机别的程序也能连上这个端口。我们想要的其实是 Mac 上那种常见的小工具:双击一个 .app 就开,屏幕右上角的菜单栏里常驻一个图标,点一下弹出一个面板或者窗口,不用时就安静地待在那里。

那么问题来了:Go 的标准库里根本没有窗口,这个窗口从哪来?网上搜到的 Fyne、Wails、systray 名字一大堆,哪些画的是真正的原生窗口,哪些里面其实还是网页?做出来以后,又怎么变成发给别人也能双击打开的程序?

我们用同一个小需求,在一台 macOS 15 上把能找到的做法挨个试一遍。

一、窗口的来源

Go 和 AppKit 之间隔着什么

Mac 上的窗口、菜单、按钮,都归一个叫 AppKit 的系统框架管1。Swift 或 Objective-C 写的 App 直接调用它,可 AppKit 是 Objective-C 接口,Go 没法直接 import。

好在 Objective-C 的方法调用有一个很朴素的底层形式。[NSStatusBar systemStatusBar] 这样一句代码,编译以后就是调用一个 C 函数 objc_msgSend:第一个参数是对象(这里是 NSStatusBar 这个类),第二个参数是方法名对应的选择子(selector),后面跟着方法的参数2。也就是说,只要 Go 能调用 C 函数,它就能给 AppKit 发消息。剩下的问题只是:从 Go 到这个 C 函数,走哪座桥。

第一条路:cgo 加一个 Objective-C 文件

最常见的做法是用 cgo3。在 Go 文件旁边放一个 app.m,里面用 Objective-C 把窗口、菜单栏图标都写好,只暴露几个 C 函数给 Go 调:

Go
/*
#cgo CFLAGS: -x objective-c -fobjc-arc
#cgo LDFLAGS: -framework Cocoa
void appSetup(void);
void appRun(void);
*/
import "C"

这样写最直接,Apple 的文档、Stack Overflow 上的例子都能照搬。代价在编译期:cgo 要调用 clang 编译 app.m,还要链接 Cocoa,所以必须有 Xcode 命令行工具和 macOS SDK。换句话说,只能在 Mac 上编译。我们在 Linux 上用 CGO_ENABLED=0 GOOS=darwin 试着编译后面要对比的 10 个库,Fyne、Gio、Wails、webview_go、darwinkit、menuet、miqt 和三个 systray 库,没有一个编得过,报的都是类似的错:

github.com/webview/webview_go: build constraints exclude all Go files in …
fyne.io/systray/systray.go:114:2: undefined: nativeLoop

带 import "C" 的文件在关掉 cgo 时整个不参与编译,真正干活的函数就都“未定义”了。

第二条路:purego,不要 C 编译器

另一条路是 purego4。它不在编译期做任何事,而是在运行时用 dlopen 打开系统库,再用一小段汇编按 C 的调用约定去调函数。它的 objc 子包把 objc_msgSend 包成了 Go 方法,前面那句 Objective-C 写成 Go 就是:

Go
bar := objc.ID(objc.GetClass("NSStatusBar")).Send(objc.RegisterName("systemStatusBar"))

既然编译期不需要 clang,也就不需要 Mac。我们在 Linux 容器里直接编译:

Terminal window
$ CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -o menubar .
$ file menubar
menubar: Mach-O 64-bit arm64 executable, flags:<|DYLDLINK|PIE>

把这个 2.2 MB 的文件拷到 Mac 上,窗口和菜单栏图标都正常出来了(第三节的截图就是它)。用 otool -L 看,它链接的只有 libSystem 和 libresolv,AppKit 是启动后才 dlopen 进来的。

Go 调用 AppKit 的两条路 左边是 Go 代码里的一行调用:向 NSStatusBar 发送 systemStatusBar 消息。中间有两条路:cgo 在编译期用 clang 编译 Objective-C 并链接 Cocoa,需要 Xcode 命令行工具和 macOS SDK;purego 在运行时 dlopen AppKit 并直接调用 objc_msgSend,编译期不需要 C 编译器。两条路最后都落到 libobjc 的 objc_msgSend,再进入 AppKit,最后由 WindowServer 画到屏幕上。 Go 程序 桥 macOS 进程里 main.go cls("NSStatusBar") .Send(sel( "systemStatusBar")) 一条 Objective-C 消息 cgo 编译期 clang -x objective-c app.m -framework Cocoa 要 Xcode 命令行工具和 SDK purego 运行时 Dlopen(".../AppKit") 调 objc_msgSend CGO_ENABLED=0 也能编 objc_msgSend libobjc.A.dylib AppKit.framework NSStatusBar NSWindow WindowServer 画到屏幕
图 1 · 不管走 cgo 还是 purego,最后都是同一个 objc_msgSend 进 AppKit;差别只在 C 编译器是编译期要,还是根本不要

两条路的差别画在图 1 里:调用最后都落到同一个 objc_msgSend,进入同一个 AppKit。cgo 的胶水代码写成 Objective-C,读起来更自然,类型也由编译器检查;purego 的胶水写成 Go,每个选择子都是字符串,写错了要到运行时才知道,但换来了在任何系统上都能交叉编译。

不管走哪条路,代码里都少不了这样一行,而且位置很讲究:

Go
func init() { runtime.LockOSThread() }

它是做什么的,为什么非得写在 init 里?

二、主线程的约束

现象:换个 goroutine 建窗口

第一节的程序如果删掉这一行,多数时候看起来照样能跑,这正是麻烦的地方。为了看清它防的是什么,我们写了一个反例:先起 200 个 goroutine,统计有几个碰巧跑在主线程上;再起一个 goroutine,在里面创建 NSWindow。同一个二进制在 Mac 上连跑三次:

200 个 goroutine 里有 38 个跑在主线程上
goroutine 在主线程上: true ,开始创建 NSWindow ...
创建成功,window = 5208318688
200 个 goroutine 里有 51 个跑在主线程上
goroutine 在主线程上: false ,开始创建 NSWindow ...
*** Terminating app due to uncaught exception 'NSInternalInconsistencyException',
reason: 'NSWindow should only be instantiated on the main thread!'
200 个 goroutine 里有 26 个跑在主线程上
goroutine 在主线程上: false ,开始创建 NSWindow ...
*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', …

第一次成功,后两次直接崩溃,区别只在于那个 goroutine 恰好被分到了哪个线程上。如果先在 main goroutine 里调用 runtime.LockOSThread() 占住主线程,其他 goroutine 就再也分不到主线程,从 goroutine 里建窗口就变成了每次必崩。

原因:AppKit 只认主线程,goroutine 却不固定在线程上

AppKit 有一条硬规定:界面对象只能在进程的主线程上创建和修改5。主线程就是进程启动时的第一个线程,它负责跑事件循环:接收鼠标点击、键盘输入,重绘窗口。

而 Go 程序里没有“在哪个线程上”这个概念。goroutine 由调度器分配到若干个系统线程上执行,一个 goroutine 这一刻在主线程上,下一刻可能就被挪到别的线程,main 函数所在的 main goroutine 也不例外。Go 运行时只保证一件事:所有 init 函数都在启动线程(也就是主线程)上执行,并且如果在 init 里调用了 runtime.LockOSThread(),main 函数也会在这个线程上被调用,并且一直待在上面6。

解决:锁住主线程,其他 goroutine 投递消息

所以做法分两半。第一半是在 init 里锁线程,让 main 从头到尾都在主线程上:在这里创建窗口和图标,最后调用 [NSApp run] 进入 AppKit 的事件循环,这个调用不会返回。

第二半是业务逻辑放到别的 goroutine 里。它们算出结果以后,不能直接改界面,而是把“改界面”这件事投递给主线程,排进事件循环的队列7。例子里每秒更新一次计数的 goroutine 是这样写的:

Go
go func() {
for i := 1; ; i++ {
time.Sleep(time.Second)
label.Send(sel("performSelectorOnMainThread:withObject:waitUntilDone:"),
sel("setStringValue:"), nsstr(fmt.Sprintf("goroutine 计数:%d", i)), false)
}
}()
一个菜单栏程序里的线程分工 三条横向的系统线程。主线程上依次是 init 里的 runtime.LockOSThread、main 里创建窗口和菜单栏图标、然后进入 NSApp run 事件循环,一直不返回。另一条线程上跑着计数的 goroutine,它每秒算出新文字,通过 performSelectorOnMainThread 把 setStringValue 投递给主线程的事件循环。第三条线程上跑着业务 goroutine,比如网络请求,不碰 AppKit。 主线程(锁给 main goroutine) 线程 M3 · 计数 goroutine 线程 M5 · 业务 goroutine init() runtime.LockOSThread() main() 建 NSWindow、NSStatusItem [NSApp run] 事件循环 点击、重绘、投递来的消息 … time.Sleep(time.Second) performSelectorOnMainThread: setStringValue: 改界面只能投递 回主线程去做 http.Get(...) / 读文件 / 算东西 不碰 AppKit,随便在哪个线程上跑
图 2 · 主线程只做一件事:跑 AppKit 的事件循环;其他 goroutine 想改界面,就把消息投递过去排队

各个库都替我们做了这件事,只是名字不同:cgo 里写 dispatch_async(dispatch_get_main_queue(), …),darwinkit 是 dispatch.MainQueue().DispatchAsync,Fyne 是 fyne.Do,Gio 是 w.Invalidate() 之后在事件循环里重画,webview 是 w.Dispatch。

主线程和窗口都有了,右上角那个图标又是怎么来的?

三、菜单栏图标

NSStatusItem

菜单栏右侧那一排图标,每一个都是一个 NSStatusItem8。向系统状态栏要一个位置,给它的按钮设一张图,再挂一个菜单,就是最基本的菜单栏程序:

Go
item := objc.ID(cls("NSStatusBar")).Send(sel("systemStatusBar")).
Send(sel("statusItemWithLength:"), -1.0) // -1:宽度跟着内容走
img := objc.ID(cls("NSImage")).Send(sel("imageWithSystemSymbolName:accessibilityDescription:"),
nsstr("gauge.with.needle"), nsstr("Go"))
item.Send(sel("button")).Send(sel("setImage:"), img)
item.Send(sel("setMenu:"), menu)

图标要用模板图(template image):只画黑色和透明,系统按浅色、深色和点击高亮自动给它上色9。上面用的 SF Symbols 图标本身就是模板图;自己画的 PNG 要设 setTemplate:YES,各个库里一般叫 SetTemplateIcon。

点开以后:菜单还是面板

点击图标后出现的东西有两种。挂了 menu,点一下就弹出标准的下拉菜单:

菜单栏里高亮的仪表盘图标下面弹出下拉菜单:打开窗口 ⌘O、退出 ⌘Q
挂了 NSMenu 的 NSStatusItem,点开就是系统标准的下拉菜单,快捷键也是菜单项自带的。

想要一块能放任意内容的面板,就不挂菜单,而是给按钮设一个点击动作,在动作里弹出 NSPopover10,并让它贴着图标的下边缘:

Go
bounds := objc.Send[NSRect](button, sel("bounds"))
pop.Send(sel("showRelativeToRect:ofView:preferredEdge:"), bounds, button, uint(1)) // 1:图标下方

点击动作要有一个 Objective-C 对象来接。purego 可以在运行时注册一个新的 Objective-C 类,方法体就是一个 Go 函数:

Go
handler, _ := objc.RegisterClass("GoStatusHandler", cls("NSObject"), nil, nil, []objc.MethodDef{
{Cmd: sel("toggle:"), Fn: func(_ objc.ID, _ objc.SEL, _ objc.ID) { toggle() }},
})
button.Send(sel("setTarget:"), objc.ID(handler).Send(sel("new")))
button.Send(sel("setAction:"), sel("toggle:"))
菜单栏图标正下方弹出一个带箭头的面板,里面写着已运行 8 秒,下面一个退出按钮
在 Linux 上交叉编译的 purego 程序,在 macOS 15 上点开菜单栏图标弹出的 NSPopover。

不要 Dock 图标

菜单栏程序一般不希望在 Dock 里也占一个位置,也不希望自己被切到前台时把屏幕左上角的菜单换成自己的。AppKit 管这叫激活策略(activation policy):regular 是普通 App,accessory 没有 Dock 图标也不接管菜单栏,但仍然可以有窗口11。可以在代码里设:

Go
app.Send(sel("setActivationPolicy:"), 1) // NSApplicationActivationPolicyAccessory

也可以写在 .app 的 Info.plist 里,把 LSUIElement 设为 true12,这样程序一启动就是 accessory,连 Dock 图标闪一下都不会有。实测里那些只有菜单栏图标的库,打包时加了这个键,lsappinfo 报告的类型都是 UIElement。

但 Info.plist 给的只是初始值,代码里再调一次 setActivationPolicy: 就会覆盖它。Gio 在启动时会主动设成 regular,我们给 Gio 的例子加上 LSUIElement 以后,lsappinfo 报告的类型仍然是 Foreground,Dock 里照样有图标。所以用某个库做菜单栏程序之前,要先看它会不会自己改激活策略。

到这里,一个菜单栏小程序需要的东西都齐了。但每个控件都这样一条条发消息,写起来很累。现成的库能省多少事,代价又是什么?

四、现成的库

窗口里的内容由谁来画

Go 生态里能在 Mac 上开窗口的库不少,第一眼很难分清。按“窗口里的内容由谁来画”来分就清楚了:窗口这个外壳,大家用的都是 AppKit 的 NSWindow;区别在里面那块内容。

窗口里的内容由谁来画 四栏。前三栏都是同一个 NSWindow 外壳,里面的内容来源不同:第一栏是 AppKit 自带的 NSTextField、NSButton,对应 cgo、purego、darwinkit;第二栏是一块 Metal 或 OpenGL 画布,由框架自己画控件,对应 Fyne、Gio、Cogent Core、Qt;第三栏是 WKWebView 里的网页,地址是 wails://localhost/,对应 Wails v3、webview_go,以及 Electron、CEF 一类。第四栏没有窗口,只有菜单栏里的 NSStatusItem 和下拉的 NSMenu,对应 fyne.io/systray、energye/systray、menuet。 AppKit 控件 NSWindow NSTextField NSButton cgo + Objective-C purego darwinkit 和系统自带 App 用同一套控件 GPU 自己画 NSWindow 一张 Metal / OpenGL 画布 Fyne(OpenGL) Gio(Metal) Cogent Core Qt(miqt) 虚线框里全是框架 自己画的,含字体 WKWebView 网页 NSWindow <h3>…</h3> wails:// localhost/ Wails v3 webview_go (Electron / CEF) HTML 由 WebKit 在别的进程里渲染 只有菜单栏图标 NSMenu 打开窗口 退出 fyne.io/systray energye/systray menuet 没有窗口,要窗口 得再配一个库
图 3 · 窗口本身都是 AppKit 的 NSWindow,区别在里面那块内容是系统控件、框架自己画的画布,还是一个网页
  • 用 AppKit 自己的控件:上面手写 cgo 和 purego 的做法,以及 darwinkit13。它把 Apple SDK 里的 AppKit、Foundation、WebKit 等框架生成了 Go 绑定,写起来像 appkit.NewWindowWithSize(360, 160),底层是 cgo 加 libffi。控件就是系统控件,长相、无障碍、输入法都和系统自带的 App 一致。
  • 框架自己用 GPU 画:Fyne14、Gio15、Cogent Core16,还有 Qt(Go 里用 miqt17 绑定)。窗口里是一块 OpenGL 或 Metal 画布,按钮、文字、滚动条全是框架自己画的。好处是一套代码在 Windows、Linux 上长得一样,代价是长相不完全是 Mac 的,字体也要框架自己找。
  • 放一个 WKWebView:Wails18 和 webview_go19。窗口里是系统自带的 WebKit 浏览器内核,界面用 HTML、CSS、JavaScript 写。更重的 Electron、CEF 一类(Go 里有 energy20、go-astilectron)是自己带一个 Chromium。
  • 只有菜单栏图标:fyne.io/systray21、energye/systray22 和更早的 getlantern/systray23,以及只为 Mac 菜单栏设计的 menuet24。它们只管 NSStatusItem 和下拉菜单,不管窗口。

开头那个问题“是不是 http server 出的页面”,在第三类上最容易混淆。Wails 和 webview_go 的界面确实是网页,但页面不是从本机端口拿的。Wails 给 WKWebView 注册了一个自定义协议 wails://,WebView 请求 wails://localhost/index.html 时,请求直接在进程内交给 Go 代码处理,嵌在二进制里的文件被原样返回25。我们的实测里,所有跑起来的方案用 lsof 查,都没有监听任何 TCP 端口。

同一个需求,挨个实测

为了能放在一起比,我们给每个库写了同一个小程序:菜单栏一个图标,菜单里“打开窗口”和“退出”,窗口里一行每秒更新的文字(只有图标的库省掉窗口,没有托盘 API 的库省掉图标)。全部用 go build -ldflags="-s -w" 编译,按图 4 的结构打成 .app,启动 8 秒后记录主进程的常驻内存和线程数、查监听端口,再截一张屏。

同一个菜单栏程序,12 种做法(macOS 15.7,Apple M2 Pro 虚拟机,Go 1.26)
方案窗口里是什么菜单栏图标二进制主进程内存监听端口
直接调 AppKit
cgo + Objective-CAppKit 控件自己写1.7 MB38 MB无
puregoAppKit 控件自己写2.3 MB41 MB无
darwinkitAppKit 控件有17.6 MB启动即崩—
框架自绘
Fyne自绘(OpenGL)有22.7 MB建不了窗口—
Gio自绘(Metal)无6.2 MB140 MB无
Cogent Core自绘无26.5 MB166 MB无
Qt(miqt)Qt 控件有29.5 MB+Qt 库95 MB无
WebView
Wails v3WKWebView有,可贴图标弹窗10.3 MB83 MB*无
webview_goWKWebView无2.0 MB69 MB*无
只有菜单栏图标
fyne.io/systray无窗口有2.5 MB28 MB无
energye/systray无窗口有,分左右键1.7 MB30 MB无
menuet无窗口(只有 Alert)有7.4 MB29 MB无

二进制用 -ldflags="-s -w" 编译。内存是启动 8 秒后主进程的 RSS;*WebView 的页面在 WebKit 的独立进程里渲染,那部分没有算进来。purego 那一行是在 Linux 上交叉编译、拷到 Mac 上跑的。

表 1 · 只要菜单栏图标的库最轻;自绘框架内存最高;所有方案都不开端口
cgo 手写 Objective-C 的窗口:系统标准标题栏,文字是 goroutine 计数 8
cgo + Objective-C:系统控件
Qt 的窗口:标题栏和系统一样,文字已运行 7 秒
Qt(miqt):Qt 按 Mac 风格自己画
Gio 的窗口:文字已运行 8 秒中的运字显示成一个方框
Gio:“运”字成了方框
Cogent Core 的窗口:Cogent Core 自绘的文字
Cogent Core:自绘
Wails 的无边框窗口贴着菜单栏图标弹出,页面地址显示 wails://localhost/index.html
Wails v3:贴着图标弹出的网页
webview_go 的窗口:页面地址 about:blank
webview_go:about:blank 上的网页
同一个需求在 macOS 15 上的样子。Fyne 和 darwinkit 没有截到,原因见下文。

几处值得单独说:

  • 自绘框架要自己找字体。 Gio 窗口里“已运行 8 秒”的“运”字显示成了一个方框,按钮、文字都是 Gio 自己排版、自己画的,字体回退没找到这个字形。同一台机器上用 AppKit 控件的方案,中文全部正常。
  • Fyne 在这台虚拟机上没能开出窗口。 程序一启动就退出,日志是 NSGL: Failed to find a suitable pixel format。Fyne 在 macOS 上仍然通过 GLFW 用 OpenGL 画界面,而 GitHub 的 macOS 虚拟机给不出可用的 OpenGL 像素格式;同样是 GPU 自绘、走 Metal 的 Gio 和 Cogent Core 都正常。我们手上没有 Mac 真机,Fyne 在真机上通常能正常开窗口,这一点是推断;这次能确定的只是,它比走 Metal 的框架多依赖了一样已经被 Apple 标为废弃的东西。
  • darwinkit 的例子启动就 SIGABRT。 我们的例子和 darwinkit 仓库自带的 menu 示例都一样,崩在 app.Run() 里,没有打印 Objective-C 异常。我们没有继续追,只记下它最后一次发版是 2024 年 6 月,在 macOS 15.7 上用要先自己验证。
  • Qt 最重。 要先 brew install qt,miqt 第一次编译花了 155 秒;二进制 29.5 MB 还只是 Go 这部分,Qt 的几个框架是动态链接的,发给别人时要一起打进 .app。
  • WebView 方案的内存要分开看。 Wails 和 webview_go 主进程只有七八十 MB,但页面是 WebKit 在独立的 WebContent 进程里渲染的,表里没有算上。

Gio 和 webview_go 都只管窗口,不管菜单栏图标。那再配一个 systray 库,不就两样都有了吗?

五、窗口和图标来自两个库

现象

最直接的写法,是把 systray.Run 放进 goroutine,主线程留给 webview:

Go
go systray.Run(onReady, nil) // 主线程要留给 webview,只好把 systray 放进 goroutine
w := webview.New(false)
w.Run()

在 Mac 上跑起来,菜单栏图标出来了,日志里也打印了 systray onReady;可是 webview.New 之后的那行日志始终没有出现,窗口一直没有弹出来,程序就卡在那里。

原因:事件循环只能有一个

回到第二节:主线程上只能跑一个事件循环。systray.Run 和 webview.Run 各自都想在主线程上调用 [NSApp run] 并一直占着它。把其中一个挪进 goroutine,它就跑在了别的线程上,违反了 AppKit 的主线程规定。

解决:让一个库挂到另一个库的循环上

fyne.io/systray 为这种情况留了一个口子:RunWithExternalLoop 不自己跑循环,只返回一个 start 函数,等别人的事件循环转起来以后,在主线程上调用它就行。webview_go 的 Dispatch 正好能把函数排到主线程上:

Go
start, _ := systray.RunWithExternalLoop(onReady, nil)
w.Dispatch(start) // 事件循环转起来以后,在主线程上执行
w.Run()

这样改完,窗口和菜单栏图标都出来了,点图标也能正常弹出菜单。

更省心的办法,是直接选窗口和图标都管的框架:Fyne 的 desktop.App 有 SetSystemTrayMenu26,Wails v3 有 SystemTray,还能用 AttachWindow 让一个无边框窗口贴着图标弹出,效果和 NSPopover 差不多;Qt 有 QSystemTrayIcon。

程序能跑了。可现在它还只是一个命令行可执行文件,怎么变成能放进“应用程序”文件夹的 .app?

六、打包成 .app

.app 只是一个目录

go build 产出的是一个裸的可执行文件。直接在终端里运行它,窗口和图标都会出来,但 Dock 里显示的名字是可执行文件名,没有自己的图标,Info.plist 里的设置(比如 LSUIElement)也无处可写。

.app 其实只是一个按约定组织的目录,Finder 把它显示成一个图标27。最小的结构只要两样东西:Contents/MacOS/ 下放可执行文件,Contents/Info.plist 里用 CFBundleExecutable 指向它。

.app 包的结构和 Info.plist 左边是 Demo.app 的目录:Contents 下有 Info.plist、MacOS/demo(go build 的产物)、Resources/AppIcon.icns,以及签名后才出现的 _CodeSignature/CodeResources。右边是 Info.plist 的关键字段:CFBundleExecutable 等于 demo,指向 MacOS 目录里的可执行文件;CFBundleIdentifier 是 net.twwy.demo;CFBundleIconFile 是 AppIcon;LSUIElement 为 true,不在 Dock 里出现;LSMinimumSystemVersion 是 12.0。 目录(Finder 里显示成一个图标) Contents/Info.plist Demo.app/ Contents/ Info.plist MacOS/demo go build 的产物 Resources/AppIcon.icns PkgInfo Contents/_CodeSignature/CodeResources 虚线:codesign 签名时才生成 键 值 CFBundleExecutable demo CFBundleIdentifier net.twwy.demo CFBundleIconFile AppIcon LSUIElement true 不进 Dock LSMinimumSystemVersion 12.0 CFBundlePackageType APPL NSHighResolutionCapable true
图 4 · .app 只是一个约定好结构的目录:Info.plist 里的 CFBundleExecutable 指向 go build 出来的那个文件

不想手写的话,Fyne 有 fyne package,Wails 有 wails3 package,都会生成这个目录、把 PNG 转成 .icns 图标;自己写一个 shell 脚本也就十几行。要同时支持 Apple 芯片和 Intel,分别用 GOARCH=arm64 和 GOARCH=amd64 编译,再用 lipo -create 合成一个通用二进制放进去。

七、签名和公证

在自己的机器上:已经签好了

Apple 芯片的 Mac 不运行没有签名的代码。好在 Go 的链接器在生成 darwin/arm64 二进制时会自动做一次 ad-hoc 签名,哪怕是在 Linux 上交叉编译的。拿第一节在 Linux 上编出来的文件到 Mac 上看:

$ codesign -dv menubar
Format=Mach-O thin (arm64)
CodeDirectory v=20400 size=17694 flags=0x20002(adhoc,linker-signed) hashes=550+0 location=embedded
Signature=adhoc
TeamIdentifier=not set

打成 .app 以后,Info.plist 也要纳入签名,再执行一次 codesign --force --deep -s - Demo.app 就行。这种签名只证明“文件没被改过”,不证明“是谁做的”。

发给别人:Gatekeeper 拦下来

从浏览器、聊天工具下载的文件会被打上 com.apple.quarantine 扩展属性,第一次打开时 Gatekeeper 会检查它是否经过 Apple 公证。只有 ad-hoc 签名的 App 过不了这一关:

$ spctl -a -vv -t exec Demo.app
Demo.app: rejected
对话框:“Downloaded” Not Opened。Apple could not verify “Downloaded” is free of malware that may harm your Mac or compromise your privacy. 两个按钮:Done 和 Move to Trash
给 ad-hoc 签名的 App 加上隔离属性,模拟从网上下载,再双击打开。

对话框里只有“完成”和“移到废纸篓”两个按钮,没有“打开”。

macOS 15 以前,用户还可以按住 Control 点“打开”绕过去;从 macOS 15 起这条路取消了,只能到“系统设置 → 隐私与安全性”里找到被拦的 App,点“仍要打开”再输密码28。自己用无所谓,发给别人就很劝退。

正路:Developer ID 签名加公证

要让别人双击就能打开,需要加入 Apple Developer Program(每年 99 美元)拿到 Developer ID 证书,然后三步29:

Terminal window
codesign --force --options runtime --timestamp \
-s "Developer ID Application: 你的名字 (TEAMID)" Demo.app # 开启 hardened runtime 签名
ditto -c -k --keepParent Demo.app Demo.zip
xcrun notarytool submit Demo.zip --keychain-profile notary --wait # 提交公证,等结果
xcrun stapler staple Demo.app # 把公证票据钉到 App 上

公证是 Apple 的服务器扫描一遍二进制,通过后发一张票据;staple 把票据存进 App 里,用户离线打开时也能校验30。这几步本身不挑语言,Go 程序和 Swift 程序走的流程一样。

如果连签名也不想在 Mac 上做,rcodesign 是一个独立实现,能在 Linux 上完成签名、提交公证和 staple31。配合 purego,一个 Mac 菜单栏程序从编译到公证可以全部在 Linux 的 CI 里完成。

八、怎么选

回到开头的需求:一个 Go 写的工具,要有原生窗口,右上角菜单栏能点开。按要做的事情从轻到重:

  1. 只要一个菜单栏图标和下拉菜单,用 fyne.io/systray 或 energye/systray。两三十行代码,二进制 2 MB 左右,常驻内存不到 30 MB。
  2. 菜单栏图标加一个小面板或简单窗口,只在 Mac 上用,直接调 AppKit。想在 Linux CI 里交叉编译就用 purego;喜欢有类型检查、照抄 Apple 文档就用 cgo 加一个 .m 文件。
  3. 界面复杂一些,还要跑在 Windows 和 Linux 上,用 Fyne:窗口和托盘都有,fyne package 一条命令打包。Gio 更轻、更快,但没有托盘,要按第五节的办法自己接 systray。
  4. 团队里有前端,界面想用 HTML 写,用 Wails v3:窗口、托盘、贴着图标弹出的面板都有,页面走 wails:// 协议,不开端口。代价是每个窗口背后有 WebKit 的渲染进程。
  5. 想要 Qt 那一整套控件,用 miqt,要先装 Qt,首次编译很慢。

小结

  1. Go 标准库没有窗口,Mac 上的窗口归 AppKit 管 → AppKit 的调用最终都是 objc_msgSend,Go 能调 C 函数就能用它。
  2. 从 Go 到 objc_msgSend 有两座桥 → cgo 在编译期要 clang 和 SDK,只能在 Mac 上编;purego 在运行时调用,Linux 上也能交叉编译。
  3. 为什么要在 init 里 LockOSThread → AppKit 只认主线程,goroutine 却会在线程间迁移;锁住以后 main 一直在主线程上跑事件循环,其他 goroutine 改界面要投递回主线程。
  4. 右上角的图标是什么 → NSStatusItem,挂菜单或弹 NSPopover;用模板图适配深浅色;accessory 激活策略或 LSUIElement 去掉 Dock 图标。
  5. 现成的库差在哪 → 窗口都是 NSWindow,里面的内容分别是系统控件、框架自绘的画布、WKWebView 网页;Wails 和 webview 是网页但不开端口。
  6. 两个库各管窗口和图标为什么不行 → 主线程上只能有一个事件循环,要用 RunWithExternalLoop 一类的接口挂到同一个循环上。
  7. 怎么交给别人用 → 打成 .app,用 Developer ID 签名并公证;只有 ad-hoc 签名的 App 会被 Gatekeeper 拦下。

附录:一个完整的菜单栏程序

下面就是第三节截图里的程序,不用 cgo,在任何系统上 CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build 都能编译。点菜单栏图标弹出面板,面板里的计时由 goroutine 每秒更新。

Go
//go:build darwin
// 点一下右上角的图标,在图标正下方弹出一个面板(NSPopover),再点一下收起。
// 不用 cgo:GOOS=darwin CGO_ENABLED=0 go build 在 Linux 上也能编。
package main
import (
"fmt"
"runtime"
"time"
"github.com/ebitengine/purego"
"github.com/ebitengine/purego/objc"
)
func init() { runtime.LockOSThread() }
type NSRect struct{ X, Y, W, H float64 }
type NSSize struct{ W, H float64 }
var (
sel = objc.RegisterName
cls = objc.GetClass
)
func nsstr(s string) objc.ID {
return objc.ID(cls("NSString")).Send(sel("stringWithUTF8String:"), s)
}
func main() {
purego.Dlopen("/System/Library/Frameworks/AppKit.framework/AppKit", purego.RTLD_GLOBAL|purego.RTLD_NOW)
app := objc.ID(cls("NSApplication")).Send(sel("sharedApplication"))
app.Send(sel("setActivationPolicy:"), 1) // Accessory:没有 Dock 图标
// 面板里的内容:一个标签 + 一个退出按钮
view := objc.ID(cls("NSView")).Send(sel("alloc")).Send(sel("initWithFrame:"), NSRect{0, 0, 280, 120})
label := objc.ID(cls("NSTextField")).Send(sel("labelWithString:"), nsstr("已运行 0 秒"))
label.Send(sel("setFrame:"), NSRect{20, 64, 240, 24})
view.Send(sel("addSubview:"), label)
quit := objc.ID(cls("NSButton")).Send(sel("buttonWithTitle:target:action:"), nsstr("退出"), app, sel("terminate:"))
quit.Send(sel("setFrame:"), NSRect{20, 16, 80, 32})
view.Send(sel("addSubview:"), quit)
vc := objc.ID(cls("NSViewController")).Send(sel("new"))
vc.Send(sel("setView:"), view)
pop := objc.ID(cls("NSPopover")).Send(sel("new"))
pop.Send(sel("setContentViewController:"), vc)
pop.Send(sel("setContentSize:"), NSSize{280, 120})
pop.Send(sel("setBehavior:"), 1) // Transient:点别处自动收起
item := objc.ID(cls("NSStatusBar")).Send(sel("systemStatusBar")).Send(sel("statusItemWithLength:"), -1.0)
button := item.Send(sel("button"))
button.Send(sel("setImage:"), objc.ID(cls("NSImage")).Send(
sel("imageWithSystemSymbolName:accessibilityDescription:"), nsstr("gauge.with.needle"), nsstr("Go")))
toggle := func() {
if objc.Send[bool](pop, sel("isShown")) {
pop.Send(sel("performClose:"), objc.ID(0))
return
}
bounds := objc.Send[NSRect](button, sel("bounds"))
pop.Send(sel("showRelativeToRect:ofView:preferredEdge:"), bounds, button, uint(1)) // 1 = NSRectEdgeMinY,图标下方
app.Send(sel("activateIgnoringOtherApps:"), true)
}
handler, err := objc.RegisterClass("GoStatusHandler", cls("NSObject"), nil, nil, []objc.MethodDef{
{Cmd: sel("toggle:"), Fn: func(_ objc.ID, _ objc.SEL, _ objc.ID) { toggle() }},
})
if err != nil {
panic(err)
}
target := objc.ID(handler).Send(sel("new"))
button.Send(sel("setTarget:"), target)
button.Send(sel("setAction:"), sel("toggle:"))
start := time.Now()
go func() {
for range time.Tick(time.Second) {
s := nsstr(fmt.Sprintf("已运行 %d 秒", int(time.Since(start).Seconds())))
label.Send(sel("performSelectorOnMainThread:withObject:waitUntilDone:"), sel("setStringValue:"), s, false)
}
}()
app.Send(sel("run"))
}

编译、打包、签名:

Terminal window
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -o menubar .
mkdir -p Menubar.app/Contents/MacOS && cp menubar Menubar.app/Contents/MacOS/
# 再按图 4 写好 Contents/Info.plist(CFBundleExecutable 填 menubar,LSUIElement 填 true)
codesign --force --deep -s - Menubar.app # 在 Mac 上 ad-hoc 签名;在 Linux 上可以用 rcodesign

参考资料

Footnotes

  1. AppKitApple Developerdeveloper.apple.com ↗macOS 上窗口、菜单、控件和事件循环所在的框架,Objective-C 接口。https://developer.apple.com/documentation/appkit ↩

  2. objc_msgSendApple Developerdeveloper.apple.com ↗Objective-C 的方法调用在运行时都编译成对它的调用:给对象发一条以选择子(selector)命名的消息。https://developer.apple.com/documentation/objectivec/1456712-objc_msgsend ↩

  3. cgoGopkg.go.dev ↗#cgo CFLAGS: -x objective-c 可以让 cgo 用 clang 编译 Objective-C;CGO_ENABLED=0 时带 import "C" 的文件全部不参与编译。https://pkg.go.dev/cmd/cgo ↩

  4. ebitengine/puregoGitHubgithub.com ↗不用 cgo 从 Go 里调用 C 函数;purego/objc 子包封装了 objc_msgSend、RegisterClass 等运行时函数。文中用 v0.11.2。https://github.com/ebitengine/purego ↩

  5. Main Thread CheckerApple Developerdeveloper.apple.com ↗AppKit、UIKit 的界面对象只能在主线程上使用;在后台线程更新界面会导致界面不刷新、显示错乱甚至崩溃。https://developer.apple.com/documentation/xcode/diagnosing-memory-thread-and-crash-issues-early#Detect-improper-UI-updates-on-background-threads ↩

  6. runtime.LockOSThreadGopkg.go.dev ↗“All init functions are run on the startup thread. Calling LockOSThread from an init function will cause the main function to be invoked on that thread.”https://pkg.go.dev/runtime#LockOSThread ↩

  7. performSelectorOnMainThread:withObject:waitUntilDone:Apple Developerdeveloper.apple.com ↗把一条消息排进主线程的 run loop,由主线程执行。https://developer.apple.com/documentation/objectivec/nsobject/1414900-performselectoronmainthread ↩

  8. NSStatusItemApple Developerdeveloper.apple.com ↗菜单栏右侧的图标。由 [NSStatusBar systemStatusBar] statusItemWithLength: 创建,button 属性可以设图片、标题和点击动作,menu 属性设下拉菜单。https://developer.apple.com/documentation/appkit/nsstatusitem ↩

  9. NSImage.isTemplateApple Developerdeveloper.apple.com ↗模板图只取 alpha 通道,系统按当前外观(浅色、深色、高亮)给它上色。菜单栏图标应该用模板图。https://developer.apple.com/documentation/appkit/nsimage/istemplate ↩

  10. NSPopoverApple Developerdeveloper.apple.com ↗带小箭头的弹出面板;showRelativeToRect:ofView:preferredEdge: 让它贴着某个视图弹出,NSPopoverBehaviorTransient 表示点别处自动收起。https://developer.apple.com/documentation/appkit/nspopover ↩

  11. NSApplication.ActivationPolicyApple Developerdeveloper.apple.com ↗regular:普通 App,有 Dock 图标和菜单;accessory:没有 Dock 图标,也不显示自己的菜单栏,可以有窗口。https://developer.apple.com/documentation/appkit/nsapplication/activationpolicy ↩

  12. LSUIElementApple Developerdeveloper.apple.com ↗Info.plist 键,为 true 时这个 App 是 agent app:在后台运行,不出现在 Dock 里。https://developer.apple.com/documentation/bundleresources/information-property-list/lsuielement ↩

  13. progrium/darwinkitGitHubgithub.com ↗原名 macdriver,从 Apple SDK 生成的 AppKit、Foundation、WebKit 等 Go 绑定,底层是 cgo + libffi。文中用 v0.5.0(2024-06)。https://github.com/progrium/darwinkit ↩

  14. Fynefyne.iofyne.io ↗跨平台 GUI 框架,GLFW 开窗口、OpenGL 画控件;桌面端用 desktop.App 的 SetSystemTrayMenu 做菜单栏图标。文中用 v2.8.2。https://fyne.io/ ↩

  15. Giogioui.orggioui.org ↗即时模式 GUI,macOS 上用 Metal 绘制。文中用 v0.10.3。https://gioui.org/ ↩

  16. Cogent Corecogentcore.orgcogentcore.org ↗纯 Go 的自绘 GUI 框架。文中用 v0.5.0,要求 Go 1.27。https://www.cogentcore.org/core/ ↩

  17. mappu/miqtGitHubgithub.com ↗Qt 5 / Qt 6 的 Go 绑定,MIT 许可。QSystemTrayIcon 在 macOS 上就是菜单栏图标。文中用 v0.15.0 + Homebrew 的 Qt 6.11.2。https://github.com/mappu/miqt ↩

  18. Wails v3wails.iov3alpha.wails.io ↗用系统 WebView(macOS 上是 WKWebView)做界面,v3 起支持多窗口和系统托盘(SystemTray、AttachWindow)。文中用 v3.0.0-beta.34。https://v3alpha.wails.io/ ↩

  19. webview/webview_goGitHubgithub.com ↗webview 库的 Go 绑定,一个窗口一个 WebView,没有托盘 API;Dispatch 把函数排到主线程执行。https://github.com/webview/webview_go ↩

  20. energye/energyGitHubgithub.com ↗Go + CEF(Chromium Embedded Framework),要随程序带一个 Chromium,界面是网页。https://github.com/energye/energy ↩

  21. fyne-io/systrayGitHubgithub.com ↗getlantern/systray 的分支,Fyne 也在用。Run 自己跑事件循环,RunWithExternalLoop 挂到别人的事件循环上。文中用 v1.12.2。https://github.com/fyne-io/systray ↩

  22. energye/systrayGitHubgithub.com ↗另一个 getlantern/systray 分支,能分别处理左键、右键点击。文中用 v1.0.3。https://github.com/energye/systray ↩

  23. getlantern/systrayGitHubgithub.com ↗这一系托盘库的源头,最后一次发版是 2023 年的 v1.2.2。https://github.com/getlantern/systray ↩

  24. caseymrm/menuetGitHubgithub.com ↗只为 macOS 菜单栏程序设计的小库:菜单栏文字、下拉菜单、Alert、通知。https://github.com/caseymrm/menuet ↩

  25. Wails v3 源码:webview_window_darwin.goGitHubgithub.com ↗[config setURLSchemeHandler:delegate forURLScheme:@"wails"]:页面请求由 WKURLSchemeHandler 交给进程内的 Go 代码处理,不经过 TCP。https://github.com/wailsapp/wails/blob/v3-alpha/v3/pkg/application/webview_window_darwin.go ↩

  26. Fyne:System Tray Menufyne.iodocs.fyne.io ↗官方文档里的系统托盘示例,macOS 上就是菜单栏图标。https://docs.fyne.io/explore/systray ↩

  27. Bundle StructuresApple Developerdeveloper.apple.com ↗macOS App 包的标准结构:Contents/Info.plist、Contents/MacOS/、Contents/Resources/。https://developer.apple.com/library/archive/documentation/CoreFoundation/Conceptual/CFBundles/BundleTypes/BundleTypes.html ↩

  28. What's changed in runtime protection for macOS SequoiaAppleInsiderappleinsider.com ↗macOS 15 起,不能再按住 Control 点“打开”绕过 Gatekeeper,要到“系统设置 → 隐私与安全性”里点“仍要打开”。https://appleinsider.com/inside/macos-sequoia/tips/whats-changed-in-runtime-protection-for-macos-sequoia ↩

  29. Notarizing macOS software before distributionApple Developerdeveloper.apple.com ↗用 Developer ID 证书签名并开启 hardened runtime,提交给 Apple 公证服务,通过后用 stapler 把公证票据钉到 App 上。https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution ↩

  30. Customizing the notarization workflowApple Developerdeveloper.apple.com ↗xcrun notarytool submit … --wait、xcrun stapler staple;旧的 altool 从 2023-11-01 起不再被公证服务接受。https://developer.apple.com/documentation/security/customizing-the-notarization-workflow ↩

  31. apple-codesign(rcodesign):Notarizing and StaplingGregory Szorcgregoryszorc.com ↗Rust 写的独立实现,能在 Linux、Windows 上给 Mach-O 和 .app 签名、提交公证、staple,不需要 Mac。https://gregoryszorc.com/docs/apple-codesign/main/apple_codesign_rcodesign_notarizing.html ↩

延伸阅读