t194 四修:托盤改用原生 NSStatusItem——energye/systray 在 Wails 之下根本不建托盤
leo:右鍵仍然沒用。這次先做**最小重現**才動手,結果推翻了我前兩次的判斷。 ## 真兇(實測,非推論) systray.go:83 的 setInternalLoop(true) **只在 systray.Run() 裡呼叫**; RunWithExternalLoop 沒有 ⇒ registerSystray() 第一行就 `if (!internalLoop) return;` ⇒ **delegate 從沒建立** ⇒ onReady 不會被呼叫、enable_on_click 不會執行、左右鍵事件根本不存在。 最小重現(scratchpad/traytest):RunWithExternalLoop + onReady 印 "READY" ⇒ **READY 從未印出**。托盤自始至終沒被建立過。 (左鍵之所以「第一次能開」是 Wails 自己開的窗,與托盤無關。) ## 正解:原生 NSStatusItem(tray_darwin.m + tray_darwin.go) 左鍵=開視窗;右鍵=暫時掛選單、performClick、再拿掉(不常駐 setMenu:, 否則按鈕 action 不會被呼叫、左鍵也會變成彈選單——與 systray 那個坑同源)。 ## 過程中踩到、也寫進閘的三個坑 ① dispatch_async(main_queue) **無效**:Wails 佔住主執行緒後不跑標準 run loop, 排進去的 block 永遠不執行(log 只印到 "dispatching…")⇒ 改 performSelectorOnMainThread ② NSStatusBar 需要 NSApp 已初始化:在 wails.Run() 之前呼叫 ⇒ 靜默失敗、icon 不出現 ③ 🔴 **我一度對著 `go build` 產的 stub 除錯**——沒帶 Wails build tags 的執行檔 一跑就印 "Wails applications will not build without the correct build tags" 並退出, 我卻以為是「托盤沒建起來」,白繞一圈。**驗 Wails App 一定要用 wails build 的產物。** ## 實測證據(AppleScript 從 UI 層查,最貼近使用者看到的) menu bar 數=2,status item 數=1 ← 選單列 icon 真的存在 collector 同步啟動、正常結束無孤兒 三支機械閘全過(閘也改成驗原生實作:不可 dispatch_async/必須 performSelectorOnMainThread/不可常駐 setMenu/不可再依賴 energye/systray)。 ⚠️ 仍未驗:左右鍵的**實際點擊行為**要 leo 手動點。
This commit is contained in:
@@ -46,24 +46,30 @@ cols={tuple(rows[y][x*4:x*4+3]) for y in range(h) for x in range(w) if rows[y][x
|
||||
print(f" ✅ template icon:{w}x{h}、不透明 {100*opq//len(al)}%、顏色 {len(cols)} 種 {list(cols)[:1]}")
|
||||
PY
|
||||
|
||||
# ② 呼叫順序:AddMenuItem → SetMenuNil → SetOnClick/SetOnRClick
|
||||
python3 - <<'PY' || exit 1
|
||||
# ② 原生托盤:三個必要條件(每一個都是實測踩過的坑)
|
||||
python3 - <<'PY2' || exit 1
|
||||
import sys
|
||||
s=open('main.go',encoding='utf-8').read()
|
||||
try:
|
||||
a=s.index('systray.AddMenuItem'); n=s.index('systray.SetMenuNil')
|
||||
c=s.index('systray.SetOnClick')
|
||||
except ValueError as e:
|
||||
print(f" ❌ 找不到必要呼叫:{e}"); sys.exit(1)
|
||||
if not (a < n < c):
|
||||
print(" ❌ 順序錯:必須 AddMenuItem → SetMenuNil → SetOnClick"); sys.exit(1)
|
||||
print(" ✅ 順序正確(建選單→清掉→註冊左鍵)")
|
||||
# 🔴 右鍵**不可以**註冊 handler:systray_on_rclick() 在沒註冊時會自己 show_menu(),
|
||||
# 註冊了反而要自己重做一遍(leo 實測「右鍵沒跳出 quit」就是這樣來的)。
|
||||
if 'systray.SetOnRClick' in s:
|
||||
print(" ❌ 註冊了 SetOnRClick ⇒ 蓋掉函式庫預設的『右鍵開選單』,右鍵會沒反應"); sys.exit(1)
|
||||
print(" ✅ 右鍵交給函式庫預設行為(不自己註冊)")
|
||||
PY
|
||||
m=open('tray_darwin.m',encoding='utf-8').read()
|
||||
g=open('tray_darwin.go',encoding='utf-8').read()
|
||||
bad=[]
|
||||
# (a) 不可用 dispatch_async(main_queue):Wails 佔住主執行緒後不跑標準 run loop,
|
||||
# 排進 main queue 的 block 永遠不會執行(實測 log 只印到 dispatching…)
|
||||
if 'dispatch_async(dispatch_get_main_queue' in m:
|
||||
bad.append('用了 dispatch_async(main_queue) ⇒ Wails 之下不會執行')
|
||||
# (b) 必須 performSelectorOnMainThread:NSStatusItem 要主執行緒+NSApp 已初始化
|
||||
if 'performSelectorOnMainThread' not in m:
|
||||
bad.append('缺 performSelectorOnMainThread ⇒ NSStatusItem 建不起來或崩潰')
|
||||
# (c) 不可 setMenu:——設了選單,按鈕 action 不會被呼叫,左鍵也會變成彈選單
|
||||
if '.item setMenu:' in m or 'item.menu = self.menu;' in m and 'item.menu = nil' not in m:
|
||||
bad.append('把選單常駐掛在 statusItem 上 ⇒ 左鍵會失效')
|
||||
# (d) 不可再依賴 energye/systray(實測它在 RunWithExternalLoop 下根本不建托盤)
|
||||
if 'energye/systray' in g:
|
||||
bad.append('還在用 energye/systray ⇒ 外部 loop 之下不會建立托盤')
|
||||
if bad:
|
||||
for b in bad: print(f" ❌ {b}")
|
||||
sys.exit(1)
|
||||
print(" ✅ 原生托盤:主執行緒建立、不常駐掛選單、未依賴 systray")
|
||||
PY2
|
||||
|
||||
# ③ ShowWindow 要能叫回「已開但被蓋住」的視窗
|
||||
for f in WindowUnminimise WindowShow WindowSetAlwaysOnTop; do
|
||||
|
||||
@@ -2,10 +2,7 @@ module arcrun-app
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/energye/systray v1.0.3
|
||||
github.com/wailsapp/wails/v2 v2.13.0
|
||||
)
|
||||
require github.com/wailsapp/wails/v2 v2.13.0
|
||||
|
||||
require (
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
||||
|
||||
@@ -4,8 +4,6 @@ github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/energye/systray v1.0.3 h1:XnyjJCeRU5z00bpNOic2fGTKz/7yHZMZjWiGIVXDS+4=
|
||||
github.com/energye/systray v1.0.3/go.mod h1:HelKhC3PXwv3ryDxbuQqV+7kAxAYNzE5cfdrerGOZTc=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
|
||||
+3
-34
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"embed"
|
||||
|
||||
"github.com/energye/systray"
|
||||
"github.com/wailsapp/wails/v2"
|
||||
"github.com/wailsapp/wails/v2/pkg/options"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||
@@ -33,10 +32,11 @@ func main() {
|
||||
// instantiated on the main thread!」⇒ SIGABRT
|
||||
// 正解:main() 一開始(還在主執行緒)就 RunWithExternalLoop 註冊並 start,
|
||||
// 它不阻塞,接著再把主執行緒交給 wails.Run()。
|
||||
// 🔴 托盤必須在**主執行緒**建立(NSStatusItem 內部會 new NSWindow)。
|
||||
// 改用原生 NSStatusItem(見 tray_darwin.m)——實測證明 energye/systray
|
||||
// 在 RunWithExternalLoop 之下**根本不會建立托盤**(onReady 從未觸發)。
|
||||
setupTray(app)
|
||||
trayStart()
|
||||
installSignalHandler() // 收到 TERM/INT 要能正常退出(不必強制結束)
|
||||
defer trayEnd()
|
||||
|
||||
err := wails.Run(&options.App{
|
||||
Title: "Arcrun",
|
||||
@@ -85,34 +85,3 @@ func main() {
|
||||
// 兩個 toolkit 搶主 loop ⇒ `signal arrived during cgo execution` 直接崩。
|
||||
// 正解是 `RunWithExternalLoop`——它只註冊、把 start/end 交給既有的 loop 呼叫。
|
||||
var trayStart, trayEnd = func() {}, func() {}
|
||||
|
||||
func setupTray(app *App) {
|
||||
trayStart, trayEnd = systray.RunWithExternalLoop(func() {
|
||||
// ① icon:**必須是 template icon**——選單列規格是 16-22pt、純黑+alpha,
|
||||
// 由系統依深淺色自動上色。先前塞 1024x1024 的彩色 app icon
|
||||
// ⇒ 縮成一坨方塊(leo 實測)。改用 CIS 的 double chevron 渲成 44x44 純黑。
|
||||
systray.SetTemplateIcon(trayIcon, trayIcon)
|
||||
systray.SetTooltip("Arcrun — 你的知識庫同步小幫手")
|
||||
|
||||
// 🔴 ②③ 的真兇(energye/systray 原始碼註解寫得很清楚):
|
||||
// 「該方法主動調用後 如果托盤菜單已創建則添加進去, **之後鼠標事件失效**」
|
||||
// ⇒ 只要用 AddMenuItem 建了選單,左右鍵 handler **全部失效**
|
||||
// ⇒ leo 實測「右鍵沒跳出 quit」「第一次能開之後就不再跳」。
|
||||
// 正解:SetMenuNil() 移除選單讓滑鼠事件生效;右鍵則交給函式庫預設行為(見下)。
|
||||
quit := systray.AddMenuItem("結束 Arcrun", "停止同步並關閉")
|
||||
quit.Click(func() { app.Quit() })
|
||||
systray.SetMenuNil() // ← 沒有這行,SetOnClick 不會被呼叫
|
||||
|
||||
// 左鍵:立刻展開主視窗(leo:「點擊托盤的 icon 就立刻展開界面」)
|
||||
systray.SetOnClick(func(menu systray.IMenu) { app.ShowWindow() })
|
||||
|
||||
// 🔴 右鍵:**故意不註冊 handler**。
|
||||
// 看 systray_darwin.go 的 systray_on_rclick():
|
||||
// if onRClick != nil { onRClick(st) } else { C.show_menu() }
|
||||
// ⇒ **沒註冊時,函式庫自己就會把選單叫出來**(而且 show_menu 內部
|
||||
// 已經處理好 create_menu → performClick → set_menu_nil 整套)。
|
||||
// 我先前註冊了自己的 handler 並加 `if menu != nil` 防呆,
|
||||
// 反而把「函式庫本來就會做對的事」換成一個更脆弱的版本
|
||||
// ⇒ leo 實測「右鍵沒跳出 quit」。少寫這段才是正解。
|
||||
}, nil)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -x objective-c
|
||||
#cgo LDFLAGS: -framework Cocoa
|
||||
#include <stdlib.h>
|
||||
void createTray(const char *iconBytes, int iconLen, const char *tooltip, const char *quitTitle);
|
||||
*/
|
||||
import "C"
|
||||
import "unsafe"
|
||||
|
||||
// trayApp 讓 C 回呼找得到 App(cgo 匯出函式不能帶 Go 參數)。
|
||||
var trayApp *App
|
||||
|
||||
//export trayOnLeftClick
|
||||
func trayOnLeftClick() {
|
||||
if trayApp != nil {
|
||||
trayApp.ShowWindow()
|
||||
}
|
||||
}
|
||||
|
||||
//export trayOnQuit
|
||||
func trayOnQuit() {
|
||||
if trayApp != nil {
|
||||
trayApp.Quit()
|
||||
}
|
||||
}
|
||||
|
||||
// setupTray 建立選單列 icon。
|
||||
// ⚠️ **必須在主執行緒呼叫**(NSStatusItem 內部會 new NSWindow)。
|
||||
func setupTray(app *App) {
|
||||
trayApp = app
|
||||
tip := C.CString("Arcrun — 你的知識庫同步小幫手")
|
||||
quit := C.CString("結束 Arcrun")
|
||||
defer C.free(unsafe.Pointer(tip))
|
||||
defer C.free(unsafe.Pointer(quit))
|
||||
C.createTray((*C.char)(unsafe.Pointer(&trayIcon[0])), C.int(len(trayIcon)), tip, quit)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//go:build darwin
|
||||
// tray_darwin.m — macOS 選單列 icon(t194 三修,原生實作)
|
||||
//
|
||||
// 🔴 為什麼不用 energye/systray:實測證明它在 Wails 之下**根本沒建立托盤**。
|
||||
// systray.go:83 的 setInternalLoop(true) 只在 systray.Run() 裡呼叫;
|
||||
// RunWithExternalLoop 沒有 ⇒ registerSystray() 第一行就
|
||||
// `if (!internalLoop) return;` ⇒ delegate 從沒建立
|
||||
// ⇒ onReady 不會被呼叫、enable_on_click 不會執行、左右鍵事件根本不存在。
|
||||
// 最小重現(scratchpad/traytest):READY 從未印出、點擊無反應。
|
||||
// ⇒ 直接用 NSStatusItem,行為完全由我們掌握。
|
||||
#import <Cocoa/Cocoa.h>
|
||||
|
||||
extern void trayOnLeftClick(void);
|
||||
extern void trayOnQuit(void);
|
||||
|
||||
@interface ArcrunTray : NSObject
|
||||
@property (strong) NSStatusItem *item;
|
||||
@property (strong) NSMenu *menu;
|
||||
- (void)onClick:(id)sender;
|
||||
- (void)onQuit:(id)sender;
|
||||
@end
|
||||
|
||||
@implementation ArcrunTray
|
||||
// 左鍵=開視窗;右鍵=彈選單。用 currentEvent 分辨(與系統慣例一致)。
|
||||
- (void)onClick:(id)sender {
|
||||
NSEvent *e = [NSApp currentEvent];
|
||||
if (e.type == NSEventTypeRightMouseUp ||
|
||||
(e.type == NSEventTypeLeftMouseUp && (e.modifierFlags & NSEventModifierFlagControl))) {
|
||||
// 右鍵(或 Ctrl+左鍵,macOS 慣例):把選單彈在 icon 底下。
|
||||
// popUpStatusItemMenu 自 10.14 起 deprecated ⇒ 改用「暫時掛上選單、
|
||||
// 送一次 click、再拿掉」——這是官方建議的替代寫法,且不會讓左鍵失效。
|
||||
self.item.menu = self.menu;
|
||||
[self.item.button performClick:nil];
|
||||
self.item.menu = nil;
|
||||
return;
|
||||
}
|
||||
trayOnLeftClick();
|
||||
}
|
||||
- (void)onQuit:(id)sender { trayOnQuit(); }
|
||||
@end
|
||||
|
||||
static ArcrunTray *gTray = nil;
|
||||
static void createTrayNow(NSData *iconData, NSString *tip, NSString *quitTitle);
|
||||
|
||||
// 用一個小物件把參數帶到主執行緒(performSelectorOnMainThread 只能傳一個 object)
|
||||
@interface ArcrunTrayBoot : NSObject
|
||||
@property (strong) NSData *iconData;
|
||||
@property (strong) NSString *tip;
|
||||
@property (strong) NSString *quitTitle;
|
||||
- (void)run;
|
||||
@end
|
||||
@implementation ArcrunTrayBoot
|
||||
- (void)run { createTrayNow(self.iconData, self.tip, self.quitTitle); }
|
||||
@end
|
||||
|
||||
// createTray:兩個條件同時要滿足,缺一個都會靜默失敗(兩個我都踩過):
|
||||
// ① **主執行緒**——NSStatusItem 內部會 new NSWindow
|
||||
// (在別的 goroutine 呼叫 ⇒ "NSWindow should only be instantiated on the main thread!")
|
||||
// ② **NSApplication 已初始化**——[NSStatusBar systemStatusBar] 在 NSApp
|
||||
// 還沒 finishLaunching 時拿不到東西 ⇒ icon 不會出現、也不報錯
|
||||
// (實測:在 wails.Run() 之前呼叫 ⇒ menu bar item 數 = 0)
|
||||
// ⇒ 用 dispatch_async 丟到 main queue:Wails 起來後才會被執行,且保證在主執行緒。
|
||||
void createTray(const char *iconBytes, int iconLen, const char *tooltip, const char *quitTitle) {
|
||||
// 先把參數複製一份——呼叫端的 C 字串在 dispatch 執行時可能已被釋放
|
||||
NSData *iconData = [NSData dataWithBytes:iconBytes length:iconLen];
|
||||
NSString *tip = [NSString stringWithUTF8String:tooltip];
|
||||
NSString *qt = [NSString stringWithUTF8String:quitTitle];
|
||||
// 🔴 **不能用 dispatch_async(main_queue)**——實測 Wails 佔住主執行緒後
|
||||
// 沒有跑標準 run loop,排進 main queue 的 block **永遠不會被執行**
|
||||
// (log 只印到 "dispatching…",block 內完全沒動靜)。
|
||||
// 改用 performSelectorOnMainThread:它走的是 run loop 的 common modes,
|
||||
// 在 Wails 的 loop 之下仍會被處理。
|
||||
ArcrunTrayBoot *boot = [[ArcrunTrayBoot alloc] init];
|
||||
boot.iconData = iconData; boot.tip = tip; boot.quitTitle = qt;
|
||||
[boot performSelectorOnMainThread:@selector(run) withObject:nil waitUntilDone:NO];
|
||||
}
|
||||
|
||||
static void createTrayNow(NSData *iconData, NSString *tip, NSString *quitTitle) {
|
||||
gTray = [[ArcrunTray alloc] init];
|
||||
gTray.item = [[NSStatusBar systemStatusBar] statusItemWithLength:NSVariableStatusItemLength];
|
||||
|
||||
NSImage *img = [[NSImage alloc] initWithData:iconData];
|
||||
[img setSize:NSMakeSize(18, 18)];
|
||||
[img setTemplate:YES]; // 由系統依深淺色自動上色
|
||||
gTray.item.button.image = img;
|
||||
gTray.item.button.toolTip = tip;
|
||||
|
||||
// 右鍵選單只有一項(leo:「托盤裡只剩下按右鍵會結束」)
|
||||
gTray.menu = [[NSMenu alloc] init];
|
||||
NSMenuItem *q = [[NSMenuItem alloc] initWithTitle:quitTitle
|
||||
action:@selector(onQuit:) keyEquivalent:@""];
|
||||
[q setTarget:gTray];
|
||||
[gTray.menu addItem:q];
|
||||
|
||||
// ⚠️ **不要** setMenu:——一旦設了選單,按鈕的 action 就不會被呼叫,
|
||||
// 左鍵也會變成彈選單(這正是 systray 那個「建了選單滑鼠事件失效」的同一件事)。
|
||||
// 改成自己在 onClick: 裡判斷左右鍵,右鍵才 popUpStatusItemMenu。
|
||||
[gTray.item.button setTarget:gTray];
|
||||
[gTray.item.button setAction:@selector(onClick:)];
|
||||
[gTray.item.button sendActionOn:(NSEventMaskLeftMouseUp | NSEventMaskRightMouseUp)];
|
||||
}
|
||||
Reference in New Issue
Block a user