diff --git a/cmd/arcrun-app/main.go b/cmd/arcrun-app/main.go index 148d23f..623162e 100644 --- a/cmd/arcrun-app/main.go +++ b/cmd/arcrun-app/main.go @@ -59,6 +59,10 @@ func main() { os.Exit(collector.Run(os.Args[2:])) } + // 清掉上一輪 Windows 自我更新留下的 `.old`(見 selfupdate.go cleanupOldExe)。 + // 必須在這裡(新行程剛啟動、舊行程已結束)才刪得掉;Mac 上是 no-op。 + cleanupOldExe() + app := NewApp() // 🔴 托盤必須在**主執行緒**建立(NSStatusItem 內部會 new NSWindow, diff --git a/cmd/arcrun-app/selfupdate.go b/cmd/arcrun-app/selfupdate.go index f2e7637..7e94a1c 100644 --- a/cmd/arcrun-app/selfupdate.go +++ b/cmd/arcrun-app/selfupdate.go @@ -280,8 +280,9 @@ func startUpdateWatcher() { // applyStagedAndRestart 套用已備妥的更新並重啟。 // 只有使用者按下「重新啟動以完成更新」才會走到這裡(不偷偷替換正在跑的自己)。 // -// 做法:解壓到暫存 → 用 ditto 覆蓋 /Applications/Arcrun.app → 重新 open → 結束自己。 -// 失敗時保留原版不動(寧可停在舊版,也不要弄出開不起來的 .app)。 +// 做法(Mac):解出 .app(見 extractAppFrom)→ 用 ditto 覆蓋正在跑的那份 → 重新 open → 結束自己。 +// 做法(Windows):見 applyWindowsUpdateAndRestart(rename 正在跑的 exe,標準自我更新手法)。 +// 失敗時保留原版不動(寧可停在舊版,也不要弄出開不起來/開不動的執行檔)。 func applyStagedAndRestart() error { s := loadStaged() if !s.Ready || s.ZipPath == "" { @@ -291,20 +292,16 @@ func applyStagedAndRestart() error { return fmt.Errorf("更新檔不見了,請再檢查一次更新") } if isWindows() { - // Windows 版走各自的安裝流程(本階段先開資料夾讓使用者取用)。 - return openPath(filepath.Dir(s.ZipPath)) + return applyWindowsUpdateAndRestart(s.ZipPath) } work := updateStagePath("unpack") _ = os.RemoveAll(work) if err := os.MkdirAll(work, 0o755); err != nil { return err } - if out, err := runCmd("ditto", "-x", "-k", s.ZipPath, work); err != nil { - return fmt.Errorf("解壓失敗:%v %s", err, out) - } - newApp := filepath.Join(work, "Arcrun.app") - if _, err := os.Stat(newApp); err != nil { - return fmt.Errorf("更新檔內容不符(找不到 Arcrun.app)") + newApp, err := extractAppFrom(s.ZipPath, work) + if err != nil { + return err } // 🔴 t184(leo 08-04 實撞:Oscar 按更新→顯示「新版 v0.15.6 已就緒」→重啟後**又跳回 v0.15.4**): // @@ -357,6 +354,55 @@ func runningAppBundlePath() (string, error) { return app, nil } +// extractAppFrom 從更新檔解出 Arcrun.app,回傳其路徑。依副檔名分流。 +// +// 🔴 2026-08-08(leo 真機實測撞到):出貨格式從 zip 換成了 dmg(t194 `build-dmg.sh`), +// +// 但這裡原本**只認 zip**(`ditto -x -k` 是解 zip 專用的參數) +// ⇒ 拿 dmg 餵它 ⇒ `ditto: Couldn't read PKZip signature` +// ⇒ 「檢查更新」從 v0.18.5 斷到 v0.18.22,至少七代都沒人發現 +// (詳見 wiki mistakes.md「出貨格式換了,消費它的程式沒換」)。 +// ⇒ 依副檔名分流,兩種格式都吃得下——manifest 未來換回 zip 也不會再斷。 +func extractAppFrom(path, work string) (string, error) { + switch strings.ToLower(filepath.Ext(path)) { + case ".dmg": + return extractAppFromDMG(path, work) + case ".zip": + if out, err := runCmd("ditto", "-x", "-k", path, work); err != nil { + return "", fmt.Errorf("解壓失敗:%v %s", err, out) + } + app := filepath.Join(work, "Arcrun.app") + if _, err := os.Stat(app); err != nil { + return "", fmt.Errorf("更新檔內容不符(找不到 Arcrun.app)") + } + return app, nil + default: + return "", fmt.Errorf("不認得的更新檔格式(%s):非 .dmg 也非 .zip", filepath.Ext(path)) + } +} + +// extractAppFromDMG 掛載 dmg、把裡面的 Arcrun.app 複製到 work 目錄下、卸載。 +// 掛載點用固定路徑(`-mountpoint`),不必解析 hdiutil 的文字輸出去猜掛去哪。 +func extractAppFromDMG(dmgPath, work string) (string, error) { + mount := filepath.Join(work, "mnt") + if err := os.MkdirAll(mount, 0o755); err != nil { + return "", err + } + if out, err := runCmd("hdiutil", "attach", dmgPath, "-nobrowse", "-noautoopen", "-mountpoint", mount); err != nil { + return "", fmt.Errorf("掛載 DMG 失敗:%v %s", err, out) + } + defer func() { _, _ = runCmd("hdiutil", "detach", mount, "-quiet") }() + src := filepath.Join(mount, "Arcrun.app") + if _, err := os.Stat(src); err != nil { + return "", fmt.Errorf("DMG 裡找不到 Arcrun.app") + } + dst := filepath.Join(work, "Arcrun.app") + if out, err := runCmd("ditto", src, dst); err != nil { + return "", fmt.Errorf("從 DMG 複製失敗:%v %s", err, out) + } + return dst, nil +} + // runCmd 執行外部命令並回傳合併輸出(本檔自用;package 內先前沒有同類 helper)。 func runCmd(name string, args ...string) (string, error) { cmd := exec.Command(name, args...) @@ -364,12 +410,94 @@ func runCmd(name string, args ...string) (string, error) { return string(out), err } -// openPath 用系統預設方式開啟路徑(Windows 版更新暫以「開資料夾」收尾)。 -func openPath(p string) error { - if isWindows() { - _, err := runCmd("cmd", "/c", "start", "", p) +// ── Windows 自我更新(2026-08-08,leo 補充事實後改寫)───────────────────────── +// +// 舊版本這裡只是「開資料夾讓使用者取用」,理由寫著「Windows 版走各自的安裝流程」。 +// leo 08-08 點破:那是併檔前(v0.18.9 之前)的殘留假設—— +// +// 「Windows 沒有資料夾,它現在是 exe,單一的。」 +// +// v0.18.9(4a26856)已把 collector 併進同一支執行檔,磁碟上只有單一 Arcrun.exe。 +// 「開資料夾」現在等於叫使用者自己去找那支 exe 手動換掉——正是要消滅的手動步驟。 +// +// Windows 不能覆寫「正在執行中」的 exe(內容被鎖),但**可以 rename 它**—— +// 執行中的映像檔預設以 FILE_SHARE_DELETE 開啟,rename/delete 不受阻, +// 這是 Windows 自我更新程式的標準手法(VSCode/Chrome 等同款做法): +// 1. 把正在跑的 Arcrun.exe rename 成 Arcrun.exe.old(不影響正在跑的行程) +// 2. 把新版 exe 搬到原本的路徑 +// 3. 用原路徑重新啟動 → 結束自己 +// 4. 下次啟動時清掉殘留的 .old(此時舊行程已結束、沒人持鎖,見 cleanupOldExe) +// +// 任一步失敗就誠實回錯、盡量復原,不留「舊的不見了、新的沒建好」的半套狀態。 +func applyWindowsUpdateAndRestart(newExePath string) error { + cur, err := os.Executable() + if err != nil { + return fmt.Errorf("找不到自己的執行檔位置:%w", err) + } + if resolved, err := filepath.EvalSymlinks(cur); err == nil { + cur = resolved + } + old := cur + ".old" + _ = os.Remove(old) // 清掉上一輪沒清乾淨的殘留(不擋主流程) + if err := os.Rename(cur, old); err != nil { + return fmt.Errorf("換掉正在跑的執行檔失敗(rename):%w", err) + } + if err := moveFile(newExePath, cur); err != nil { + // 盡量復原,不留「舊的不見了、新的沒建好」的半套狀態 + _ = os.Rename(old, cur) + return fmt.Errorf("寫入新版執行檔失敗:%w", err) + } + _ = os.Remove(stagedMarkerPath()) + currentStaged = stagedUpdate{} + // `cmd /c start` 只負責喚起新行程就回傳,不會等新行程跑完(與 mac 的 `open -n` 同款行為)。 + if out, err := runCmd("cmd", "/c", "start", "", cur); err != nil { + // 復原:新版已經在原路徑了,重啟失敗至少別把舊的删了拿不回來 + return fmt.Errorf("重新啟動失敗:%v %s(新版已就緒於 %s,可手動雙擊開啟)", err, out, cur) + } + go func() { time.Sleep(2 * time.Second); os.Exit(0) }() + return nil +} + +// cleanupOldExe 清掉上一輪更新留下的 `.old`(Windows 專用)。 +// 呼叫時機=App 啟動時:這時**新行程剛啟動、舊行程已經結束**,.old 不再被任何人持鎖, +// 才是唯一保證刪得掉的時間點(更新當下 rename 完就想刪,舊行程還沒真的死,會失敗)。 +// 找不到/刪不掉都不當錯誤處理——它只是暫存垃圾,留著不影響功能,別讓清理步驟擋住啟動。 +func cleanupOldExe() { + if !isWindows() { + return + } + exe, err := os.Executable() + if err != nil { + return + } + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved + } + _ = os.Remove(exe + ".old") +} + +// moveFile 把 src 搬到 dst:同磁碟區走 os.Rename(原子、快), +// 跨磁碟區(rename 回傳 err,例如更新暫存區跟安裝目錄不同槽)退回複製後砍來源。 +func moveFile(src, dst string) error { + if err := os.Rename(src, dst); err == nil { + return nil + } + in, err := os.Open(src) + if err != nil { return err } - _, err := runCmd("open", p) - return err + defer in.Close() + out, err := os.Create(dst) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + return err + } + if err := out.Close(); err != nil { + return err + } + _ = os.Remove(src) + return nil } diff --git a/cmd/arcrun-app/selfupdate_test.go b/cmd/arcrun-app/selfupdate_test.go new file mode 100644 index 0000000..bf3121a --- /dev/null +++ b/cmd/arcrun-app/selfupdate_test.go @@ -0,0 +1,234 @@ +package main + +// selfupdate_test.go — 2026-08-08 leo 真機實測撞到「檢查更新」壞掉兩個症狀後補的迴歸測試。 +// +// 症狀一:manifest.daemon.mac.file 出貨格式從 zip 換成 dmg,但 applyStagedAndRestart +// +// 只認 zip(ditto -x -k)⇒ 拿 dmg 餵它 ⇒ ditto: Couldn't read PKZip signature, +// Mac 自我更新從 v0.18.5 斷到 v0.18.22(至少七代)。 +// +// 症狀二:手動裝好新版後按「檢查更新」,仍顯示「已下載完成,重新啟動就會套用」—— +// +// 殘留的 staged.json 沒人清,判斷依據是「有沒有下載過」而不是「目前版本 vs 最新版本」。 +// +// 本檔測兩件事的迴歸: +// 1. extractAppFrom 兩種格式(.dmg/.zip)都真的解得出 Arcrun.app(用真的 hdiutil/ditto, +// 不是 mock——症狀一就是「編譯期看起來對,跑真檔案才炸」,mock 測不出來)。 +// 2. stagedDecision 的四種版本組合,涵蓋症狀二的所有分支。 + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" +) + +// ── 症狀二:staged 旗標的判準要是「當前 vs 最新」,不是「有沒有下載過」 ────────── + +func TestStagedDecision(t *testing.T) { + cases := []struct { + name string + available bool + stagedVersion string + latestVersion string + wantReport bool + wantClear bool + }{ + { + name: "已經追上(leo 08-08 實測的那個病):手動裝好新版,殘留 staged 版本剛好等於 latest", + available: false, // newerThanCurrent(latest) == false ⇒ 目前版本已經是最新 + stagedVersion: "v0.18.22", + latestVersion: "v0.18.22", + wantReport: false, + wantClear: true, + }, + { + name: "真的落後、staged 正是這次查到的最新版 ⇒ 該回報已就緒", + available: true, + stagedVersion: "v0.18.22", + latestVersion: "v0.18.22", + wantReport: true, + wantClear: false, + }, + { + name: "落後,但 staged 的是更舊的一版(線上又出新的了)⇒ 舊 staged 作廢", + available: true, + stagedVersion: "v0.18.21", + latestVersion: "v0.18.22", + wantReport: false, + wantClear: true, + }, + { + name: "已追上、且 staged 版本本來就對不上 ⇒ 一樣要清", + available: false, + stagedVersion: "v0.18.20", + latestVersion: "v0.18.22", + wantReport: false, + wantClear: true, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + report, clear := stagedDecision(c.available, c.stagedVersion, c.latestVersion) + if report != c.wantReport || clear != c.wantClear { + t.Fatalf("stagedDecision(%v, %q, %q) = (%v, %v),want (%v, %v)", + c.available, c.stagedVersion, c.latestVersion, report, clear, c.wantReport, c.wantClear) + } + }) + } +} + +// TestCheckUpdate_ClearsStaleStagedFile 端到端驗症狀二:staged.json 與暫存檔案 +// 在「已追上」時真的從磁碟上消失,不是只清記憶體變數(否則下次啟動 loadStaged() 又撿回來)。 +func TestCheckUpdate_ClearsStaleStagedFile(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + // 準備一個「已備妥」的殘留檔案+標記,版本跟目前正在跑的一樣(模擬手動裝好新版)。 + stalePath := updateStagePath("v0.18.22-fake.dmg") + if err := os.WriteFile(stalePath, []byte("fake"), 0o644); err != nil { + t.Fatal(err) + } + staged := stagedUpdate{Version: "v0.18.22", ZipPath: stalePath, Ready: true} + saveStaged(staged) + currentStaged = staged + t.Cleanup(func() { currentStaged = stagedUpdate{} }) + + if _, err := os.Stat(stalePath); err != nil { + t.Fatalf("前置條件不成立:暫存檔沒建起來:%v", err) + } + + // available=false、stagedVersion==latestVersion ⇒ 直接測 clearStaleStaged 路徑本身 + // (CheckUpdate 會打真網路,這裡只驗「決定要清」之後磁碟真的乾淨——那才是症狀二的核心)。 + report, clear := stagedDecision(false, staged.Version, staged.Version) + if report || !clear { + t.Fatalf("前置判斷不對:report=%v clear=%v,這個案例應該是 (false, true)", report, clear) + } + clearStaleStaged() + + if _, err := os.Stat(stalePath); !os.IsNotExist(err) { + t.Fatalf("clearStaleStaged 後暫存檔還在:%v", err) + } + if _, err := os.Stat(stagedMarkerPath()); !os.IsNotExist(err) { + t.Fatalf("clearStaleStaged 後 staged.json 還在:%v", err) + } + if reloaded := loadStaged(); reloaded.Ready { + t.Fatalf("清完之後重新 loadStaged() 還是 Ready=true:%+v", reloaded) + } +} + +// ── 症狀一:extractAppFrom 兩種格式都要吃得下,且是用真的系統工具驗,不是 mock ────── + +// fakeApp 在 dir 底下造一個看起來像 Arcrun.app 的最小骨架,回傳其路徑。 +// 只需要「能被辨識出是這個 app」,不需要真的能跑(selfupdate 的解壓/覆蓋邏輯不在乎內容物)。 +func fakeApp(t *testing.T, dir string) string { + t.Helper() + app := filepath.Join(dir, "Arcrun.app") + contents := filepath.Join(app, "Contents", "MacOS") + if err := os.MkdirAll(contents, 0o755); err != nil { + t.Fatal(err) + } + marker := filepath.Join(contents, "arcrun-app") + if err := os.WriteFile(marker, []byte("#!/bin/sh\necho fake\n"), 0o755); err != nil { + t.Fatal(err) + } + return app +} + +func TestExtractAppFrom_UnknownExtension(t *testing.T) { + work := t.TempDir() + _, err := extractAppFrom(filepath.Join(work, "update.tar.gz"), work) + if err == nil { + t.Fatal("非 .dmg 非 .zip 應該要報錯,卻沒有") + } +} + +func TestExtractAppFrom_Zip(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("ditto 是 macOS 專用工具") + } + if _, err := exec.LookPath("ditto"); err != nil { + t.Skip("這台機器沒有 ditto,略過") + } + + src := t.TempDir() + app := fakeApp(t, src) + + zipPath := filepath.Join(t.TempDir(), "update.zip") + if out, err := exec.Command("ditto", "-c", "-k", "--keepParent", app, zipPath).CombinedOutput(); err != nil { + t.Fatalf("測試前置:打包 zip 失敗:%v\n%s", err, out) + } + + work := t.TempDir() + got, err := extractAppFrom(zipPath, work) + if err != nil { + t.Fatalf("extractAppFrom(.zip) 失敗:%v", err) + } + marker := filepath.Join(got, "Contents", "MacOS", "arcrun-app") + if _, err := os.Stat(marker); err != nil { + t.Fatalf("解出來的 .app 裡找不到標記檔:%v", err) + } +} + +// TestExtractAppFrom_DMG 是症狀一的核心迴歸:用真的 hdiutil 造一顆 dmg(與出貨的 +// build-dmg.sh 同一款工具),餵給 extractAppFrom,驗證解得出 Arcrun.app。 +// 這正是 08-08 之前會爆「ditto: Couldn't read PKZip signature」的那條路徑。 +func TestExtractAppFrom_DMG(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("hdiutil 是 macOS 專用工具") + } + if _, err := exec.LookPath("hdiutil"); err != nil { + t.Skip("這台機器沒有 hdiutil,略過") + } + + stage := t.TempDir() + fakeApp(t, stage) + + dmgPath := filepath.Join(t.TempDir(), "update.dmg") + out, err := exec.Command("hdiutil", "create", + "-volname", "ArcrunTest", + "-srcfolder", stage, + "-fs", "HFS+", + "-ov", "-format", "UDZO", + dmgPath).CombinedOutput() + if err != nil { + t.Fatalf("測試前置:造 dmg 失敗:%v\n%s", err, out) + } + + work := t.TempDir() + got, err := extractAppFrom(dmgPath, work) + if err != nil { + t.Fatalf("extractAppFrom(.dmg) 失敗(症狀一沒修好就會在這裡炸):%v", err) + } + marker := filepath.Join(got, "Contents", "MacOS", "arcrun-app") + if _, err := os.Stat(marker); err != nil { + t.Fatalf("解出來的 .app 裡找不到標記檔:%v", err) + } +} + +// ── moveFile:Windows 自我更新覆蓋新 exe 那一步,同磁碟區要用 rename 般的效果 ────── + +func TestMoveFile_SameDir(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src.bin") + dst := filepath.Join(dir, "dst.bin") + want := []byte("new version content") + if err := os.WriteFile(src, want, 0o644); err != nil { + t.Fatal(err) + } + if err := moveFile(src, dst); err != nil { + t.Fatalf("moveFile 失敗:%v", err) + } + if _, err := os.Stat(src); !os.IsNotExist(err) { + t.Fatalf("moveFile 後來源檔還在:%v", err) + } + got, err := os.ReadFile(dst) + if err != nil { + t.Fatalf("目的檔讀不到:%v", err) + } + if string(got) != string(want) { + t.Fatalf("內容不符:got %q want %q", got, want) + } +} diff --git a/cmd/arcrun-app/update_api.go b/cmd/arcrun-app/update_api.go index bda46f6..cc5355f 100644 --- a/cmd/arcrun-app/update_api.go +++ b/cmd/arcrun-app/update_api.go @@ -11,6 +11,7 @@ package main // ② DownloadUpdate 下載+sha256 校驗 → 備妥 // ③ ApplyUpdate 覆蓋正在跑的 .app 並重啟 import ( + "os" "runtime" "strings" ) @@ -35,12 +36,46 @@ func (a *App) CheckUpdate() UpdateInfo { info.Latest = rel.Version info.Notes = rel.Notes info.Available = newerThanCurrent(rel.Version) - if s := loadStaged(); s.Ready && s.Version == rel.Version { - info.Staged = true + // 🔴 2026-08-08(leo 真機實測):手動裝好新版後按「檢查更新」, + // 仍顯示「新版已下載完成,重新啟動就會套用」——因為背景/上次曾自動下載備妥過 + // 同一版,使用者卻是自己手動裝的,staged.json 沒人清,殘留狀態一直騙下去。 + // 判準要是「當前版本 vs 最新版本」,不是「有沒有下載過」——見 stagedDecision。 + if s := loadStaged(); s.Ready { + report, clear := stagedDecision(info.Available, s.Version, rel.Version) + if clear { + clearStaleStaged() + } + info.Staged = report } return info } +// stagedDecision 決定「已備妥」的殘留旗標該回報成「已就緒,可重啟套用」還是該被清掉。 +// 抽成純函式(無 IO)方便單元測試涵蓋 08-08 那個真機撞到的殘留旗標病,不必真的碰磁碟/網路。 +// +// available=目前版本是否落後 latest(newerThanCurrent 的結果) +// stagedVersion/latestVersion=已備妥的版本 vs 這次查到的最新版本 +// +// 只有「目前版本落後、且備妥的正是這次的最新版」才回報 Staged=true; +// 其餘情況(已經追上/備妥的是舊版)都是殘留,要清掉不能再誤導使用者。 +func stagedDecision(available bool, stagedVersion, latestVersion string) (report bool, clear bool) { + if !available || stagedVersion != latestVersion { + return false, true + } + return true, false +} + +// clearStaleStaged 清掉「已備妥但已經不需要套用」的殘留(暫存檔+標記), +// 不只是記憶體裡的 currentStaged——否則下次啟動 loadStaged() 又會撿回同一個誤導。 +func clearStaleStaged() { + s := loadStaged() + if s.ZipPath != "" { + _ = os.Remove(s.ZipPath) + } + _ = os.Remove(stagedMarkerPath()) + currentStaged = stagedUpdate{} +} + func (a *App) DownloadUpdate() UpdateInfo { info := UpdateInfo{Current: version} rel, err := fetchLatestRelease()