Files
arcrun-collector/cmd/arcrun-tray/selfupdate.go
T
Leo 8dd15fbe43 t150: 小幫手自我更新(背景備妥+重啟完成)+版本一致性
leo 07-29 三句話定調:
「小白不會動不動就刪除再裝新的」
「如果它都掛着,那準備好就告訴他重啓更新」
「**我要他下載多幾次他就放棄了,所以抓到一個客戶後不能讓他有機會離開**」

① 版本一致性(leo:「我的和下載下來的會是同一個嗎?」)
   真因:build-mac.sh L22/build-windows.sh L46 **只有 tray 注入版本,collector 沒有**
   ⇒ 托盤顯示 tray 的版本,實際幹活的 collector 無版本可查、可能不同版。
   修:collector 加 version/buildTime 變數與 --version 旗標;兩個 build 腳本都改為
   兩支注入同一個 LDFLAGS_VER(各 2 處)。
   驗:collector --version → v0.15.0 (build 20260729-2238);tray 內含同一組值。

② 自我更新(leo 選 1+3:完全自動,但要有提醒與手動檢查)
   - 背景每日檢查 manifest 的 daemon 版本(單一 CDN 靜態檔,非 GitHub API,不違反不輪詢紅線)
   - 有新版**默默下載並驗 sha256**,備妥後選單變「🟢 新版已就緒 — 點此重新啟動完成更新」
   - daemon 常駐不重啟 ⇒ 不偷換正在跑的自己;使用者按一下才 ditto 覆蓋並重啟
   - 另有「檢查更新…」讓人隨時手動按(不必等每日排程)
   - 驗章不符或解壓失敗=保留舊版不動(壞掉的 .app 比舊版更糟)
   ⇒ **封測者完全不必再下載任何東西**

驗:go build/go vet 過;三個版本比較測試通過(dev 不提示/同版不提示/新版要提示);
未剝離符號版確認 5 個更新函式在 build 範圍內
(註:打包版被 -s -w 剝符號,用 grep/strings 驗會誤判為「功能沒進去」)。
2026-07-29 22:40:58 +08:00

310 lines
9.7 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
// 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"
"strings"
"time"
)
// manifestURL 是 bundle manifest(含 daemon 區塊)。與安裝器同源,不另開發佈通道。
const manifestURL = "https://cdn.jsdelivr.net/gh/youlinhsieh/arcrun-rag-bundles@main/manifest.json"
// 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(manifestURL)
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 {
const base = "https://cdn.jsdelivr.net/gh/youlinhsieh/arcrun-rag-bundles@main/"
if isWindows() {
return base + rel.Win.File
}
return base + rel.Mac.File
}
// 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)
}
dst := updateStagePath(rel.Version + "-" + filepath.Base(url))
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 套用已備妥的更新並重啟。
// 只有使用者按下「重新啟動以完成更新」才會走到這裡(不偷偷替換正在跑的自己)。
//
// 做法:解壓到暫存 → 用 ditto 覆蓋 /Applications/Arcrun RAG.app → 重新 open → 結束自己。
// 失敗時保留原版不動(寧可停在舊版,也不要弄出開不起來的 .app)。
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() {
// Windows 版走各自的安裝流程(本階段先開資料夾讓使用者取用)。
return openPath(filepath.Dir(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 RAG.app")
if _, err := os.Stat(newApp); err != nil {
return fmt.Errorf("更新檔內容不符(找不到 Arcrun RAG.app")
}
target := "/Applications/Arcrun RAG.app"
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
}
// updateMenuLabel 回傳選單該顯示什麼(三種狀態,使用者一眼看懂要做什麼)。
// 回傳 ("", false) 代表不顯示這個項目。
func updateMenuLabel() (string, bool) {
if s := loadStaged(); s.Ready {
return "🟢 新版 " + s.Version + " 已就緒 — 點此重新啟動完成更新", true
}
if currentUpdate.Available {
return "🟠 有新版 " + currentUpdate.Latest + " — 正在背景下載…", true
}
return "", false
}
// runCmd 執行外部命令並回傳合併輸出(本檔自用;package 內先前沒有同類 helper)。
func runCmd(name string, args ...string) (string, error) {
cmd := exec.Command(name, args...)
out, err := cmd.CombinedOutput()
return string(out), err
}
// openPath 用系統預設方式開啟路徑(Windows 版更新暫以「開資料夾」收尾)。
func openPath(p string) error {
if isWindows() {
_, err := runCmd("cmd", "/c", "start", "", p)
return err
}
_, err := runCmd("open", p)
return err
}