修好:Mac 自我更新斷七代(dmg 餵給解 zip 的 ditto)+殘留 staged 旗標騙人重啟

症狀一:manifest.daemon.mac.file 出貨格式從 zip 換成 dmg(build-dmg.sh),
但 applyStagedAndRestart 只認 zip(ditto -x -k)⇒ ditto: Couldn't read PKZip
signature,Mac「檢查更新」從 v0.18.5 斷到 v0.18.22(leo 真機截圖實測)。
症狀二:手動裝好新版後仍顯示「已下載完成,重新啟動就會套用」——staged.json
判準是「有沒有下載過」而非「目前版本 vs 最新版本」。

- extractAppFrom 依副檔名分流 .dmg(hdiutil attach -mountpoint)/.zip;
  Windows 補上真正的自我更新(rename 正在跑的 exe → 搬新版 → 原路徑重啟,
  併檔後 Windows 只有單一 exe,「開資料夾」是併檔前的殘留假設)。
- CheckUpdate 加 stagedDecision 純函式:已追上或 staged 版本過期就清掉
  殘留(連磁碟檔案一起清,不只清記憶體)。
- 新增 selfupdate_test.go:用真的 hdiutil 造 dmg 驗證解壓(不是 mock)。

Mac 端到端已用真的線上 manifest/真 v0.18.22 dmg 驗證整條鏈路(含用舊寫法
對同一份真檔案重現原始錯誤,證明修法對症)。Windows 只驗到 GOOS=windows
交叉編譯成功,真機行為待 leo 用 VMware Fusion 驗證——詳見 status.md。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 02:58:01 +08:00
parent 837e5e8052
commit edc9916b2f
4 changed files with 419 additions and 18 deletions
+234
View File
@@ -0,0 +1,234 @@
package main
// selfupdate_test.go — 2026-08-08 leo 真機實測撞到「檢查更新」壞掉兩個症狀後補的迴歸測試。
//
// 症狀一:manifest.daemon.mac.file 出貨格式從 zip 換成 dmg,但 applyStagedAndRestart
//
// 只認 zipditto -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(用真的 hdiutilditto
// 不是 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)
}
}
// ── moveFileWindows 自我更新覆蓋新 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)
}
}