Files
arcrun-collector/cmd/arcrun-app/app.go
T
Leo f4eb3c9fa1 Mac 四修:Dock 不再重複/結束真的會結束/全螢幕可用/視窗 70%
leo 08-06 Mac 實測回報。四個都是**只在 Mac 現形**的病。

## ① Dock 與選單列兩邊都顯示(連帶:只有 Arcrun 列在強制結束清單)
真兇:Wails 在 AppDelegate.m:44 applicationWillFinishLaunching 硬設
`setActivationPolicy:Regular`,**runtime 蓋掉 Info.plist 的 LSUIElement**
(後者只決定啟動當下的初始 policy)。而 v2.13 的 mac.ActivationPolicy 是註解掉的。
🔴 **這件事 fyne 版 604704a(07-24)已經解過且真機驗證通過**,換 Wails 時整段掉了;
   wiki mistakes.md:124 也早就記著同一條。**本次是取回原版,不是重新發明**——
   dock_darwin.go/dock_other.go 逐字沿用,只更新註解說明 Wails 死法相同。

## ② 托盤右鍵「結束 Arcrun」點了不會結束
leo:「唯一結束法是強制結束…因為舊版沒結束導致無法覆蓋,
      **如果不知道怎麼強制結束的人就放棄了**」
真兇:Wails 的 runtime.Quit() 與「按 ×」**共用同一個 OnBeforeClose**
(frontend.go:364 `if !OnBeforeClose(ctx) { mainWindow.Quit() }`),
我們無條件回 true ⇒ mainWindow.Quit() 永遠不執行,還順手 WindowHide
⇒ 看起來像關了、其實行程還活著。
(AppDelegate.m:41 的 applicationShouldTerminate 也一律回 NSTerminateCancel,
  決定權整個在這個回呼 ⇒ 回 true 就沒有任何出口。)
解:quitting 旗標區分兩者,並抽出 closeMeansHide()/beginQuit() 讓它**可機械驗證**。

## ③ Mac 全螢幕(綠色)按鈕被停用
真兇同樣在 Wails:window.go:92 `zoomable` 只在 `frontendOptions.Mac != nil` 時賦值,
我們沒設 Mac ⇒ zoomable 維持零值 false ⇒ WailsContext.m:208
`if (!zoomable && resizable) { [zoomButton setEnabled:NO]; }`。Windows 無此段。
解:`Mac: &mac.Options{}`(空的即可,DisableZoom 預設 false)。

## ④ 視窗 50% 太小
leo:「Google Drive 大約螢幕長寬的 70%,改成 50% 後顯得太小」⇒ 常數 screenFraction=0.70。
並加 appLog 留痕(螢幕多大、算出多大),以後不必請人量像素。

## 驗(本機真機實跑,非推測)
· `lsappinfo` → **type="UIElement"**、Version="0.18.8"
  = 604704a 當年用的同一個驗法,通過 ⇒ Dock 無 icon、不列強制結束
· 截圖放大號誌燈 → **綠燈是彩色的(enabled)**,停用時會是灰色
· app.log 自報 → **視窗依螢幕 1470x956 設為 1029x669(70%)**
· 首頁顯示「看守中·資料夾有變動就會自動整理/已整理 1 份」=判活正常
· go test TestCloseMeansHide 通過(釘住「按×要隱藏、按結束要放行」)
· go vet 全綠;mac build + windows 交叉編譯皆 OK
· **未驗**:托盤右鍵實際點擊(osascript 缺輔助取用權限,點不到選單列)
  ⇒ 邏輯已單元測試+原始碼追到底,但**實際點擊歸 leo 真機**
2026-08-06 14:11:49 +08:00

