修好: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
+37 -2
View File
@@ -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=目前版本是否落後 latestnewerThanCurrent 的結果)
// stagedVersionlatestVersion=已備妥的版本 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()