修好: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:
+144
-16
@@ -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 清掉上一輪更新留下的 `<exe>.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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user