diff --git a/cmd/arcrun-app/app.go b/cmd/arcrun-app/app.go index 9ffcfb0..abdc49a 100644 --- a/cmd/arcrun-app/app.go +++ b/cmd/arcrun-app/app.go @@ -365,9 +365,21 @@ func (a *App) OpenURL(u string) { runtime.BrowserOpenURL(a.ctx, u) } // 🔴 leo 2026-08-05:「**點擊托盤的 icon 就立刻展開界面**」 // ⇒ 左鍵不彈選單、直接開窗(Google Drive 的行為)。 func (a *App) ShowWindow() { - runtime.WindowShow(a.ctx) + // 🔴 leo 實測③:「第一次點擊可以開啟,然後再點擊托盤就**不再跳出**」。 + // 真兇有二:(a) 選單建了 ⇒ 滑鼠事件失效(見 setupTray 的 SetMenuNil) + // (b) 視窗其實還在、只是被蓋住或最小化 ⇒ 只呼叫 WindowShow 沒有效果。 + // ⇒ 三個都做:取消最小化、顯示、**強制帶到最前面**。 runtime.WindowUnminimise(a.ctx) + runtime.WindowShow(a.ctx) + runtime.WindowSetAlwaysOnTop(a.ctx, true) + runtime.WindowSetAlwaysOnTop(a.ctx, false) // 只用來搶焦點,不真的釘在最上層 } // Quit 真的結束程式(=停止看守)。只有托盤右鍵那一項會呼叫。 -func (a *App) Quit() { runtime.Quit(a.ctx) } +// +// 🔴 leo 實測④:「**用強制結束把它關掉才能測試**」——代表沒有一條正常的結束路徑。 +// 這裡先停掉 collector 子行程再關 App,否則子行程會變孤兒繼續跑。 +func (a *App) Quit() { + stopSupervisor() + runtime.Quit(a.ctx) +} diff --git a/cmd/arcrun-app/build/trayicon.png b/cmd/arcrun-app/build/trayicon.png index 0c7ad4f..9eb591d 100644 Binary files a/cmd/arcrun-app/build/trayicon.png and b/cmd/arcrun-app/build/trayicon.png differ diff --git a/cmd/arcrun-app/main.go b/cmd/arcrun-app/main.go index 7f6cadc..1b9589b 100644 --- a/cmd/arcrun-app/main.go +++ b/cmd/arcrun-app/main.go @@ -35,6 +35,7 @@ func main() { // 它不阻塞,接著再把主執行緒交給 wails.Run()。 setupTray(app) trayStart() + installSignalHandler() // 收到 TERM/INT 要能正常退出(不必強制結束) defer trayEnd() err := wails.Run(&options.App{ @@ -87,15 +88,28 @@ 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 — 你的知識庫同步小幫手") - // 左鍵:立刻展開主視窗 - systray.SetOnClick(func(menu systray.IMenu) { app.ShowWindow() }) - - // 右鍵:只有「結束」一項 + // 🔴 ②③ 的真兇(energye/systray 原始碼註解寫得很清楚): + // 「該方法主動調用後 如果托盤菜單已創建則添加進去, **之後鼠標事件失效**」 + // ⇒ 只要用 AddMenuItem 建了選單,左右鍵 handler **全部失效** + // ⇒ leo 實測「右鍵沒跳出 quit」「第一次能開之後就不再跳」。 + // 正解:SetMenuNil() 移除選單讓滑鼠事件生效,右鍵時**自己叫出選單**。 quit := systray.AddMenuItem("結束 Arcrun", "停止同步並關閉") quit.Click(func() { app.Quit() }) - systray.SetOnRClick(func(menu systray.IMenu) { _ = menu.ShowMenu() }) + systray.SetMenuNil() // ← 沒有這行,下面兩個 handler 都不會被呼叫 + + // 左鍵:立刻展開主視窗(leo:「點擊托盤的 icon 就立刻展開界面」) + systray.SetOnClick(func(menu systray.IMenu) { app.ShowWindow() }) + // 右鍵:把選單叫出來(只有「結束 Arcrun」一項) + systray.SetOnRClick(func(menu systray.IMenu) { + if menu != nil { + _ = menu.ShowMenu() + } + }) }, nil) } diff --git a/cmd/arcrun-app/supervise.go b/cmd/arcrun-app/supervise.go index 0f7e02e..b0619ca 100644 --- a/cmd/arcrun-app/supervise.go +++ b/cmd/arcrun-app/supervise.go @@ -10,8 +10,13 @@ package main // 含 t191 的 phase:start/done ⇒ 「同步中…」)。這裡只做「找到執行檔、拉起來」。 import ( "os" + "os/exec" + "os/signal" "path/filepath" "runtime" + "strings" + "syscall" + "time" "arcrun-rag/collector/supervisor" ) @@ -54,3 +59,53 @@ func restartWatch() { sup.Start() } } + +// stopSupervisor 結束前停掉 collector 子行程。 +// 沒有這一步,按「結束 Arcrun」後 collector 會變孤兒繼續跑 +// ⇒ 使用者以為關了、其實還在同步,而且下次啟動會有兩個。 +func stopSupervisor() { + if sup != nil { + sup.Stop() + } +} + +// installSignalHandler 讓 App 對 SIGTERM/SIGINT 有反應。 +// +// 🔴 leo 實測④:「**用強制結束把它關掉才能測試**,一般托盤程式不會顯示在強制結束列表中」。 +// +// 真兇:Wails 的 loop 不理會 TERM,而 collector 是我們自己 Start 的子行程 +// ⇒ 主程式被殺時**子行程變孤兒繼續跑**(實測:kill -TERM 後 collector 還在)。 +// ⇒ 收到訊號就先停子行程再退出,避免「關了卻還在同步」與「下次啟動有兩個」。 +func installSignalHandler() { + ch := make(chan os.Signal, 1) + signal.Notify(ch, syscall.SIGTERM, syscall.SIGINT) + go func() { + <-ch + stopSupervisor() + // CommandContext 的 kill 是非同步的:Stop() 回來時子行程可能還在收屍。 + // 實測若直接 os.Exit(0),collector 會變孤兒繼續跑(leo 撞到的④)。 + // ⇒ 等它真的不見,最多 3 秒;逾時就自己補一刀。 + waitCollectorGone(3 * time.Second) + os.Exit(0) + }() +} + +// waitCollectorGone 等同綑的 collector 子行程真的結束。 +// 逾時就直接殺——寧可強制,也不要留一個「使用者以為關了卻還在同步」的孤兒。 +func waitCollectorGone(max time.Duration) { + bin := collectorBinPath() + deadline := time.Now().Add(max) + for time.Now().Before(deadline) { + if !processRunning(bin) { + return + } + time.Sleep(120 * time.Millisecond) + } + _ = exec.Command("pkill", "-TERM", "-f", bin).Run() +} + +// processRunning 用 pgrep 查有沒有這支執行檔在跑(純查詢,不動別人的行程)。 +func processRunning(bin string) bool { + out, _ := exec.Command("pgrep", "-f", bin).Output() + return len(strings.TrimSpace(string(out))) > 0 +}