Files
arcrun-collector/cmd/arcrun-tray/main.go
T
Leo b9d880413d feat(daemon-beta t14/t15/t17): supervisor log 檔+removed 清本地卡+托盤建新資料夾
- t14 supervisor:子行程 stdout(tee)/stderr 全落 ~/.arcrun-rag/collector.log,
  >5MB 改名 .old 重開(兩代輪替);落 log 失敗一律吞掉不擋看守。新增 2 測試
  (輸出進檔+輪替觸發)。
- t15 direct removed(extractor 模式):雲端 takedown 2xx 後同步刪本地
  system-dev/wiki/cards/<頁名>.md(存在才刪;刪失敗只記 warning 不擋)。
  新增 2 測試(刪原檔→本地卡被清/卡已不在照常下架)。
- t17 arcrun-tray:tray 選單+設定視窗加「建立新資料夾並看守…」——
  widget.Entry 輸入名稱→ ~/ 底下 mkdir(已存在沿用;擋路徑分隔與 ..)→
  addWatchFolder+saveConfig+重啟看守+rebuildTray。

collector: go vet ✓、go test -count=1 ./... 43/43 綠;arcrun-tray: go build ✓(產物已刪)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 15:06:21 +08:00

359 lines
11 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.
// arcrun-tray — Arcrun RAG 桌面托盤殼(CP-1 第 6 關 daemon 產品化,leo 2026-07-21 定 fyne)。
//
// 目的:讓「不開 terminal 的人」也能裝、開關、選資料夾——一個選單列/系統匣 icon 就是全部。
// 它看守 `collector direct`(子行程,見 supervisor 套件的誠實界定)並顯示狀態。
//
// ⚠️ 本檔是 fyne GUICGo+系統 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 (
"encoding/json"
"errors"
"fmt"
neturl "net/url"
"os"
"path/filepath"
"strings"
"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;完整勾選 UItask 7
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 == "" && 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 == '\\' }
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()
}
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)
}
// t17fyne 內建 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
}
items := []*fyne.MenuItem{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.NewMenuItemSeparator(),
pauseItem,
)
desk.SetSystemTrayMenu(fyne.NewMenu("Arcrun RAG", items...))
}
if hasTray {
desk.SetSystemTrayIcon(theme.StorageIcon()) // TODO 換品牌 iconleo 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.Dotray 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 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 "尚未開始"
}
}