532 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
// app.go — Arcrun 桌面 App 的後端(t193
//
// 🔴 為什麼從 fyne 換到 Wailsleo 2026-08-04 看過 v0.16.0 畫面後拍板):
//
// leo:「功能都有了,但**美感非常糟糕**……**跟 CIS 完全無關**,
// 每個功能都開一個小小的 popup 視窗,**非常缺乏整體感**,
// 這要理解的是**原始的技術選擇是否出錯**?」
// 「我的要求是**符合 CIS**,在風格上**跟 portal 一樣**」
//
// fyne 的哲學=所有 UI 自己用 OpenGL 畫 ⇒ 不像 Mac、不像 Windows、**也不像 portal**
// CSS 套不進去、popup 外觀無法控制 ⇒ **CIS 這個硬要求在 fyne 上做不到**。
// Wails 是 WebView 殼 ⇒ 前端就是 HTML/CSS ⇒ **可以直接用 portal 那份色票與 lockup**。
// (此結論 07-27 就查過並寫進 decisions-summary.md D-daemon-UI,我卻沒在動工前提醒。)
//
// 邊界:本檔只做「把既有能力接到 UI」——config 讀寫、狀態、資料夾增刪都對齊
// collector 既有的檔案協定(~/.arcrun-rag/),不另發明一套。
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
// App 是 Wails 綁定的後端物件;前端呼叫的方法都掛在它身上。
type App struct {
ctx context.Context
}
func NewApp() *App { return &App{} }
func (a *App) startup(ctx context.Context) { a.ctx = ctx }
// ── 與 collector 共用的資料位置(路徑規則與 collector/direct.go 一致)──
func appDir() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".arcrun-rag")
}
func configPath() string { return filepath.Join(appDir(), "config.json") }
func statusPath() string { return filepath.Join(appDir(), "status.json") }
func syncNowSignal() string { return filepath.Join(appDir(), "sync-now") }
// ── config 結構(欄位與 collector/direct.go 的 DirectConfig 對齊)──
type accountCfg struct {
InstanceName string `json:"instance_name,omitempty"`
Email string `json:"email,omitempty"`
CypherURL string `json:"cypher_url"`
Namespace string `json:"namespace"`
APIKey string `json:"api_key,omitempty"`
WatchFolders []string `json:"watch_folders,omitempty"`
Extractor string `json:"extractor,omitempty"`
GeminiAPIKey string `json:"gemini_api_key,omitempty"`
}
type directConfig struct {
Accounts []accountCfg `json:"accounts,omitempty"`
WatchFolders []string `json:"watch_folders,omitempty"`
Manifest string `json:"manifest"`
Extractor string `json:"extractor,omitempty"`
ExtractorExplicit bool `json:"extractor_explicit,omitempty"`
GeminiAPIKey string `json:"gemini_api_key,omitempty"`
raw map[string]any
}
// syncStatus 對映 collector 寫的 status.json(只取 UI 要的欄位)。
type syncStatus struct {
LastSync string `json:"last_sync,omitempty"`
ExtractedOK int `json:"extracted_ok"`
ExtractFailed int `json:"extract_failed"`
ExtractorOK bool `json:"extractor_ok"`
ExtractorError string `json:"extractor_error,omitempty"`
// 「上次真的有做事」那一輪(2026-08-05)——ExtractedOK 是本輪計數、會被下一輪歸零,
// 只靠它顯示成果,使用者在同步完成 15 秒後就看不到任何證據(leo 實撞)。
LastActivityAt string `json:"last_activity_at,omitempty"`
LastActivityOK int `json:"last_activity_ok"`
LastActivityFailed int `json:"last_activity_failed"`
// G-6.22026-08-06):collector 讀不了、因此整個跳過的檔案。
// 以前這些檔在 scan 的白名單閘就無聲蒸發,使用者只看到「什麼都沒發生」。
SkippedDocs []skippedDoc `json:"skipped_docs,omitempty"`
SkippedDocCount int `json:"skipped_doc_count"`
SkippedOtherCount int `json:"skipped_other_count"`
}
type skippedDoc struct {
Path string `json:"path"`
Ext string `json:"ext"`
}
// extLabel 把副檔名翻成使用者認得的東西。
// 使用者不會因為看到「.pages」就懂,但看到「Pages」會——那是他自己按存檔時的名字。
func extLabel(ext string) string {
switch strings.ToLower(ext) {
case ".doc":
return "舊版 Word"
case ".xls":
return "舊版 Excel"
case ".ppt":
return "舊版 PowerPoint"
case ".pages":
return "Pages"
case ".numbers":
return "Numbers"
case ".key":
return "Keynote"
case ".odt", ".ods", ".odp":
return "OpenDocument"
case ".rtf":
return "RTF"
case ".epub":
return "EPUB"
case ".msg", ".eml":
return "郵件檔"
case ".wpd":
return "WordPerfect"
}
return strings.TrimPrefix(ext, ".")
}
// loadCfg 同時保留原始 map ⇒ 回寫時**不會弄丟我們沒宣告的欄位**
// config 裡還有 poll_interval_sec、libraries 等,漏寫就等於幫用戶刪設定)。
func loadCfg() (*directConfig, error) {
b, err := os.ReadFile(configPath())
if err != nil {
return &directConfig{raw: map[string]any{}}, err
}
c := &directConfig{}
if err := json.Unmarshal(b, c); err != nil {
return &directConfig{raw: map[string]any{}}, err
}
_ = json.Unmarshal(b, &c.raw)
return c, nil
}
func saveCfg(c *directConfig) error {
if c.raw == nil {
c.raw = map[string]any{}
}
// 只覆寫我們改過的鍵,其餘原樣保留
accs, _ := json.Marshal(c.Accounts)
var accAny any
_ = json.Unmarshal(accs, &accAny)
c.raw["accounts"] = accAny
c.raw["extractor"] = c.Extractor
c.raw["extractor_explicit"] = c.ExtractorExplicit
c.raw["gemini_api_key"] = c.GeminiAPIKey
out, err := json.MarshalIndent(c.raw, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(appDir(), 0o755); err != nil {
return err
}
return os.WriteFile(configPath(), out, 0o600)
}
// ── 前端要的資料形狀 ──
type UIFolder struct {
Path string `json:"path"`
AccIdx int `json:"accIdx"`
}
type UIAccount struct {
Name string `json:"name"`
Host string `json:"host"`
Folders []UIFolder `json:"folders"`
}
type UIState struct {
Version string `json:"version"`
StatusBig string `json:"statusBig"`
StatusSub string `json:"statusSub"`
Syncing bool `json:"syncing"`
Accounts []UIAccount `json:"accounts"`
Engine string `json:"engine"` // "workers-ai" | "gemma"
GeminiKey string `json:"geminiKey"` // 只回遮罩,不回真值
ExtractedOK int `json:"extractedOK"` // 首頁「已整理幾份」
Steps []Step `json:"steps"` // 首頁狀態時間軸(leo #6
Skipped *UISkipped `json:"skipped"` // 讀不了的檔(沒有就是 null,前端不畫)
}
// UISkipped=首頁那張「這些檔案現在還處理不了」的卡。
//
// 🔴 存在理由(J-1/S6 考題 G-6.2):
//
// 「Given 我丟進去的是 PDF 或 Word/When 我搜它的內容/
// Then 我一樣找得到——**或當場被告知這種檔案還不支援**,不准安靜地略過。」
//
// 後半句在這裡兌現。文字一律白話:講「你的哪個檔沒進去」「你要不要做什麼」,
// 不講 allowedExt、副檔名白名單、extractor 這些系統內部詞。
type UISkipped struct {
Title string `json:"title"` // 「有 3 個檔案現在還讀不了」
Note string `json:"note"` // 該不該做什麼——這裡的答案是「不用,之後會自動補上」
Files []string `json:"files"` // 「舊版報告.doc(舊版 Word)」
More int `json:"more"` // 沒列出來的還有幾個
Other string `json:"other"` // 非文件檔的一行說明(沒有就空字串)
}
// buildSkipped 把 status.json 的三個欄位翻成首頁看得懂的一張卡。
// 完全沒有東西被略過時回 nil ⇒ 前端不畫這張卡(沒事就別佔畫面)。
func buildSkipped(s syncStatus) *UISkipped {
if s.SkippedDocCount == 0 && s.SkippedOtherCount == 0 {
return nil
}
u := &UISkipped{}
for _, d := range s.SkippedDocs {
u.Files = append(u.Files, fmt.Sprintf("%s%s", filepath.Base(d.Path), extLabel(d.Ext)))
}
if n := s.SkippedDocCount - len(s.SkippedDocs); n > 0 {
u.More = n
}
if s.SkippedDocCount > 0 {
u.Title = fmt.Sprintf("有 %d 個檔案現在還讀不了", s.SkippedDocCount)
// 誠實地告訴他「這不是你的錯,也不用你動手」——否則使用者會反覆重丟同一個檔。
u.Note = "這些格式我們還沒支援,所以沒有進你的知識庫。等支援了會自動補上,你不用重丟。" +
"急著要的話,先用原本的軟體另存成 PDF 或 Word(.docx)放進同一個資料夾就行。"
} else {
// 只有非文件檔的情況(例如整個資料夾都是照片)——不需要驚動他,但也不能不說。
u.Title = "有些檔案沒有被整理"
u.Note = "看起來不是文件,所以跳過了。"
}
if s.SkippedOtherCount > 0 {
u.Other = fmt.Sprintf("另外有 %d 個不是文件的檔案(圖片、影片、壓縮檔之類)也沒有處理。",
s.SkippedOtherCount)
}
return u
}
// Step 是首頁狀態時間軸的一格。
// 🔴 leo 08-04:「首頁要顯示的應該是 status,如果**看守、發現變化、萃取、上傳…
//
// 不同 status 在哪裡顯示**?」
//
// ⇒ 把一輪同步拆成四步,讓使用者看得到「現在走到哪」,而不是只有一句「看守中」。
type Step struct {
Title string `json:"title"`
Meta string `json:"meta,omitempty"`
State string `json:"state"` // "done"(已完成)|"now"(進行中)|""(還沒輪到)
}
// buildSteps 依 status.json 與訊號檔推出四步的狀態。
// 誠實邊界:collector 目前回報的是「整輪」而非逐檔階段,所以同步中時
// 「發現變化→萃取→上傳」一起標進行中;不假裝有更細的進度。
func buildSteps(s syncStatus, syncing bool) []Step {
watch := Step{Title: "看守資料夾", Meta: "有變動就自動開始", State: "done"}
if syncing {
return []Step{
watch,
{Title: "發現變化", State: "done"},
{Title: "用 AI 整理成知識卡", Meta: "進行中", State: "now"},
{Title: "上傳到你的知識庫", State: ""},
}
}
// 🔴 2026-08-05:這三步以前只要「跑過任何一輪」(LastSync 非空)就全標綠,
// 而 meta 用的是**本輪**計數(下一輪歸零)⇒ 綠燈與實際進度脫鉤、數字又永遠空白,
// 正是 leo 說的「都顯示綠燈沒動,實際上已經做完了」。
// ⇒ 綠燈只在「真的有整理過東西」時才亮,且亮的是上次有產出那輪的數字+時間。
done := ""
if s.LastActivityOK > 0 || s.LastActivityFailed > 0 {
done = "done"
}
found := "等待中"
if done != "" {
if t, err := time.Parse(time.RFC3339, s.LastActivityAt); err == nil {
found = "上次 " + t.Local().Format("15:04")
} else {
found = "已處理"
}
}
meta := ""
if s.LastActivityOK > 0 {
meta = fmt.Sprintf("上次 %d 份", s.LastActivityOK)
}
up := Step{Title: "上傳到你的知識庫", Meta: meta, State: done}
if s.LastActivityFailed > 0 {
up.Meta = strings.TrimPrefix(fmt.Sprintf("%s · ⚠ %d 份失敗", meta, s.LastActivityFailed), " · ")
}
return []Step{
watch,
{Title: "發現變化", Meta: found, State: done},
{Title: "用 AI 整理成知識卡", State: done},
up,
}
}
// GetState 是前端每秒拉一次的單一入口。
func (a *App) GetState() UIState {
st := UIState{Version: version}
cfg, _ := loadCfg()
for i, acc := range cfg.Accounts {
ui := UIAccount{Name: accountName(acc), Host: shortHost(acc.CypherURL)}
for _, f := range acc.WatchFolders {
ui.Folders = append(ui.Folders, UIFolder{Path: f, AccIdx: i})
}
st.Accounts = append(st.Accounts, ui)
}
st.Engine = cfg.Extractor
if !cfg.ExtractorExplicit || st.Engine == "" {
st.Engine = "workers-ai" // 與 direct.go 的預設判準一致
}
if strings.TrimSpace(cfg.GeminiAPIKey) != "" {
st.GeminiKey = "••••••••"
}
sync := loadSyncStatus()
st.ExtractedOK = sync.ExtractedOK
st.Syncing, st.StatusBig, st.StatusSub = describeStatus(sync)
st.Steps = buildSteps(sync, st.Syncing)
st.Skipped = buildSkipped(sync)
return st
}
func loadSyncStatus() syncStatus {
var s syncStatus
if b, err := os.ReadFile(statusPath()); err == nil {
_ = json.Unmarshal(b, &s)
}
return s
}
// describeStatus 產生狀態文案。
// 🔴 t195leo 08-05 實撞:「燈號是真的還是假的?」——**是假的**):
//
// 舊版只看「sync-now 訊號檔存不存在」就顯示「同步中…」。
// 但那只代表**排隊了**,不代表有人在處理:leo 的 collector 在 11:25 死掉,
// 他 11:38 按同步 ⇒ 訊號檔沒人消化 ⇒ 畫面一直說「正在整理知識卡」,
// 實際上 13 分鐘沒跑過任何一輪、也不會產卡。**這比沒有燈號更糟——它在說謊。**
//
// ⇒ 燈號改成有憑有據:
// · collector 沒在跑 → 明說「同步引擎沒有在跑」,不要假裝在整理
// · 有訊號檔且引擎活著 → 才是真的「同步中」
// · 其餘 → 看守中
//
// 🔴 2026-08-05 第二修(leo:「拖新檔進資料夾…自始至終都顯示『等待中』,
//
// 實際上已經做完了,這個 status 是壞的」)——t195 只補了「引擎活著」那半,
// 「同步中」仍靠 sync-now 訊號檔判斷,那是錯的判準,兩個理由:
// ① 訊號檔**只有手動按「立刻同步」才會產生**;leo 這次是拖檔進資料夾
// (自動觸發),整輪從頭到尾沒有訊號檔 ⇒ 畫面永遠停在「看守中/等待中」。
// ② 就算是手動按的,collector 是**先刪檔再跑**consumeSyncNowSignal),
// 所以真正在跑的那段時間訊號檔早就不見了。
// ⇒ 改用 t191 已經做好的機制:collector 開工印 phase:"start"、跑完印 "done"
// supervisor 據此維護 StateSyncingsupervisor.go)。**那條線本來就在,
// Wails 版換代時沒接上而已**——不要再自己發明第三種判斷法。
func describeStatus(s syncStatus) (syncing bool, big, sub string) {
if !collectorAlive() {
return false, "同步引擎沒有在跑", "資料夾不會被自動整理 ⇒ 請結束 Arcrun 再重新開啟"
}
if collectorSyncing() {
return true, "同步中… 正在讀檔並整理成知識卡", "請稍候,完成後會顯示整理了幾份"
}
if s.ExtractorError != "" && !s.ExtractorOK {
return false, "需要你處理一下", "⚠ " + s.ExtractorError
}
parts := []string{}
if t, err := time.Parse(time.RFC3339, s.LastSync); err == nil {
parts = append(parts, "上次檢查 "+t.Local().Format("15:04"))
}
// 用「上次真的有做事」那輪的數字,不用本輪計數——後者每輪歸零,
// 會讓剛整理完的成果在十幾秒後從畫面上消失(就是 leo 撞到的那個「壞掉的 status」)。
if s.LastActivityOK > 0 || s.LastActivityFailed > 0 {
when := ""
if t, err := time.Parse(time.RFC3339, s.LastActivityAt); err == nil {
when = t.Local().Format("15:04") + " "
}
if s.LastActivityOK > 0 {
parts = append(parts, fmt.Sprintf("%s已整理 %d 份", when, s.LastActivityOK))
}
if s.LastActivityFailed > 0 {
parts = append(parts, fmt.Sprintf("⚠ %d 份失敗", s.LastActivityFailed))
}
}
if len(parts) == 0 {
return false, "看守中 · 資料夾有變動就會自動整理", "還沒有同步紀錄"
}
return false, "看守中 · 資料夾有變動就會自動整理", strings.Join(parts, " · ")
}
func accountName(a accountCfg) string {
if s := strings.TrimSpace(a.InstanceName); s != "" {
return s
}
if s := strings.TrimSpace(a.Email); s != "" {
return s
}
return shortHost(a.CypherURL)
}
func shortHost(u string) string {
s := strings.TrimPrefix(strings.TrimPrefix(u, "https://"), "http://")
return strings.TrimSuffix(strings.SplitN(s, "/", 2)[0], "/")
}
// ── 動作(前端按鈕直接呼叫)──
// SyncNow 寫訊號檔讓 collector 立刻跑一輪(沿用 t98 的機制,不新增 IPC)。
func (a *App) SyncNow() error {
if err := os.MkdirAll(appDir(), 0o755); err != nil {
return err
}
return os.WriteFile(syncNowSignal(), []byte{}, 0o644)
}
// PickFolder 用**系統原生**資料夾選擇器。
// 🔴 這是換 Wails 的另一個實質好處(D-daemon-UI 已記):macOS 的 powerbox 機制
// 會在使用者用原生面板選資料夾時**自動授予該資料夾存取權**;fyne 自繪的 picker 拿不到。
// 未來要上 Mac App Store 或開沙箱時,原生 picker 是硬需求。
func (a *App) PickFolder() (string, error) {
return runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
Title: "選一個要自動整理的資料夾",
})
}
func (a *App) AddFolder(accIdx int, path string) error {
if strings.TrimSpace(path) == "" {
return nil
}
cfg, err := loadCfg()
if err != nil {
return err
}
if accIdx < 0 || accIdx >= len(cfg.Accounts) {
return fmt.Errorf("找不到這個知識庫帳號")
}
for _, f := range cfg.Accounts[accIdx].WatchFolders {
if f == path {
return nil // 已經在看守了,不重複加
}
}
cfg.Accounts[accIdx].WatchFolders = append(cfg.Accounts[accIdx].WatchFolders, path)
sort.Strings(cfg.Accounts[accIdx].WatchFolders)
if err := saveCfg(cfg); err != nil {
return err
}
restartWatch() // 立刻生效,不必等下一輪
return nil
}
func (a *App) RemoveFolder(accIdx int, path string) error {
cfg, err := loadCfg()
if err != nil {
return err
}
if accIdx < 0 || accIdx >= len(cfg.Accounts) {
return fmt.Errorf("找不到這個知識庫帳號")
}
keep := []string{}
for _, f := range cfg.Accounts[accIdx].WatchFolders {
if f != path {
keep = append(keep, f)
}
}
cfg.Accounts[accIdx].WatchFolders = keep
if err := saveCfg(cfg); err != nil {
return err
}
restartWatch()
return nil
}
// SetAI 存 AI 設定。
// 🔴 t190:金鑰**無條件以輸入框為準**(清空=刪除)——leo 實撞過「金鑰刪不掉」。
func (a *App) SetAI(useGemini bool, key string) error {
cfg, err := loadCfg()
if err != nil {
return err
}
engine := "workers-ai"
if useGemini {
engine = "gemma"
if strings.TrimSpace(key) == "" {
return fmt.Errorf("選了 Gemini 就要貼上金鑰;不想申請的話請改選「雲端 AI」")
}
}
cfg.Extractor = engine
cfg.ExtractorExplicit = true
cfg.GeminiAPIKey = key
for i := range cfg.Accounts {
cfg.Accounts[i].Extractor = engine
cfg.Accounts[i].GeminiAPIKey = key
}
if err := saveCfg(cfg); err != nil {
return err
}
restartWatch()
return nil
}
// OpenURL 用系統瀏覽器開網址(下載頁/說明文件)。
func (a *App) OpenURL(u string) { runtime.BrowserOpenURL(a.ctx, u) }
// ── 托盤會呼叫的兩個動作(t194)──
// ShowWindow 把主視窗叫出來並帶到前景。
// 🔴 leo 2026-08-05:「**點擊托盤的 icon 就立刻展開界面**」
// ⇒ 左鍵不彈選單、直接開窗(Google Drive 的行為)。
func (a *App) ShowWindow() {
// 🔴 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 真的結束程式(=停止看守)。只有托盤右鍵那一項會呼叫。
//
// 🔴 leo 實測④:「**用強制結束把它關掉才能測試**」——代表沒有一條正常的結束路徑。
//
// 這裡先停掉 collector 子行程再關 App,否則子行程會變孤兒繼續跑。
func (a *App) Quit() {
// 🔴 2026-08-06:先舉旗再喊退。沒有這一步,OnBeforeClose 會把 runtime.Quit()
// 當成「按 ×」攔下來(Wails frontend.go:364 兩者共用同一個回呼)
// ⇒ leo 實撞:托盤右鍵「結束 Arcrun」點了沒反應,只能強制結束,
// 導致下載的新版覆蓋不掉還活著的舊版。
beginQuit()
stopSupervisor()
runtime.Quit(a.ctx)
}