Files
arcrun-collector/cmd/arcrun-app/selfupdate.go
T
Leo edc9916b2f 修好: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>
2026-08-08 02:58:01 +08:00

504 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
// ⚠️ 本檔**逐字取自 arcrun-tray/selfupdate.go**(只拿掉 fyne 專屬的選單函式)。
// 理由:那裡面有踩過坑才長出來的東西——jsDelivr @main 的 ref 解析會卡死(t186 改走 raw)、
// 自更新要蓋「正在跑的那個 .app」而非寫死 /Applicationst184Oscar 靜默失敗的真兇)。
// **兩邊哪天要改就兩邊一起改。**
// selfupdate.go — 小幫手自我更新(t1502026-07-29 leo 拍板)
//
// 為什麼一定要做(leo 原話):
// 「小白不會動不動就刪除再裝新的」
// 「如果有自動更新,就不用讓測試者一直重新下載,他去按一下檢查更新然後更新就好」
// 「**我要他下載多幾次他就放棄了,所以抓到一個客戶後不能讓他有機會離開**」
//
// ⇒ 每一次「請你重新下載」都是流失點。修好的東西到不了用戶手上=等於沒修
// (t149 實例:多帳號同步修好了,leo 手上仍是壞的,因為沒有更新機制)。
//
// 設計(leo 選 1+3):**背景自動更新為主,但一定要有提醒與手動「檢查更新」**——
// daemon 常駐不重啟,完全自動才符合實際使用;但仍要看得見、按得動,
// 否則使用者不知道發生什麼事(違反「視覺化明示原則」)。
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
// manifestBase 是 bundle manifest(含 daemon 區塊)。與安裝器同源,不另開發佈通道。
//
// 🔴 t1862026-08-04):**改打 raw.githubusercontent,不再用 jsDelivr `@main`。**
//
// ── 這推翻了 t150 二修(07-29 `142f1d5`)的判斷,理由如下 ──
//
// 那一輪的真兇是「jsDelivr 對 `@main` 回 7 天**檔案**快取」,解法是加 `?t=<unix>`。
// **今天的病不同**:卡住的是「main → 哪個 commit」這層 **ref 解析**
// 而 `?t=` 與 purge **都清不掉 ref 解析**。08-04 實測三條路同時比對:
// @main → 1.4.9 / v0.15.6 ← 卡住(age 持續增長、purge 回 finished 也沒用)
// @<sha> → 1.4.10 / v0.15.7 ← 正確
// raw → 1.4.10 / v0.15.7 ← 正確
// `x-cache: MISS, MISS` 卻仍吐舊內容 ⇒ 坐實不是檔案快取,是 ref 解析被快取。
// 同一天發作三次(win zip → mac zip → manifest)。zip 能靠「改帶版本號檔名」繞開,
// **manifest.json 不行**——檔名寫死在這裡,換不了 ⇒ 只剩「換來源」一條路。
//
// ── D20 合規(t150 那輪否決 raw 的理由是誤判)──
// 舊註解寫「raw 是實名讀 GitHub,受 D20 頻率閘管制」。
// 但 D20 對實名的定義是「URL 帶 `token@``user:pass@`、或 clone 自己 private 殼」,
// **daemon 這個請求不帶任何憑證=匿名讀**,
// D20 明列「匿名讀(curl 公開 release…)✅ 放行,不計數」
// `system-dev/docs/2-architecture/decisions/D20-github-contact-protocol.md:22`)。
// repo 是公開的,GitHub 不知道請求者是誰 ⇒ 零 flag 風險。
//
// 仍保留 `?t=<unix>`:擋瀏覽器/中間層快取,成本為零。
const manifestBase = "https://raw.githubusercontent.com/youlinhsieh/arcrun-rag-bundles/main/manifest.json"
func manifestURLNoCache() string {
return manifestBase + "?t=" + strconv.FormatInt(time.Now().Unix(), 10)
}
// updateCheckInterval:背景檢查頻率。守「不輪詢」紅線——這是**單一 CDN 靜態檔**、
// 每天一次、非 GitHub API,不構成 fan-out 或高頻寫入。
const updateCheckInterval = 24 * time.Hour
type daemonRelease struct {
Version string `json:"version"`
Built string `json:"built"`
Notes string `json:"notes"`
Mac struct {
File string `json:"file"`
SHA256 string `json:"sha256"`
} `json:"mac"`
Win struct {
File string `json:"file"`
SHA256 string `json:"sha256"`
} `json:"win"`
}
type bundleManifest struct {
Daemon *daemonRelease `json:"daemon,omitempty"`
}
// updateState 是檢查結果(給選單讀)。
type updateState struct {
Available bool // 有新版
Latest string // 最新版本號
Notes string // 更新說明
CheckedAt time.Time
Err string // 檢查失敗原因(顯示用,不擋工作)
}
var currentUpdate updateState
// fetchLatestRelease 抓 manifest 的 daemon 區塊。
func fetchLatestRelease() (*daemonRelease, error) {
cli := &http.Client{Timeout: 20 * time.Second}
resp, err := cli.Get(manifestURLNoCache())
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("manifest HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return nil, err
}
var m bundleManifest
if err := json.Unmarshal(body, &m); err != nil {
return nil, err
}
if m.Daemon == nil || strings.TrimSpace(m.Daemon.Version) == "" {
return nil, fmt.Errorf("manifest 沒有 daemon 版本欄位")
}
return m.Daemon, nil
}
// newerThanCurrent 比較版本。dev(本機隨手編)一律不提示更新——
// 開發中的機器被提示「有新版」只會造成困惑。
func newerThanCurrent(latest string) bool {
cur := strings.TrimSpace(version)
if cur == "" || cur == "dev" {
return false
}
return strings.TrimSpace(latest) != cur
}
// checkForUpdate 執行一次檢查並更新 currentUpdate。
func checkForUpdate() updateState {
st := updateState{CheckedAt: time.Now()}
rel, err := fetchLatestRelease()
if err != nil {
st.Err = err.Error()
currentUpdate = st
return st
}
st.Latest = rel.Version
st.Notes = rel.Notes
st.Available = newerThanCurrent(rel.Version)
currentUpdate = st
return st
}
// downloadURLFor 回傳本平台的下載網址。
func downloadURLFor(rel *daemonRelease) string {
// t186:與 manifestBase 同一個理由改走 raw——`@main` 的 ref 解析會卡住,
// 抓到舊 zip ⇒ sha256 校驗不符 ⇒ **更新永遠失敗且靜默**(正是 08-04 撞的病)。
// portal 的下載按鈕本來就走 rawMac zip 曾大於 jsDelivr 20MB 單檔上限),
// 這裡改過來後**兩條下載路徑同源**,不會再出現「下載頁對、檢查更新錯」的分歧。
base := "https://raw.githubusercontent.com/youlinhsieh/arcrun-rag-bundles/main/"
suffix := "?t=" + strconv.FormatInt(time.Now().Unix(), 10)
if isWindows() {
return base + rel.Win.File + suffix
}
return base + rel.Mac.File + suffix
}
// updateStagePath 是下載暫存位置(~/.arcrun-rag/updates/)。
func updateStagePath(name string) string {
home, _ := os.UserHomeDir()
dir := filepath.Join(home, ".arcrun-rag", "updates")
_ = os.MkdirAll(dir, 0o755)
return filepath.Join(dir, name)
}
// ── 下載與套用(leo 07-29:「如果它都掛着,那準備好就告訴他重啓更新」)─────────
//
// 設計要點:daemon 常駐不重啟 ⇒ **不能中途替換自己正在跑的執行檔**。
// 所以分兩段:① 背景默默下載+驗章,備妥後只改選單文案(不打擾)
// ② 使用者按「重新啟動以完成更新」才真的替換並重啟。
// ⇒ 使用者**完全不必再下載任何東西**(leo:「我要他下載多幾次他就放棄了」)。
// stagedUpdate 記錄「已下載備妥、等重啟套用」的版本。
type stagedUpdate struct {
Version string `json:"version"`
ZipPath string `json:"zip_path"`
Ready bool `json:"ready"`
}
var currentStaged stagedUpdate
// stagedMarkerPath 讓「備妥狀態」跨重啟可見(重啟後才好知道要不要套用)。
func stagedMarkerPath() string { return updateStagePath("staged.json") }
func saveStaged(s stagedUpdate) {
b, err := json.Marshal(s)
if err != nil {
return
}
_ = os.WriteFile(stagedMarkerPath(), b, 0o644)
}
func loadStaged() stagedUpdate {
var s stagedUpdate
b, err := os.ReadFile(stagedMarkerPath())
if err != nil {
return s
}
_ = json.Unmarshal(b, &s)
return s
}
// downloadUpdate 下載新版 zip 到暫存區並驗 sha256。
// 驗章失敗=寧可不更新(壞掉的 .app 會讓小幫手開不起來,比舊版更糟)。
func downloadUpdate(rel *daemonRelease) (string, error) {
url := downloadURLFor(rel)
want := rel.Mac.SHA256
if isWindows() {
want = rel.Win.SHA256
}
cli := &http.Client{Timeout: 10 * time.Minute}
resp, err := cli.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("下載 HTTP %d", resp.StatusCode)
}
// 檔名要用「去掉查詢字串」的網址算,否則會變成 ...zip?t=1785... 這種怪檔名。
cleanURL := strings.SplitN(url, "?", 2)[0]
dst := updateStagePath(rel.Version + "-" + filepath.Base(cleanURL))
f, err := os.Create(dst)
if err != nil {
return "", err
}
h := sha256.New()
if _, err := io.Copy(io.MultiWriter(f, h), resp.Body); err != nil {
f.Close()
return "", err
}
f.Close()
got := hex.EncodeToString(h.Sum(nil))
if want != "" && got != want {
_ = os.Remove(dst)
return "", fmt.Errorf("檔案校驗不符(可能下載不完整),已丟棄")
}
return dst, nil
}
// prepareUpdateInBackground:檢查 → 有新版就下載備妥 → 記錄 staged。
// 全程不打擾使用者;備妥後由選單顯示「重新啟動以完成更新」。
func prepareUpdateInBackground() {
st := checkForUpdate()
if !st.Available {
return
}
rel, err := fetchLatestRelease()
if err != nil {
return
}
zip, err := downloadUpdate(rel)
if err != nil {
currentUpdate.Err = "自動下載失敗:" + err.Error()
return
}
currentStaged = stagedUpdate{Version: rel.Version, ZipPath: zip, Ready: true}
saveStaged(currentStaged)
}
// startUpdateWatcher 啟動背景更新檢查(每日一次;啟動後先等 1 分鐘避免搶開機資源)。
func startUpdateWatcher() {
go func() {
time.Sleep(time.Minute)
for {
prepareUpdateInBackground()
time.Sleep(updateCheckInterval)
}
}()
}
// applyStagedAndRestart 套用已備妥的更新並重啟。
// 只有使用者按下「重新啟動以完成更新」才會走到這裡(不偷偷替換正在跑的自己)。
//
// 做法(Mac):解出 .app(見 extractAppFrom)→ 用 ditto 覆蓋正在跑的那份 → 重新 open → 結束自己。
// 做法(Windows):見 applyWindowsUpdateAndRestartrename 正在跑的 exe,標準自我更新手法)。
// 失敗時保留原版不動(寧可停在舊版,也不要弄出開不起來/開不動的執行檔)。
func applyStagedAndRestart() error {
s := loadStaged()
if !s.Ready || s.ZipPath == "" {
return fmt.Errorf("沒有已備妥的更新")
}
if _, err := os.Stat(s.ZipPath); err != nil {
return fmt.Errorf("更新檔不見了,請再檢查一次更新")
}
if isWindows() {
return applyWindowsUpdateAndRestart(s.ZipPath)
}
work := updateStagePath("unpack")
_ = os.RemoveAll(work)
if err := os.MkdirAll(work, 0o755); err != nil {
return err
}
newApp, err := extractAppFrom(s.ZipPath, work)
if err != nil {
return err
}
// 🔴 t184leo 08-04 實撞:Oscar 按更新→顯示「新版 v0.15.6 已就緒」→重啟後**又跳回 v0.15.4**):
//
// 原本寫死 `/Applications/Arcrun.app`。但使用者**不一定把 app 放在 Applications**
// (Oscar 就是從下載資料夾/桌面直接跑)。這時 `ditto` 會**自動建出**
// `/Applications/Arcrun.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)
}
// 清掉 staged 標記,避免重啟後又提示一次
_ = os.Remove(stagedMarkerPath())
currentStaged = stagedUpdate{}
if out, err := runCmd("open", "-n", target); err != nil {
return fmt.Errorf("重新啟動失敗:%v %s", err, out)
}
go func() { time.Sleep(2 * time.Second); os.Exit(0) }()
return nil
}
// runningAppBundlePath 回傳**現在正在跑的**那個 .app 路徑(t184)。
//
// 路徑長相:<某處>/Arcrun.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.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
}
// extractAppFrom 從更新檔解出 Arcrun.app,回傳其路徑。依副檔名分流。
//
// 🔴 2026-08-08leo 真機實測撞到):出貨格式從 zip 換成了 dmgt194 `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...)
out, err := cmd.CombinedOutput()
return string(out), err
}
// ── Windows 自我更新(2026-08-08,leo 補充事實後改寫)─────────────────────────
//
// 舊版本這裡只是「開資料夾讓使用者取用」,理由寫著「Windows 版走各自的安裝流程」。
// leo 08-08 點破:那是併檔前(v0.18.9 之前)的殘留假設——
//
// 「Windows 沒有資料夾,它現在是 exe,單一的。」
//
// v0.18.94a26856)已把 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
}
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
}