diff --git a/cmd/arcrun-tray/main_test.go b/cmd/arcrun-tray/main_test.go index 3683d96..4238bf7 100644 --- a/cmd/arcrun-tray/main_test.go +++ b/cmd/arcrun-tray/main_test.go @@ -486,33 +486,45 @@ func TestAddOrUpdateAccountPreservesMachineLayer(t *testing.T) { // ── t126:每帳號獨立引擎設定 ────────────────────────────────────────────────── -// t126①:accountEngineLabel 依帳號或 defaultExtractor 顯示引擎名(帳號層優先)。 +// t126①→t182 改版:accountEngineLabel 的判準是 **explicit(使用者主動選過沒有)**, +// 不是 config 裡殘留什麼字串。 +// +// 為什麼改(leo 08-04 實撞):更新到 v0.15.5 後托盤**兩個帳號都還顯示 Gemini**, +// 因為他 config 的帳號層留著 extractor="gemma"、而 explicit 沒設。 +// t178 只把 `claude` 這一個殘留值導向 Gemini,殘留 `gemma` 一樣脫鉤 ⇒ t182 改成通則: +// **沒主動選過就一律念「雲端 AI」(空字串),與 direct.go 的預設邏輯同一條判準。** func TestAccountEngineLabel(t *testing.T) { cases := []struct { name string acc accountCfg defaultEx string + explicit bool want string }{ - {"帳號有gemma", accountCfg{Extractor: "gemma"}, "", " · Gemini"}, - // t178(leo 08-04 實撞:朋友托盤顯示「oscar · Claude」但他根本沒有 Claude): - // **claude 也顯示 Gemini**——因為 direct.go 早就把 claude 正規化成 gemma(t176)。 - // 標籤照 config 舊字串念會與實際行為不符:用戶看到 Claude、卻收到「Gemini 金鑰是空的」。 - // 這兩則若變回「· Claude」,代表標籤又和萃取實際走的路脫鉤了。 - {"帳號有claude→仍顯示 Gemini(實際就是走 gemma)", accountCfg{Extractor: "claude"}, "", " · Gemini"}, - {"帳號空退回default-gemma", accountCfg{}, "gemma", " · Gemini"}, - {"帳號空退回default-claude→仍顯示 Gemini", accountCfg{}, "claude", " · Gemini"}, - // t181:兩者皆空=新用戶=走 workers-ai(免金鑰預設)⇒ **不顯示標籤** - // (leo 08-04:「上方如果用 workers AI 就不顯示」——預設路徑是常態,不佔版面) - {"兩者皆空=預設雲端 AI=不顯示", accountCfg{}, "", ""}, - {"t181 明確 workers-ai 也不顯示", accountCfg{Extractor: "workers-ai"}, "", ""}, - {"帳號層優先於default", accountCfg{Extractor: "gemma"}, "claude", " · Gemini"}, + // ── 沒主動選過(explicit=false):不管殘留什麼,一律不顯示(=走 workers-ai)── + // 這一組就是 leo 實撞的情境:config 殘留 gemma/claude,但他從沒去「AI 設定…」選過。 + {"殘留gemma但沒選過→不顯示", accountCfg{Extractor: "gemma"}, "", false, ""}, + {"殘留claude但沒選過→不顯示", accountCfg{Extractor: "claude"}, "", false, ""}, + {"default殘留gemma但沒選過→不顯示", accountCfg{}, "gemma", false, ""}, + {"兩者皆空=新用戶→不顯示", accountCfg{}, "", false, ""}, + + // ── 主動選過(explicit=true):照實顯示 ── + // Gemini 是選配(leo:「客戶說他要用 Gemini,但現在變成選配」)⇒ 選了就要標出來。 + {"主動選gemma→顯示 Gemini", accountCfg{Extractor: "gemma"}, "", true, " · Gemini"}, + // t178 保留:claude 也念 Gemini——direct.go 早把 claude 正規化成 gemma(t176)。 + // 若這則變回「· Claude」,代表標籤又和萃取實際走的路脫鉤了。 + {"主動選過但殘留claude→仍顯示 Gemini", accountCfg{Extractor: "claude"}, "", true, " · Gemini"}, + {"帳號空退回default-gemma", accountCfg{}, "gemma", true, " · Gemini"}, + {"帳號層優先於default", accountCfg{Extractor: "gemma"}, "claude", true, " · Gemini"}, + // t181:明確選 workers-ai 也不顯示(leo:「用 workers AI 就不顯示」) + {"主動選 workers-ai→不顯示", accountCfg{Extractor: "workers-ai"}, "", true, ""}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - got := accountEngineLabel(c.acc, c.defaultEx) + got := accountEngineLabel(c.acc, c.defaultEx, c.explicit) if got != c.want { - t.Errorf("accountEngineLabel(%+v, %q) = %q, want %q", c.acc, c.defaultEx, got, c.want) + t.Errorf("accountEngineLabel(%+v, %q, explicit=%v) = %q, want %q", + c.acc, c.defaultEx, c.explicit, got, c.want) } }) } diff --git a/cmd/arcrun-tray/selfupdate.go b/cmd/arcrun-tray/selfupdate.go index 6f54402..19c6eee 100644 --- a/cmd/arcrun-tray/selfupdate.go +++ b/cmd/arcrun-tray/selfupdate.go @@ -281,7 +281,20 @@ func applyStagedAndRestart() error { if _, err := os.Stat(newApp); err != nil { return fmt.Errorf("更新檔內容不符(找不到 Arcrun RAG.app)") } - target := "/Applications/Arcrun RAG.app" + // 🔴 t184(leo 08-04 實撞:Oscar 按更新→顯示「新版 v0.15.6 已就緒」→重啟後**又跳回 v0.15.4**): + // + // 原本寫死 `/Applications/Arcrun RAG.app`。但使用者**不一定把 app 放在 Applications** + // (Oscar 就是從下載資料夾/桌面直接跑)。這時 `ditto` 會**自動建出** + // `/Applications/Arcrun RAG.app` 並**回傳成功**(實測 exit 0,不是報錯) + // ⇒ 新版被寫到一個他根本沒在跑的路徑,他重開的還是原地那份舊的 + // ⇒ 畫面說「更新完成」、版本卻永遠是舊的=**靜默失敗**,最難查的那種。 + // + // 改成更新**當前這個真的在跑的 app**(os.Executable() 往上推 .app): + // 放哪都能更新,也不再無中生有一個 /Applications 的副本。 + target, err := runningAppBundlePath() + if err != nil { + return err + } if out, err := runCmd("ditto", newApp, target); err != nil { return fmt.Errorf("覆蓋失敗(可能需要權限):%v %s", err, out) } @@ -295,6 +308,30 @@ func applyStagedAndRestart() error { return nil } +// runningAppBundlePath 回傳**現在正在跑的**那個 .app 路徑(t184)。 +// +// 路徑長相:<某處>/Arcrun RAG.app/Contents/MacOS/arcrun-tray +// ⇒ 從執行檔往上三層就是 .app 本體。 +// +// 為什麼不寫死 /Applications:使用者常常就地執行(下載資料夾/桌面/隨身碟), +// 寫死會把新版蓋到他沒在跑的地方,而且 ditto 還會「成功」(見 applyStagedAndRestart 的註解)。 +// 找不到 .app 結構時**誠實回錯**,不要猜一個路徑亂蓋——蓋錯地方比不更新更難查。 +func runningAppBundlePath() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", fmt.Errorf("找不到自己的執行檔位置:%w", err) + } + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved // 走過 symlink 才是真身 + } + // .../Arcrun RAG.app/Contents/MacOS/arcrun-tray → 上三層 + app := filepath.Dir(filepath.Dir(filepath.Dir(exe))) + if filepath.Ext(app) != ".app" { + return "", fmt.Errorf("這份 Arcrun RAG 不是從 .app 啟動的(%s)⇒ 請改用下載頁的 .app 版本再更新", exe) + } + return app, nil +} + // updateMenuLabel 回傳選單該顯示什麼(三種狀態,使用者一眼看懂要做什麼)。 // 回傳 ("", false) 代表不顯示這個項目。 func updateMenuLabel() (string, bool) { diff --git a/cmd/arcrun-tray/t184_test.go b/cmd/arcrun-tray/t184_test.go new file mode 100644 index 0000000..aaf1363 --- /dev/null +++ b/cmd/arcrun-tray/t184_test.go @@ -0,0 +1,55 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// t184:更新必須蓋在「正在跑的那個 .app」,不是寫死 /Applications。 +// leo 08-04 實撞:Oscar 從下載資料夾跑 → ditto 自動建 /Applications 副本並回成功 +// → 他重開仍是舊版,畫面卻說更新完成(靜默失敗)。 +func TestRunningAppBundlePathNotHardcoded(t *testing.T) { + got, err := runningAppBundlePath() + // 測試執行檔不在 .app 裡 → 應誠實回錯,**不可**回傳寫死的 /Applications 路徑 + if err == nil && strings.HasPrefix(got, "/Applications/") { + t.Errorf("不該回寫死的 /Applications 路徑:%q", got) + } + if err == nil && !strings.HasSuffix(got, ".app") { + t.Errorf("回傳的不是 .app:%q", got) + } +} + +// 模擬真實 .app 結構:/Arcrun RAG.app/Contents/MacOS/arcrun-tray +// 驗證「往上三層」的推導正確——放在哪個目錄都要算得出來。 +func TestAppBundleDerivationFromExePath(t *testing.T) { + for _, base := range []string{"/Applications", "/Users/oscar/Downloads", "/Volumes/USB"} { + exe := filepath.Join(base, "Arcrun RAG.app", "Contents", "MacOS", "arcrun-tray") + app := filepath.Dir(filepath.Dir(filepath.Dir(exe))) + want := filepath.Join(base, "Arcrun RAG.app") + if app != want { + t.Errorf("從 %s 推導錯:got %q want %q", base, app, want) + } + } +} + +// ditto 對不存在的目標會自動建目錄且回成功——這正是靜默失敗的成因,留測防回歸認知。 +func TestDittoCreatesMissingTargetSilently(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "Fake.app", "Contents") + if err := os.MkdirAll(src, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "x"), []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + dst := filepath.Join(dir, "nowhere", "Fake.app") + if _, err := runCmd("ditto", filepath.Join(dir, "Fake.app"), dst); err != nil { + t.Skipf("此環境沒有 ditto:%v", err) + } + if _, err := os.Stat(dst); err != nil { + t.Fatal("前提失效:ditto 應自動建出目標") + } + t.Log("確認:ditto 蓋到不存在的路徑會成功建出 ⇒ 寫死路徑必然靜默失敗") +}