f4eb3c9fa1
leo 08-06 Mac 實測回報。四個都是**只在 Mac 現形**的病。
## ① Dock 與選單列兩邊都顯示(連帶:只有 Arcrun 列在強制結束清單)
真兇:Wails 在 AppDelegate.m:44 applicationWillFinishLaunching 硬設
`setActivationPolicy:Regular`,**runtime 蓋掉 Info.plist 的 LSUIElement**
(後者只決定啟動當下的初始 policy)。而 v2.13 的 mac.ActivationPolicy 是註解掉的。
🔴 **這件事 fyne 版 604704a(07-24)已經解過且真機驗證通過**,換 Wails 時整段掉了;
wiki mistakes.md:124 也早就記著同一條。**本次是取回原版,不是重新發明**——
dock_darwin.go/dock_other.go 逐字沿用,只更新註解說明 Wails 死法相同。
## ② 托盤右鍵「結束 Arcrun」點了不會結束
leo:「唯一結束法是強制結束…因為舊版沒結束導致無法覆蓋,
**如果不知道怎麼強制結束的人就放棄了**」
真兇:Wails 的 runtime.Quit() 與「按 ×」**共用同一個 OnBeforeClose**
(frontend.go:364 `if !OnBeforeClose(ctx) { mainWindow.Quit() }`),
我們無條件回 true ⇒ mainWindow.Quit() 永遠不執行,還順手 WindowHide
⇒ 看起來像關了、其實行程還活著。
(AppDelegate.m:41 的 applicationShouldTerminate 也一律回 NSTerminateCancel,
決定權整個在這個回呼 ⇒ 回 true 就沒有任何出口。)
解:quitting 旗標區分兩者,並抽出 closeMeansHide()/beginQuit() 讓它**可機械驗證**。
## ③ Mac 全螢幕(綠色)按鈕被停用
真兇同樣在 Wails:window.go:92 `zoomable` 只在 `frontendOptions.Mac != nil` 時賦值,
我們沒設 Mac ⇒ zoomable 維持零值 false ⇒ WailsContext.m:208
`if (!zoomable && resizable) { [zoomButton setEnabled:NO]; }`。Windows 無此段。
解:`Mac: &mac.Options{}`(空的即可,DisableZoom 預設 false)。
## ④ 視窗 50% 太小
leo:「Google Drive 大約螢幕長寬的 70%,改成 50% 後顯得太小」⇒ 常數 screenFraction=0.70。
並加 appLog 留痕(螢幕多大、算出多大),以後不必請人量像素。
## 驗(本機真機實跑,非推測)
· `lsappinfo` → **type="UIElement"**、Version="0.18.8"
= 604704a 當年用的同一個驗法,通過 ⇒ Dock 無 icon、不列強制結束
· 截圖放大號誌燈 → **綠燈是彩色的(enabled)**,停用時會是灰色
· app.log 自報 → **視窗依螢幕 1470x956 設為 1029x669(70%)**
· 首頁顯示「看守中·資料夾有變動就會自動整理/已整理 1 份」=判活正常
· go test TestCloseMeansHide 通過(釘住「按×要隱藏、按結束要放行」)
· go vet 全綠;mac build + windows 交叉編譯皆 OK
· **未驗**:托盤右鍵實際點擊(osascript 缺輔助取用權限,點不到選單列)
⇒ 邏輯已單元測試+原始碼追到底,但**實際點擊歸 leo 真機**
532 lines
20 KiB
Go
532 lines
20 KiB
Go
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"`
|
||
// 「上次真的有做事」那一輪(2026-08-05)——ExtractedOK 是本輪計數、會被下一輪歸零,
|
||
// 只靠它顯示成果,使用者在同步完成 15 秒後就看不到任何證據(leo 實撞)。
|
||
LastActivityAt string `json:"last_activity_at,omitempty"`
|
||
LastActivityOK int `json:"last_activity_ok"`
|
||
LastActivityFailed int `json:"last_activity_failed"`
|
||
// G-6.2(2026-08-06):collector 讀不了、因此整個跳過的檔案。
|
||
// 以前這些檔在 scan 的白名單閘就無聲蒸發,使用者只看到「什麼都沒發生」。
|
||
SkippedDocs []skippedDoc `json:"skipped_docs,omitempty"`
|
||
SkippedDocCount int `json:"skipped_doc_count"`
|
||
SkippedOtherCount int `json:"skipped_other_count"`
|
||
}
|
||
|
||
type skippedDoc struct {
|
||
Path string `json:"path"`
|
||
Ext string `json:"ext"`
|
||
}
|
||
|
||
// extLabel 把副檔名翻成使用者認得的東西。
|
||
// 使用者不會因為看到「.pages」就懂,但看到「Pages」會——那是他自己按存檔時的名字。
|
||
func extLabel(ext string) string {
|
||
switch strings.ToLower(ext) {
|
||
case ".doc":
|
||
return "舊版 Word"
|
||
case ".xls":
|
||
return "舊版 Excel"
|
||
case ".ppt":
|
||
return "舊版 PowerPoint"
|
||
case ".pages":
|
||
return "Pages"
|
||
case ".numbers":
|
||
return "Numbers"
|
||
case ".key":
|
||
return "Keynote"
|
||
case ".odt", ".ods", ".odp":
|
||
return "OpenDocument"
|
||
case ".rtf":
|
||
return "RTF"
|
||
case ".epub":
|
||
return "EPUB"
|
||
case ".msg", ".eml":
|
||
return "郵件檔"
|
||
case ".wpd":
|
||
return "WordPerfect"
|
||
}
|
||
return strings.TrimPrefix(ext, ".")
|
||
}
|
||
|
||
// 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)
|
||
Skipped *UISkipped `json:"skipped"` // 讀不了的檔(沒有就是 null,前端不畫)
|
||
}
|
||
|
||
// UISkipped=首頁那張「這些檔案現在還處理不了」的卡。
|
||
//
|
||
// 🔴 存在理由(J-1/S6 考題 G-6.2):
|
||
//
|
||
// 「Given 我丟進去的是 PDF 或 Word/When 我搜它的內容/
|
||
// Then 我一樣找得到——**或當場被告知這種檔案還不支援**,不准安靜地略過。」
|
||
//
|
||
// 後半句在這裡兌現。文字一律白話:講「你的哪個檔沒進去」「你要不要做什麼」,
|
||
// 不講 allowedExt、副檔名白名單、extractor 這些系統內部詞。
|
||
type UISkipped struct {
|
||
Title string `json:"title"` // 「有 3 個檔案現在還讀不了」
|
||
Note string `json:"note"` // 該不該做什麼——這裡的答案是「不用,之後會自動補上」
|
||
Files []string `json:"files"` // 「舊版報告.doc(舊版 Word)」
|
||
More int `json:"more"` // 沒列出來的還有幾個
|
||
Other string `json:"other"` // 非文件檔的一行說明(沒有就空字串)
|
||
}
|
||
|
||
// buildSkipped 把 status.json 的三個欄位翻成首頁看得懂的一張卡。
|
||
// 完全沒有東西被略過時回 nil ⇒ 前端不畫這張卡(沒事就別佔畫面)。
|
||
func buildSkipped(s syncStatus) *UISkipped {
|
||
if s.SkippedDocCount == 0 && s.SkippedOtherCount == 0 {
|
||
return nil
|
||
}
|
||
u := &UISkipped{}
|
||
for _, d := range s.SkippedDocs {
|
||
u.Files = append(u.Files, fmt.Sprintf("%s(%s)", filepath.Base(d.Path), extLabel(d.Ext)))
|
||
}
|
||
if n := s.SkippedDocCount - len(s.SkippedDocs); n > 0 {
|
||
u.More = n
|
||
}
|
||
if s.SkippedDocCount > 0 {
|
||
u.Title = fmt.Sprintf("有 %d 個檔案現在還讀不了", s.SkippedDocCount)
|
||
// 誠實地告訴他「這不是你的錯,也不用你動手」——否則使用者會反覆重丟同一個檔。
|
||
u.Note = "這些格式我們還沒支援,所以沒有進你的知識庫。等支援了會自動補上,你不用重丟。" +
|
||
"急著要的話,先用原本的軟體另存成 PDF 或 Word(.docx)放進同一個資料夾就行。"
|
||
} else {
|
||
// 只有非文件檔的情況(例如整個資料夾都是照片)——不需要驚動他,但也不能不說。
|
||
u.Title = "有些檔案沒有被整理"
|
||
u.Note = "看起來不是文件,所以跳過了。"
|
||
}
|
||
if s.SkippedOtherCount > 0 {
|
||
u.Other = fmt.Sprintf("另外有 %d 個不是文件的檔案(圖片、影片、壓縮檔之類)也沒有處理。",
|
||
s.SkippedOtherCount)
|
||
}
|
||
return u
|
||
}
|
||
|
||
// 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: ""},
|
||
}
|
||
}
|
||
// 🔴 2026-08-05:這三步以前只要「跑過任何一輪」(LastSync 非空)就全標綠,
|
||
// 而 meta 用的是**本輪**計數(下一輪歸零)⇒ 綠燈與實際進度脫鉤、數字又永遠空白,
|
||
// 正是 leo 說的「都顯示綠燈沒動,實際上已經做完了」。
|
||
// ⇒ 綠燈只在「真的有整理過東西」時才亮,且亮的是上次有產出那輪的數字+時間。
|
||
done := ""
|
||
if s.LastActivityOK > 0 || s.LastActivityFailed > 0 {
|
||
done = "done"
|
||
}
|
||
found := "等待中"
|
||
if done != "" {
|
||
if t, err := time.Parse(time.RFC3339, s.LastActivityAt); err == nil {
|
||
found = "上次 " + t.Local().Format("15:04")
|
||
} else {
|
||
found = "已處理"
|
||
}
|
||
}
|
||
meta := ""
|
||
if s.LastActivityOK > 0 {
|
||
meta = fmt.Sprintf("上次 %d 份", s.LastActivityOK)
|
||
}
|
||
up := Step{Title: "上傳到你的知識庫", Meta: meta, State: done}
|
||
if s.LastActivityFailed > 0 {
|
||
up.Meta = strings.TrimPrefix(fmt.Sprintf("%s · ⚠ %d 份失敗", meta, s.LastActivityFailed), " · ")
|
||
}
|
||
return []Step{
|
||
watch,
|
||
{Title: "發現變化", Meta: found, 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)
|
||
st.Skipped = buildSkipped(sync)
|
||
return st
|
||
}
|
||
|
||
func loadSyncStatus() syncStatus {
|
||
var s syncStatus
|
||
if b, err := os.ReadFile(statusPath()); err == nil {
|
||
_ = json.Unmarshal(b, &s)
|
||
}
|
||
return s
|
||
}
|
||
|
||
// describeStatus 產生狀態文案。
|
||
// 🔴 t195(leo 08-05 實撞:「燈號是真的還是假的?」——**是假的**):
|
||
//
|
||
// 舊版只看「sync-now 訊號檔存不存在」就顯示「同步中…」。
|
||
// 但那只代表**排隊了**,不代表有人在處理:leo 的 collector 在 11:25 死掉,
|
||
// 他 11:38 按同步 ⇒ 訊號檔沒人消化 ⇒ 畫面一直說「正在整理知識卡」,
|
||
// 實際上 13 分鐘沒跑過任何一輪、也不會產卡。**這比沒有燈號更糟——它在說謊。**
|
||
//
|
||
// ⇒ 燈號改成有憑有據:
|
||
// · collector 沒在跑 → 明說「同步引擎沒有在跑」,不要假裝在整理
|
||
// · 有訊號檔且引擎活著 → 才是真的「同步中」
|
||
// · 其餘 → 看守中
|
||
//
|
||
// 🔴 2026-08-05 第二修(leo:「拖新檔進資料夾…自始至終都顯示『等待中』,
|
||
//
|
||
// 實際上已經做完了,這個 status 是壞的」)——t195 只補了「引擎活著」那半,
|
||
// 「同步中」仍靠 sync-now 訊號檔判斷,那是錯的判準,兩個理由:
|
||
// ① 訊號檔**只有手動按「立刻同步」才會產生**;leo 這次是拖檔進資料夾
|
||
// (自動觸發),整輪從頭到尾沒有訊號檔 ⇒ 畫面永遠停在「看守中/等待中」。
|
||
// ② 就算是手動按的,collector 是**先刪檔再跑**(consumeSyncNowSignal),
|
||
// 所以真正在跑的那段時間訊號檔早就不見了。
|
||
// ⇒ 改用 t191 已經做好的機制:collector 開工印 phase:"start"、跑完印 "done",
|
||
// supervisor 據此維護 StateSyncing(supervisor.go)。**那條線本來就在,
|
||
// Wails 版換代時沒接上而已**——不要再自己發明第三種判斷法。
|
||
func describeStatus(s syncStatus) (syncing bool, big, sub string) {
|
||
if !collectorAlive() {
|
||
return false, "同步引擎沒有在跑", "資料夾不會被自動整理 ⇒ 請結束 Arcrun 再重新開啟"
|
||
}
|
||
if collectorSyncing() {
|
||
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"))
|
||
}
|
||
// 用「上次真的有做事」那輪的數字,不用本輪計數——後者每輪歸零,
|
||
// 會讓剛整理完的成果在十幾秒後從畫面上消失(就是 leo 撞到的那個「壞掉的 status」)。
|
||
if s.LastActivityOK > 0 || s.LastActivityFailed > 0 {
|
||
when := ""
|
||
if t, err := time.Parse(time.RFC3339, s.LastActivityAt); err == nil {
|
||
when = t.Local().Format("15:04") + " "
|
||
}
|
||
if s.LastActivityOK > 0 {
|
||
parts = append(parts, fmt.Sprintf("%s已整理 %d 份", when, s.LastActivityOK))
|
||
}
|
||
if s.LastActivityFailed > 0 {
|
||
parts = append(parts, fmt.Sprintf("⚠ %d 份失敗", s.LastActivityFailed))
|
||
}
|
||
}
|
||
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() {
|
||
// 🔴 2026-08-06:先舉旗再喊退。沒有這一步,OnBeforeClose 會把 runtime.Quit()
|
||
// 當成「按 ×」攔下來(Wails frontend.go:364 兩者共用同一個回呼)
|
||
// ⇒ leo 實撞:托盤右鍵「結束 Arcrun」點了沒反應,只能強制結束,
|
||
// 導致下載的新版覆蓋不掉還活著的舊版。
|
||
beginQuit()
|
||
stopSupervisor()
|
||
runtime.Quit(a.ctx)
|
||
}
|