// arcrun-tray — Arcrun RAG 桌面托盤殼(CP-1 第 6 關 daemon 產品化,leo 2026-07-21 定 fyne)。 // // 目的:讓「不開 terminal 的人」也能裝、開關、選資料夾——一個選單列/系統匣 icon 就是全部。 // 它看守 `collector direct`(子行程,見 supervisor 套件的誠實界定)並顯示狀態。 // // ⚠️ 本檔是 fyne GUI(CGo+系統 webview/GL),**無法在無螢幕 sandbox build/驗**—— // // build/簽章/公證/TCC 授權=leo/地端在真機做(紅線②③)。純邏輯(看守/config)已抽到 // supervisor 套件並在 sandbox `go test` 驗過。 // // 真機 build: // // cd collector/cmd/arcrun-tray && go mod tidy // go run fyne.io/fyne/v2/cmd/fyne@latest package -os darwin -icon icon.png -name "Arcrun RAG" // (Windows:-os windows;簽章/公證另見同目錄 README) package main import ( neturl "net/url" "encoding/json" "fmt" "os" "path/filepath" "arcrun-rag/collector/supervisor" "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" ) // directConfig 是 collector direct 的設定(與 collector/direct.go 的 DirectConfig 同結構; // 這裡只需讀寫 watch_folder,其餘由安裝器帶入)。 type directConfig struct { WatchFolder string `json:"watch_folder"` Manifest string `json:"manifest"` CypherURL string `json:"cypher_url"` Namespace string `json:"namespace"` APIKey string `json:"api_key,omitempty"` Library string `json:"library,omitempty"` IngestWF string `json:"ingest_workflow,omitempty"` RemovedWF string `json:"removed_workflow,omitempty"` PollSec int `json:"poll_interval_sec,omitempty"` MaxRemoved float64 `json:"max_removed_ratio,omitempty"` } // appDir 是設定與 manifest 落地處:~/.arcrun-rag/ func appDir() string { home, _ := os.UserHomeDir() return filepath.Join(home, ".arcrun-rag") } func configPath() string { return filepath.Join(appDir(), "config.json") } // defaultWatchFolder 預設 ~/ArcrunRAG——**刻意避開 ~/Documents**:macOS launchd 背景程序碰 // ~/Documents 會被 TCC 擋(07-19 實撞)。預設在非 TCC 禁區=零授權即可用;用戶要改到 Documents // 才需走 TCC 同意流(見 README)。 func defaultWatchFolder() string { home, _ := os.UserHomeDir() return filepath.Join(home, "ArcrunRAG") } func loadConfig() *directConfig { c := &directConfig{} if data, err := os.ReadFile(configPath()); err == nil { _ = json.Unmarshal(data, c) } if c.WatchFolder == "" { c.WatchFolder = defaultWatchFolder() } if c.Manifest == "" { c.Manifest = filepath.Join(appDir(), "manifest.json") } return c } func saveConfig(c *directConfig) error { if err := os.MkdirAll(appDir(), 0o755); err != nil { return err } data, _ := json.MarshalIndent(c, "", " ") return os.WriteFile(configPath(), data, 0o600) } // collectorBinPath 找同綑的 collector 執行檔(app bundle 內與托盤同層)。 func collectorBinPath() string { exe, err := os.Executable() if err != nil { return "arcrun-collector" } name := "arcrun-collector" if isWindows() { name += ".exe" } return filepath.Join(filepath.Dir(exe), name) } func isWindows() bool { return os.PathSeparator == '\\' } func main() { a := app.NewWithID("dev.arcrun.rag.tray") cfg := loadConfig() // 設定視窗(平時隱藏;選資料夾/看說明時開) win := a.NewWindow("Arcrun RAG") win.SetCloseIntercept(func() { win.Hide() }) // 關窗只隱藏,不結束 app(托盤續跑) win.Resize(fyne.NewSize(420, 220)) sup := supervisor.New(collectorBinPath(), configPath()) // 狀態列(tray 選單第一項,只讀) statusItem := fyne.NewMenuItem("狀態:尚未開始", nil) statusItem.Disabled = true folderItem := fyne.NewMenuItem("選擇知識資料夾…", func() { win.Show() win.RequestFocus() dialog.ShowFolderOpen(func(uri fyne.ListableURI, err error) { if err != nil || uri == nil { return } cfg.WatchFolder = uri.Path() if err := saveConfig(cfg); err != nil { dialog.ShowError(err, win) return } // 換資料夾=重起看守 sup.Stop() sup.Start() win.Hide() }, win) }) openFolderItem := fyne.NewMenuItem("打開資料夾", func() { // fyne 的 App.OpenURL 吃 *url.URL(非 fyne.URI)——2026-07-21 真機 build 實測修正 if u, err := neturl.Parse("file://" + cfg.WatchFolder); err == nil { _ = a.OpenURL(u) } }) var pauseItem *fyne.MenuItem pauseItem = fyne.NewMenuItem("暫停看守", func() { st := sup.Status() if st.State == supervisor.StateStopped { sup.Start() pauseItem.Label = "暫停看守" } else { sup.Stop() pauseItem.Label = "繼續看守" } }) tray := fyne.NewMenu("Arcrun RAG", statusItem, fyne.NewMenuItemSeparator(), folderItem, openFolderItem, pauseItem, ) desk, hasTray := a.(desktop.App) if hasTray { desk.SetSystemTrayMenu(tray) desk.SetSystemTrayIcon(theme.StorageIcon()) // TODO 換品牌 icon(leo logo 進行中);Mac 建議 template 單色 } // 重設選單=刷新(比 (*Menu).Refresh() 跨版本更穩) refreshTray := func() { if hasTray { desk.SetSystemTrayMenu(tray) } } // 狀態變更 → 刷新 tray 文案(回呼在 supervisor goroutine,切回 UI thread) sup.SetOnChange(func(s supervisor.Status) { // 此版 fyne 無 fyne.Do;tray menu label 更新直接做即可 // (2026-07-21 真機 build 實測修正) statusItem.Label = "狀態:" + humanStatus(s) refreshTray() }) // 設定視窗內容:說明現況 + 選資料夾按鈕(給會開窗的人;不會開的人靠 tray 選單) win.SetContent(container.NewVBox( widget.NewLabelWithStyle("Arcrun RAG 知識同步", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), widget.NewLabel("把檔案丟進你選的資料夾,就會自動進你的知識庫。\n這個程式會待在選單列/系統匣,關掉視窗它仍在背景看守。"), widget.NewButton("選擇知識資料夾…", func() { folderItem.Action() }), )) // 開機即看守(若設定完整) if cfg.CypherURL != "" && cfg.Namespace != "" { sup.Start() } else { // 缺安裝器帶入的 cypher_url/namespace:提示用戶先完成安裝 statusItem.Label = "狀態:尚未連結(請先完成安裝)" win.Show() } a.Lifecycle().SetOnStopped(func() { sup.Stop() }) a.Run() } // humanStatus 把狀態機轉成白話(給不懂內部的人)。 func humanStatus(s supervisor.Status) string { switch s.State { case supervisor.StateWatching: if !s.LastRoundAt.IsZero() { return fmt.Sprintf("看守中 · 上次同步 %s", s.LastRoundAt.Local().Format("15:04")) } return "看守中" case supervisor.StateStarting: return "啟動中…" case supervisor.StateError: return "暫時出錯,正在自動重試" case supervisor.StateStopped: return "已暫停" default: return "尚未開始" } }