package main // app.go — Arcrun 桌面 App 的後端(t193) // // 🔴 為什麼從 fyne 換到 Wails(leo 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"` } // 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) } // 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: ""}, } } done := "" if s.ExtractedOK > 0 || s.LastSync != "" { done = "done" } meta := "" if s.ExtractedOK > 0 { meta = fmt.Sprintf("上一輪 %d 份", s.ExtractedOK) } up := Step{Title: "上傳到你的知識庫", Meta: meta, State: done} if s.ExtractFailed > 0 { up.Meta = fmt.Sprintf("%s · ⚠ %d 份失敗", meta, s.ExtractFailed) } return []Step{ watch, {Title: "發現變化", Meta: "等待中", 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) return st } func loadSyncStatus() syncStatus { var s syncStatus if b, err := os.ReadFile(statusPath()); err == nil { _ = json.Unmarshal(b, &s) } return s } // describeStatus 產生狀態文案。 // 🔴 issue #17:正在跑就要**看得出來在跑**——按「立刻同步」會寫 sync-now 訊號檔, // collector 開工時才把它吃掉;訊號檔還在=已排隊未完成 ⇒ 顯示「同步中…」。 func describeStatus(s syncStatus) (syncing bool, big, sub string) { if _, err := os.Stat(syncNowSignal()); err == nil { 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")) } if s.ExtractedOK > 0 { parts = append(parts, fmt.Sprintf("已整理 %d 份", s.ExtractedOK)) } if s.ExtractFailed > 0 { parts = append(parts, fmt.Sprintf("⚠ %d 份失敗", s.ExtractFailed)) } 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() { stopSupervisor() runtime.Quit(a.ctx) }