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

參考資料

參考資料

  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 ↩

延伸閱讀