Files
arcrun-collector/cmd/arcrun-tray/main.go
T

644 lines
22 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 (
"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;完整勾選 UItask 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
}
// ── t54leo 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("網址看起來不太對,請從信裡或瀏覽器網址列複製整段")
}
// portalGUI)與 cypherAPI)是不同子域:用戶手上的是 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)
}
// 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
}
// t26:選單最頂顯示「連線中:<暱稱||email>」(CF/cypher_url 全程不現身這一行);
// 有 cypher_url 時再加一行縮短的 hostfyne 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,
// t55leo 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 換品牌 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 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 "尚未開始"
}
}