用 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 調:
/*#cgo CFLAGS: -x objective-c -fobjc-arc#cgo LDFLAGS: -framework Cocoavoid 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 就是:
bar := objc.ID(objc.GetClass("NSStatusBar")).Send(objc.RegisterName("systemStatusBar"))既然編譯期不需要 clang,也就不需要 Mac。我們在 Linux 容器裡直接編譯:
$ CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -o menubar .$ file menubarmenubar: Mach-O 64-bit arm64 executable, flags:<|DYLDLINK|PIE>把這個 2.2 MB 的檔案拷到 Mac 上,視窗和選單欄圖示都正常出來了(第三節的截圖就是它)。用 otool -L 看,它連結的只有 libSystem 和 libresolv,AppKit 是啟動後才 dlopen 進來的。
兩條路的差別畫在圖 1 裡:呼叫最後都落到同一個 objc_msgSend,進入同一個 AppKit。cgo 的膠水程式碼寫成 Objective-C,讀起來更自然,型別也由編譯器檢查;purego 的膠水寫成 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 func() { for i := 1; ; i++ { time.Sleep(time.Second) label.Send(sel("performSelectorOnMainThread:withObject:waitUntilDone:"), sel("setStringValue:"), nsstr(fmt.Sprintf("goroutine 计数:%d", i)), false) }}()各個庫都替我們做了這件事,只是名字不同:cgo 裡寫 dispatch_async(dispatch_get_main_queue(), …),darwinkit 是 dispatch.MainQueue().DispatchAsync,Fyne 是 fyne.Do,Gio 是 w.Invalidate() 之後在事件迴圈裡重畫,webview 是 w.Dispatch。
主執行緒和視窗都有了,右上角那個圖示又是怎麼來的?
三、選單欄圖示
NSStatusItem
選單欄右側那一排圖示,每一個都是一個 NSStatusItem8。向系統狀態列要一個位置,給它的按鈕設一張圖,再掛一個選單,就是最基本的選單欄程式:
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,點一下就彈出標準的下拉選單:
想要一塊能放任意內容的面板,就不掛選單,而是給按鈕設一個點選動作,在動作裡彈出 NSPopover10,並讓它貼著圖示的下邊緣:
bounds := objc.Send[NSRect](button, sel("bounds"))pop.Send(sel("showRelativeToRect:ofView:preferredEdge:"), bounds, button, uint(1)) // 1:图标下方點選動作要有一個 Objective-C 物件來接。purego 可以在執行時註冊一個新的 Objective-C 類,方法體就是一個 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:"))
不要 Dock 圖示
選單欄程式一般不希望在 Dock 裡也佔一個位置,也不希望自己被切到前臺時把螢幕左上角的選單換成自己的。AppKit 管這叫啟用策略(activation policy):regular 是普通 App,accessory 沒有 Dock 圖示也不接管選單欄,但仍然可以有視窗11。可以在程式碼裡設:
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;區別在裡面那塊內容。
- 用 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 秒後記錄主程序的常駐記憶體和執行緒數、查監聽埠,再截一張屏。
| 方案 | 視窗裡是什麼 | 選單欄圖示 | 二進位制 | 主程序記憶體 | 監聽埠 |
|---|---|---|---|---|---|
| 直接調 AppKit | |||||
| cgo + Objective-C | AppKit 控制元件 | 自己寫 | 1.7 MB | 38 MB | 無 |
| purego | AppKit 控制元件 | 自己寫 | 2.3 MB | 41 MB | 無 |
| darwinkit | AppKit 控制元件 | 有 | 17.6 MB | 啟動即崩 | — |
| 框架自繪 | |||||
| Fyne | 自繪(OpenGL) | 有 | 22.7 MB | 建不了視窗 | — |
| Gio | 自繪(Metal) | 無 | 6.2 MB | 140 MB | 無 |
| Cogent Core | 自繪 | 無 | 26.5 MB | 166 MB | 無 |
| Qt(miqt) | Qt 控制元件 | 有 | 29.5 MB+Qt 庫 | 95 MB | 無 |
| WebView | |||||
| Wails v3 | WKWebView | 有,可貼圖示彈窗 | 10.3 MB | 83 MB* | 無 |
| webview_go | WKWebView | 無 | 2.0 MB | 69 MB* | 無 |
| 只有選單欄圖示 | |||||
| fyne.io/systray | 無視窗 | 有 | 2.5 MB | 28 MB | 無 |
| energye/systray | 無視窗 | 有,分左右鍵 | 1.7 MB | 30 MB | 無 |
| menuet | 無視窗(只有 Alert) | 有 | 7.4 MB | 29 MB | 無 |
二進位制用 -ldflags="-s -w" 編譯。記憶體是啟動 8 秒後主程序的 RSS;*WebView 的頁面在 WebKit 的獨立程序裡渲染,那部分沒有算進來。purego 那一行是在 Linux 上交叉編譯、拷到 Mac 上跑的。






幾處值得單獨說:
- 自繪框架要自己找字型。 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 systray.Run(onReady, nil) // 主线程要留给 webview,只好把 systray 放进 goroutinew := 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 正好能把函式排到主執行緒上:
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 指向它。
不想手寫的話,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 menubarFormat=Mach-O thin (arm64)CodeDirectory v=20400 size=17694 flags=0x20002(adhoc,linker-signed) hashes=550+0 location=embeddedSignature=adhocTeamIdentifier=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.appDemo.app: rejected
對話方塊裡只有“完成”和“移到廢紙簍”兩個按鈕,沒有“開啟”。
macOS 15 以前,使用者還可以按住 Control 點“開啟”繞過去;從 macOS 15 起這條路取消了,只能到“系統設定 → 隱私與安全性”裡找到被攔的 App,點“仍要開啟”再輸密碼28。自己用無所謂,發給別人就很勸退。
正路:Developer ID 簽名加公證
要讓別人雙擊就能開啟,需要加入 Apple Developer Program(每年 99 美元)拿到 Developer ID 證書,然後三步29:
codesign --force --options runtime --timestamp \ -s "Developer ID Application: 你的名字 (TEAMID)" Demo.app # 开启 hardened runtime 签名ditto -c -k --keepParent Demo.app Demo.zipxcrun 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 寫的工具,要有原生視窗,右上角選單欄能點開。按要做的事情從輕到重:
- 只要一個選單欄圖示和下拉選單,用 fyne.io/systray 或 energye/systray。兩三十行程式碼,二進位制 2 MB 左右,常駐記憶體不到 30 MB。
- 選單欄圖示加一個小面板或簡單視窗,只在 Mac 上用,直接調 AppKit。想在 Linux CI 裡交叉編譯就用 purego;喜歡有型別檢查、照抄 Apple 文件就用 cgo 加一個
.m檔案。 - 介面複雜一些,還要跑在 Windows 和 Linux 上,用 Fyne:視窗和托盤都有,
fyne package一條命令打包。Gio 更輕、更快,但沒有托盤,要按第五節的辦法自己接 systray。 - 團隊裡有前端,介面想用 HTML 寫,用 Wails v3:視窗、托盤、貼著圖示彈出的面板都有,頁面走
wails://協議,不開埠。代價是每個視窗背後有 WebKit 的渲染程序。 - 想要 Qt 那一整套控制元件,用 miqt,要先裝 Qt,首次編譯很慢。
小結
- Go 標準庫沒有視窗,Mac 上的視窗歸 AppKit 管 → AppKit 的呼叫最終都是
objc_msgSend,Go 能調 C 函式就能用它。 - 從 Go 到
objc_msgSend有兩座橋 → cgo 在編譯期要 clang 和 SDK,只能在 Mac 上編;purego 在執行時呼叫,Linux 上也能交叉編譯。 - 為什麼要在
init裡LockOSThread→ AppKit 只認主執行緒,goroutine 卻會線上程間遷移;鎖住以後main一直在主執行緒上跑事件迴圈,其他 goroutine 改介面要投遞迴主執行緒。 - 右上角的圖示是什麼 →
NSStatusItem,掛選單或彈NSPopover;用模板圖適配深淺色;accessory啟用策略或LSUIElement去掉 Dock 圖示。 - 現成的庫差在哪 → 視窗都是
NSWindow,裡面的內容分別是系統控制元件、框架自繪的畫布、WKWebView 網頁;Wails 和 webview 是網頁但不開埠。 - 兩個庫各管視窗和圖示為什麼不行 → 主執行緒上只能有一個事件迴圈,要用
RunWithExternalLoop一類的介面掛到同一個迴圈上。 - 怎麼交給別人用 → 打成
.app,用 Developer ID 簽名並公證;只有 ad-hoc 簽名的 App 會被 Gatekeeper 攔下。
附錄:一個完整的選單欄程式
下面就是第三節截圖裡的程式,不用 cgo,在任何系統上 CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build 都能編譯。點選單欄圖示彈出面板,面板裡的計時由 goroutine 每秒更新。
//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"))}編譯、打包、簽名:
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參考資料
參考資料
-
AppKitdeveloper.apple.com ↗macOS 上視窗、選單、控制元件和事件迴圈所在的框架,Objective-C 介面。https://developer.apple.com/documentation/appkit ↩
-
objc_msgSenddeveloper.apple.com ↗Objective-C 的方法呼叫在執行時都編譯成對它的呼叫:給物件發一條以選擇子(selector)命名的訊息。https://developer.apple.com/documentation/objectivec/1456712-objc_msgsend ↩
-
cgopkg.go.dev ↗
#cgo CFLAGS: -x objective-c可以讓 cgo 用 clang 編譯 Objective-C;CGO_ENABLED=0時帶import "C"的檔案全部不參與編譯。https://pkg.go.dev/cmd/cgo ↩ -
ebitengine/puregogithub.com ↗不用 cgo 從 Go 裡呼叫 C 函式;
purego/objc子包封裝了objc_msgSend、RegisterClass等執行時函式。文中用 v0.11.2。https://github.com/ebitengine/purego ↩ -
Main Thread Checkerdeveloper.apple.com ↗AppKit、UIKit 的介面物件只能在主執行緒上使用;在後臺執行緒更新介面會導致介面不重新整理、顯示錯亂甚至崩潰。https://developer.apple.com/documentation/xcode/diagnosing-memory-thread-and-crash-issues-early#Detect-improper-UI-updates-on-background-threads ↩
-
runtime.LockOSThreadpkg.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 ↩
-
performSelectorOnMainThread:withObject:waitUntilDone:developer.apple.com ↗把一條訊息排進主執行緒的 run loop,由主執行緒執行。https://developer.apple.com/documentation/objectivec/nsobject/1414900-performselectoronmainthread ↩
-
NSStatusItemdeveloper.apple.com ↗選單欄右側的圖示。由
[NSStatusBar systemStatusBar] statusItemWithLength:建立,button屬性可以設圖片、標題和點選動作,menu屬性設下拉選單。https://developer.apple.com/documentation/appkit/nsstatusitem ↩ -
NSImage.isTemplatedeveloper.apple.com ↗模板圖只取 alpha 通道,系統按當前外觀(淺色、深色、高亮)給它上色。選單欄圖示應該用模板圖。https://developer.apple.com/documentation/appkit/nsimage/istemplate ↩
-
NSPopoverdeveloper.apple.com ↗帶小箭頭的彈出面板;
showRelativeToRect:ofView:preferredEdge:讓它貼著某個檢視彈出,NSPopoverBehaviorTransient表示點別處自動收起。https://developer.apple.com/documentation/appkit/nspopover ↩ -
NSApplication.ActivationPolicydeveloper.apple.com ↗
regular:普通 App,有 Dock 圖示和選單;accessory:沒有 Dock 圖示,也不顯示自己的選單欄,可以有視窗。https://developer.apple.com/documentation/appkit/nsapplication/activationpolicy ↩ -
LSUIElementdeveloper.apple.com ↗Info.plist 鍵,為 true 時這個 App 是 agent app:在後臺執行,不出現在 Dock 裡。https://developer.apple.com/documentation/bundleresources/information-property-list/lsuielement ↩
-
progrium/darwinkitgithub.com ↗原名 macdriver,從 Apple SDK 生成的 AppKit、Foundation、WebKit 等 Go 繫結,底層是 cgo + libffi。文中用 v0.5.0(2024-06)。https://github.com/progrium/darwinkit ↩
-
Fynefyne.io ↗跨平臺 GUI 框架,GLFW 開視窗、OpenGL 畫控制元件;桌面端用
desktop.App的SetSystemTrayMenu做選單欄圖示。文中用 v2.8.2。https://fyne.io/ ↩ -
Giogioui.org ↗即時模式 GUI,macOS 上用 Metal 繪製。文中用 v0.10.3。https://gioui.org/ ↩
-
Cogent Corecogentcore.org ↗純 Go 的自繪 GUI 框架。文中用 v0.5.0,要求 Go 1.27。https://www.cogentcore.org/core/ ↩
-
mappu/miqtgithub.com ↗Qt 5 / Qt 6 的 Go 繫結,MIT 許可。
QSystemTrayIcon在 macOS 上就是選單欄圖示。文中用 v0.15.0 + Homebrew 的 Qt 6.11.2。https://github.com/mappu/miqt ↩ -
Wails v3v3alpha.wails.io ↗用系統 WebView(macOS 上是 WKWebView)做介面,v3 起支援多視窗和系統托盤(
SystemTray、AttachWindow)。文中用 v3.0.0-beta.34。https://v3alpha.wails.io/ ↩ -
webview/webview_gogithub.com ↗webview 庫的 Go 繫結,一個視窗一個 WebView,沒有托盤 API;
Dispatch把函式排到主執行緒執行。https://github.com/webview/webview_go ↩ -
energye/energygithub.com ↗Go + CEF(Chromium Embedded Framework),要隨程式帶一個 Chromium,介面是網頁。https://github.com/energye/energy ↩
-
fyne-io/systraygithub.com ↗getlantern/systray 的分支,Fyne 也在用。
Run自己跑事件迴圈,RunWithExternalLoop掛到別人的事件迴圈上。文中用 v1.12.2。https://github.com/fyne-io/systray ↩ -
energye/systraygithub.com ↗另一個 getlantern/systray 分支,能分別處理左鍵、右鍵點選。文中用 v1.0.3。https://github.com/energye/systray ↩
-
getlantern/systraygithub.com ↗這一系托盤庫的源頭,最後一次發版是 2023 年的 v1.2.2。https://github.com/getlantern/systray ↩
-
Wails v3 原始碼:webview_window_darwin.gogithub.com ↗
[config setURLSchemeHandler:delegate forURLScheme:@"wails"]:頁面請求由 WKURLSchemeHandler 交給程序內的 Go 程式碼處理,不經過 TCP。https://github.com/wailsapp/wails/blob/v3-alpha/v3/pkg/application/webview_window_darwin.go ↩ -
Fyne:System Tray Menudocs.fyne.io ↗官方文件裡的系統托盤示例,macOS 上就是選單欄圖示。https://docs.fyne.io/explore/systray ↩
-
Bundle Structuresdeveloper.apple.com ↗macOS App 包的標準結構:
Contents/Info.plist、Contents/MacOS/、Contents/Resources/。https://developer.apple.com/library/archive/documentation/CoreFoundation/Conceptual/CFBundles/BundleTypes/BundleTypes.html ↩ -
What's changed in runtime protection for macOS Sequoiaappleinsider.com ↗macOS 15 起,不能再按住 Control 點“開啟”繞過 Gatekeeper,要到“系統設定 → 隱私與安全性”裡點“仍要開啟”。https://appleinsider.com/inside/macos-sequoia/tips/whats-changed-in-runtime-protection-for-macos-sequoia ↩
-
Notarizing macOS software before distributiondeveloper.apple.com ↗用 Developer ID 證書籤名並開啟 hardened runtime,提交給 Apple 公證服務,透過後用 stapler 把公證票據釘到 App 上。https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution ↩
-
Customizing the notarization workflowdeveloper.apple.com ↗
xcrun notarytool submit … --wait、xcrun stapler staple;舊的 altool 從 2023-11-01 起不再被公證服務接受。https://developer.apple.com/documentation/security/customizing-the-notarization-workflow ↩ -
apple-codesign(rcodesign):Notarizing and Staplinggregoryszorc.com ↗Rust 寫的獨立實現,能在 Linux、Windows 上給 Mach-O 和 .app 簽名、提交公證、staple,不需要 Mac。https://gregoryszorc.com/docs/apple-codesign/main/apple_codesign_rcodesign_notarizing.html ↩