Files
arcrun-collector/cmd/arcrun-app/app.go
T
Leo 3d13e5a54e t193 二修:側邊欄版面+共用 CSS+App 內更新+兩支視覺機械閘
leo 08-04 看過 v0.17.0 後的四點,逐條回應:

## ① 「不會這裡又寫了一個新的 CSS?」——是,我又寫了一份
真兇:portal 的樣式內嵌在 HTML 裡,沒有獨立檔 ⇒ 四個站各抄一份、本來就在漂移。
解:抽出 InkStoneCo/arcrun-cis/css/arcrun-cis.css 當唯一真相源。
    daemon 的 style.css 現在**一個 hex 都沒有**,顏色全走 var(--...)。

## ② 「完全模仿 gdrive,左方側邊欄,在右方替換頁面」
底部 nav bar 拿掉(leo:「幾乎沒看過這種 nav bar 在下方的」)。
改成左側邊欄四頁:首頁/同步資料夾/AI 設定/版本與更新,右側換頁。
設定不再開 popup;只有「確認移除資料夾」才用覆蓋層。

## ③ 「既然是 Web,你做的時候無法檢視?」——可以,我之前沒做
新增 check-render.sh:headless Chrome 真的渲染一次並**量像素**。
它抓到一個肉眼容易誤判的真 bug:
  arcrun-lockup-h-paper-on-ink.png **自帶不透明 Ink 底板**(實測左上像素 RGBA=23,24,26,255),
  設計前提是貼在純 Ink 表面;我們背景有紙張紋理 ⇒ 出現突兀方塊。
  修:深色模式把品牌區做成純 Ink。實測落差 4(原本會是一個明顯方塊)。
過程中也發現自己對著**舊的 dist**除錯,浪費一輪——這支閘同時防這個。

## ④ 「檢查更新連到 docs,不正確,是直接在這裡進行」
新增「版本與更新」頁:顯示**現在版本 vs 最新版本**,
三段式全在 App 內完成——檢查/下載並安裝/重新啟動套用。
selfupdate 邏輯逐字沿用 arcrun-tray(含 t186 走 raw、t184 蓋正在跑的 .app),不重寫。

## 交貨閘(leo:「你做為檢查有嗎?沒有怎麼交貨?」)
check-cis.sh 擴充:版面檔不准出現任何 hex + 共用底層色票逐項比對。
兩支閘實跑全過。
2026-08-04 23:09:54 +08:00

304 lines
9.9 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.
package main
// app.go — Arcrun 桌面 App 的後端(t193
//
// 🔴 為什麼從 fyne 換到 Wailsleo 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"` // 首頁「已整理幾份」
}
// 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)
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)
return saveCfg(cfg)
}
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
return saveCfg(cfg)
}
// 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
}
return saveCfg(cfg)
}
// OpenURL 用系統瀏覽器開網址(下載頁/說明文件)。
func (a *App) OpenURL(u string) { runtime.BrowserOpenURL(a.ctx, u) }