// 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 ( "bytes" "encoding/json" "errors" "fmt" "net/http" neturl "net/url" "os" "path/filepath" "strings" "time" "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,omitempty"` // 單數舊制(第一個資料夾的鏡像,維持相容) WatchFolders []string `json:"watch_folders,omitempty"` // 多資料夾(daemon-beta task 1;完整勾選 UI=task 7) Manifest string `json:"manifest"` CypherURL string `json:"cypher_url"` Namespace string `json:"namespace"` APIKey string `json:"api_key,omitempty"` Email string `json:"email,omitempty"` // 實例主身分(t26;人人記得自己的 email,CF 全程隱形) InstanceName string `json:"instance_name,omitempty"` // 暱稱(t26 選配;不取就顯示 email) Library string `json:"library,omitempty"` Extractor string `json:"extractor,omitempty"` // t54:連線精靈帶入(claude/gemma) Libraries map[string]string `json:"libraries,omitempty"` // t52:資料夾→庫對映(key=絕對路徑) 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 == "" && len(c.WatchFolders) == 0 { c.WatchFolder = defaultWatchFolder() } if c.WatchFolder == "" && len(c.WatchFolders) > 0 { c.WatchFolder = c.WatchFolders[0] // 單數欄位=第一根鏡像(舊 collector 相容) } if c.Manifest == "" { c.Manifest = filepath.Join(appDir(), "manifest.json") } return c } // addWatchFolder 把資料夾加進監看清單(去重、保序),並維持單數欄位=第一根的鏡像。 // 完整的「勾選/移除」UI 是 task 7;本函式先保證資料層正確。 func addWatchFolder(c *directConfig, p string) { if p == "" { return } if len(c.WatchFolders) == 0 && c.WatchFolder != "" && c.WatchFolder != defaultWatchFolder() { c.WatchFolders = []string{c.WatchFolder} } for _, f := range c.WatchFolders { if f == p { return } } c.WatchFolders = append(c.WatchFolders, p) c.WatchFolder = c.WatchFolders[0] } // Folders 回傳監看根清單(正規化:單數舊制併入、去重、保序;與 collector DirectConfig 同語意)。 func (c *directConfig) Folders() []string { seen := map[string]bool{} var out []string add := func(p string) { if p == "" || seen[p] { return } seen[p] = true out = append(out, p) } add(c.WatchFolder) for _, f := range c.WatchFolders { add(f) } return out } // removeWatchFolder 把資料夾移出監看清單,並維持單數欄位=第一根鏡像(清單空=兩欄皆空)。 func removeWatchFolder(c *directConfig, p string) { if len(c.WatchFolders) == 0 && c.WatchFolder == p { c.WatchFolder = "" return } var out []string for _, f := range c.WatchFolders { if f != p { out = append(out, f) } } c.WatchFolders = out if len(out) > 0 { c.WatchFolder = out[0] } else { c.WatchFolder = "" } } 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 == '\\' } // connectionStatusLabel 決定托盤選單頂列「連線中:…」顯示什麼身分(t26 leo 拍板): // 暱稱(InstanceName)優先於 email,兩者都空才顯示「未設定」——CF/cypher 全程不入此字串。 func connectionStatusLabel(instanceName, email string) string { name := strings.TrimSpace(instanceName) if name == "" { name = strings.TrimSpace(email) } if name == "" { name = "未設定" } return "連線中:" + name } // shortCypherHost 把 cypher_url 縮成純 host 給第二行 disabled 選單項看(fyne MenuItem 無 tooltip, // 取捨=直接多一行而非塞進同一行——選單寬度有限,host 通常已夠長)。解析失敗就原樣回傳(不隱藏錯誤設定)。 func shortCypherHost(cypherURL string) string { u := strings.TrimSpace(cypherURL) if u == "" { return "" } if parsed, err := neturl.Parse(u); err == nil && parsed.Host != "" { return parsed.Host } return u } // ── t54(leo 2026-07-25:「最好的就是把它的帳密直接輸入」)───────────────────── // 首次啟動不再需要用戶去網站下載 config.json 丟進隱藏資料夾: // 輸入「知識庫網址 + 帳號密碼」→ 打 /portal/daemon/config → 設定自動寫好。 // 用戶只需要記得他剛在網站設的那組帳密(他本來就記得)。 // daemonConfigResp 是 /portal/daemon/config 的回應(只含連線設定,不含知識內容)。 type daemonConfigResp struct { Success bool `json:"success"` Config struct { CypherURL string `json:"cypher_url"` Namespace string `json:"namespace"` Library string `json:"library"` Extractor string `json:"extractor"` Email string `json:"email"` InstanceName string `json:"instance_name"` } `json:"config"` Error string `json:"error"` } // normalizePortalURL 把用戶貼的東西變成可打的 origin: // 允許貼完整 portal 網址(.../portal/)、只貼主機名、或大小寫/尾斜線不一致。 func normalizePortalURL(raw string) (string, error) { s := strings.TrimSpace(raw) if s == "" { return "", errors.New("請貼上你的知識庫網址") } if !strings.Contains(s, "://") { s = "https://" + s } u, err := neturl.Parse(s) if err != nil || u.Host == "" { return "", errors.New("網址看起來不太對,請從信裡或瀏覽器網址列複製整段") } // portal(GUI)與 cypher(API)是不同子域:用戶手上的是 portal,這裡換算成 API 位址。 host := u.Host if strings.HasPrefix(host, "arcrun-rag-ui.") { host = "arcrun-cypher-executor." + strings.TrimPrefix(host, "arcrun-rag-ui.") } return "https://" + host, nil } // fetchConfigByLogin 用帳密向實例換取這台機器該用的設定。 func fetchConfigByLogin(portalURL, email, password string) (*daemonConfigResp, error) { base, err := normalizePortalURL(portalURL) if err != nil { return nil, err } body, _ := json.Marshal(map[string]string{"email": strings.TrimSpace(email), "password": password}) req, err := http.NewRequest(http.MethodPost, base+"/portal/daemon/config", bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 20 * time.Second} res, err := client.Do(req) if err != nil { return nil, errors.New("連不上這個網址——請確認網址正確、網路正常") } defer res.Body.Close() var out daemonConfigResp if err := json.NewDecoder(res.Body).Decode(&out); err != nil { return nil, errors.New("這個網址不像是 Arcrun RAG 知識庫,請再確認一次") } if res.StatusCode == http.StatusUnauthorized || res.StatusCode == http.StatusForbidden { return nil, errors.New("帳號或密碼不對——用你在知識庫網站設定的那組") } if !out.Success { if out.Error != "" { return nil, errors.New(out.Error) } return nil, errors.New("連線失敗,請稍後再試一次") } return &out, nil } // applyRemoteConfig 把換到的設定寫進本地 config(保留既有看守資料夾)。 func applyRemoteConfig(cfg *directConfig, r *daemonConfigResp) { cfg.CypherURL = r.Config.CypherURL cfg.Namespace = r.Config.Namespace if r.Config.Library != "" { cfg.Library = r.Config.Library } if r.Config.Extractor != "" { cfg.Extractor = r.Config.Extractor } cfg.Email = r.Config.Email if r.Config.InstanceName != "" { cfg.InstanceName = r.Config.InstanceName } if cfg.Manifest == "" { cfg.Manifest = filepath.Join(appDir(), "manifest.json") } } // registerLibraries 把「這台機器看守的資料夾各自對應的庫」報上雲端自動登記(t52)。 // leo 2026-07-26:「用戶可以看到我有 2 個庫,地端雲端都是 2 個,如果只有一個一定被罵。」 // 只在連線精靈那一刻做(那時手上才有帳密;daemon 平時不存密碼)。失敗不擋連線。 func registerLibraries(portalURL, email, password string, cfg *directConfig) error { base, err := normalizePortalURL(portalURL) if err != nil { return err } type libItem struct { Name string `json:"name"` DisplayName string `json:"display_name"` } var libs []libItem seen := map[string]bool{} for _, folder := range cfg.Folders() { name := libraryNameFor(cfg, folder) if name == "" || seen[name] { continue } seen[name] = true libs = append(libs, libItem{Name: name, DisplayName: filepath.Base(folder)}) } if len(libs) == 0 { return nil } body, _ := json.Marshal(map[string]any{"email": strings.TrimSpace(email), "password": password, "libraries": libs}) req, err := http.NewRequest(http.MethodPost, base+"/portal/daemon/libraries", bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") res, err := (&http.Client{Timeout: 20 * time.Second}).Do(req) if err != nil { return err } defer res.Body.Close() if res.StatusCode >= 400 { return fmt.Errorf("庫登記回 HTTP %d", res.StatusCode) } return nil } // libraryNameFor 與 collector/direct.go 的 libraryFor 同規則(資料夾名 slug;空則退回 library)。 func libraryNameFor(cfg *directConfig, folder string) string { if cfg.Libraries != nil { if v, ok := cfg.Libraries[folder]; ok && strings.TrimSpace(v) != "" { return strings.TrimSpace(v) } } base := filepath.Base(strings.TrimRight(folder, string(filepath.Separator))) var b strings.Builder lastUnderscore := false for _, r := range base { switch { case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_': b.WriteRune(r) lastUnderscore = false case r >= 'A' && r <= 'Z': b.WriteRune(r + 32) lastUnderscore = false default: if !lastUnderscore && b.Len() > 0 { b.WriteRune('_') lastUnderscore = true } } } if slug := strings.Trim(b.String(), "_"); slug != "" { return slug } if cfg.Library != "" { return cfg.Library } return "kb" } // isConnected 判斷「這台機器已經連上某個知識庫」——沒有就該跳連線精靈。 func isConnected(cfg *directConfig) bool { return strings.TrimSpace(cfg.CypherURL) != "" && strings.TrimSpace(cfg.Namespace) != "" } 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 desk, hasTray := a.(desktop.App) // rebuildTray 依現況重建整個 tray 選單(daemon-beta t7 多資料夾): // 每個看守中的資料夾一列(點=在 Finder 打開)+各自的「停止看守」項; // 重設選單=刷新(比 (*Menu).Refresh() 跨版本更穩)。 var rebuildTray func() restartWatch := func() { sup.Stop() sup.Start() } // t54 連線精靈:輸入知識庫網址+帳密 → 換設定 → 寫檔 → 開始看守。 // 沒連線過的機器一開 app 就自動跳;之後也能從選單「重新連線…」再開。 var showConnectWizard func() showConnectWizard = func() { urlEntry := widget.NewEntry() urlEntry.SetPlaceHolder("https://…workers.dev/portal/") if cfg.CypherURL != "" { urlEntry.SetText(cfg.CypherURL) } emailEntry := widget.NewEntry() emailEntry.SetPlaceHolder("你的 Email") if cfg.Email != "" { emailEntry.SetText(cfg.Email) } pwEntry := widget.NewPasswordEntry() pwEntry.SetPlaceHolder("你在知識庫設定的密碼") hint := widget.NewLabel("") hint.Wrapping = fyne.TextWrapWord form := container.NewVBox( widget.NewLabel("連上你的知識庫"), widget.NewLabel("貼上網址,再輸入你在網站上設定的帳號密碼:"), urlEntry, emailEntry, pwEntry, hint, ) d := dialog.NewCustomConfirm("Arcrun RAG", "連線", "取消", form, func(ok bool) { if !ok { return } hint.SetText("連線中…") go func() { r, err := fetchConfigByLogin(urlEntry.Text, emailEntry.Text, pwEntry.Text) if err != nil { // fyne 的 UI 更新須回主執行緒;用 dialog 顯示即可(它內部處理) dialog.ShowError(err, win) showConnectWizard() // 讓用戶改完再試,不要把他丟回空白畫面 return } applyRemoteConfig(cfg, r) if err := saveConfig(cfg); err != nil { dialog.ShowError(err, win) return } // t52:把本機資料夾對應的庫報上去自動登記(地端幾個資料夾=雲端幾個庫) if lerr := registerLibraries(urlEntry.Text, emailEntry.Text, pwEntry.Text, cfg); lerr != nil { fmt.Println("庫登記略過:", lerr) // 不擋連線(可能是還沒選資料夾) } restartWatch() rebuildTray() dialog.ShowInformation("連上了", "已連上「"+connectionStatusLabel(cfg.InstanceName, cfg.Email)+"」。\n接下來用選單「+ 新增知識資料夾…」挑要同步的資料夾就好。", win) }() }, win) d.Resize(fyne.NewSize(460, 320)) win.Show() win.RequestFocus() d.Show() } addAction := func() { win.Show() win.RequestFocus() dialog.ShowFolderOpen(func(uri fyne.ListableURI, err error) { if err != nil || uri == nil { return } addWatchFolder(cfg, uri.Path()) if err := saveConfig(cfg); err != nil { dialog.ShowError(err, win) return } restartWatch() rebuildTray() win.Hide() }, win) } // t17:fyne 內建 folder picker 無法輸入名稱建新資料夾(leo 07-24 親測)—— // 補一條「建立新資料夾並看守…」:文字輸入+確定 → 在 ~/ 底下 mkdir(已存在則沿用)→ 看守。 newFolderAction := func() { win.Show() win.RequestFocus() entry := widget.NewEntry() entry.SetPlaceHolder("例如:我的知識庫") content := container.NewVBox( widget.NewLabel("輸入新資料夾名稱(會建立在你的家目錄底下):"), entry, ) dialog.ShowCustomConfirm("建立新資料夾並看守", "確定", "取消", content, func(ok bool) { if !ok { return } name := strings.TrimSpace(entry.Text) if name == "" { return } // 只收「單層名稱」:擋路徑分隔與 ..(避免建到家目錄之外) if strings.ContainsAny(name, `/\`) || name == "." || name == ".." { dialog.ShowError(errors.New("名稱不能包含 / 或 \\,也不能是 . 或 .."), win) return } home, err := os.UserHomeDir() if err != nil { dialog.ShowError(err, win) return } p := filepath.Join(home, name) if err := os.MkdirAll(p, 0o755); err != nil { // 已存在=直接沿用 dialog.ShowError(err, win) return } addWatchFolder(cfg, p) if err := saveConfig(cfg); err != nil { dialog.ShowError(err, win) return } restartWatch() rebuildTray() win.Hide() }, win) } 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 = "繼續看守" } rebuildTray() }) rebuildTray = func() { if !hasTray { return } // t26:選單最頂顯示「連線中:<暱稱||email>」(CF/cypher_url 全程不現身這一行); // 有 cypher_url 時再加一行縮短的 host(fyne MenuItem 無 tooltip,取捨見 shortCypherHost 注解)。 connItem := fyne.NewMenuItem(connectionStatusLabel(cfg.InstanceName, cfg.Email), nil) connItem.Disabled = true items := []*fyne.MenuItem{connItem} if host := shortCypherHost(cfg.CypherURL); host != "" { hostItem := fyne.NewMenuItem("→ "+host, nil) hostItem.Disabled = true items = append(items, hostItem) } items = append(items, statusItem, fyne.NewMenuItemSeparator()) for _, f := range cfg.Folders() { folder := f // capture it := fyne.NewMenuItem("📁 "+filepath.Base(folder), func() { if u, err := neturl.Parse("file://" + folder); err == nil { _ = a.OpenURL(u) } }) it.ChildMenu = fyne.NewMenu("", fyne.NewMenuItem("停止看守這個資料夾", func() { removeWatchFolder(cfg, folder) if err := saveConfig(cfg); err != nil { dialog.ShowError(err, win) return } restartWatch() rebuildTray() }), ) items = append(items, it) } items = append(items, fyne.NewMenuItem("+ 新增知識資料夾…", addAction), fyne.NewMenuItem("+ 建立新資料夾並看守…", newFolderAction), // t17 fyne.NewMenuItem("🔗 連上知識庫…(換帳號/重新連線)", func() { showConnectWizard() }), // t54 fyne.NewMenuItemSeparator(), pauseItem, // t55(leo 2026-07-26 實撞:「沒有 exit 的選項,我無法關閉它」): // 沒有出口的 app 是設計缺陷——用戶只剩「強制結束」可用,而托盤 app 連 Dock 都沒有 // (LSUIElement),右鍵結束那條路也不存在。這裡給一條明確的出路: // 先停子行程(collector),再退整個 app,不留孤兒行程。 fyne.NewMenuItem("結束 Arcrun RAG", func() { sup.Stop() a.Quit() }), ) desk.SetSystemTrayMenu(fyne.NewMenu("Arcrun RAG", items...)) } if hasTray { desk.SetSystemTrayIcon(theme.StorageIcon()) // TODO 換品牌 icon(leo logo 進行中);Mac 建議 template 單色 } rebuildTray() // 開機即建初始選單(否則第一次狀態回呼前選單是空的) // 托盤唯一指示器(leo 07-24 裁決):NSApp 起來後把 activation policy 切 Accessory //(plist LSUIElement 會被 fyne/glfw 蓋掉——07-24 真機實測第三枚坑,見 dock_darwin.go) a.Lifecycle().SetOnStarted(func() { hideDockIcon() }) refreshTray := rebuildTray // 狀態變更 → 刷新 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() { addAction() }), widget.NewButton("建立新資料夾並看守…", func() { newFolderAction() }), // t17 )) // 開機即看守(若已連上知識庫) if isConnected(cfg) { sup.Start() } else { // t54:沒連過就直接跳連線精靈(輸入網址+帳密),不再要用戶去下載 config.json。 statusItem.Label = "狀態:尚未連線" showConnectWizard() } 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 "尚未開始" } }