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:
2026-08-05 02:56:41 +08:00
parent a02eed539a
commit 5860ef72df
6 changed files with 168 additions and 57 deletions
+101
View File
@@ -0,0 +1,101 @@
//go:build darwin
// tray_darwin.m — macOS 選單列 icont194 三修,原生實作)
//
// 🔴 為什麼不用 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 queueWails 起來後才會被執行,且保證在主執行緒。
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)];
}