diff --git a/cmd/arcrun-app/check-tray.sh b/cmd/arcrun-app/check-tray.sh index 986ee22..12dee8d 100755 --- a/cmd/arcrun-app/check-tray.sh +++ b/cmd/arcrun-app/check-tray.sh @@ -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 diff --git a/cmd/arcrun-app/go.mod b/cmd/arcrun-app/go.mod index 07a1050..a297cf3 100644 --- a/cmd/arcrun-app/go.mod +++ b/cmd/arcrun-app/go.mod @@ -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 diff --git a/cmd/arcrun-app/go.sum b/cmd/arcrun-app/go.sum index 955bc0e..1d25d78 100644 --- a/cmd/arcrun-app/go.sum +++ b/cmd/arcrun-app/go.sum @@ -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= diff --git a/cmd/arcrun-app/main.go b/cmd/arcrun-app/main.go index bbdbf2f..a1509aa 100644 --- a/cmd/arcrun-app/main.go +++ b/cmd/arcrun-app/main.go @@ -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) -} diff --git a/cmd/arcrun-app/tray_darwin.go b/cmd/arcrun-app/tray_darwin.go new file mode 100644 index 0000000..bf2f3f1 --- /dev/null +++ b/cmd/arcrun-app/tray_darwin.go @@ -0,0 +1,40 @@ +//go:build darwin + +package main + +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework Cocoa +#include +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) +} diff --git a/cmd/arcrun-app/tray_darwin.m b/cmd/arcrun-app/tray_darwin.m new file mode 100644 index 0000000..d77e39d --- /dev/null +++ b/cmd/arcrun-app/tray_darwin.m @@ -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 + +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)]; +}