diff --git a/cmd/arcrun-tray/build-mac.sh b/cmd/arcrun-tray/build-mac.sh index 797c8f7..54c9962 100755 --- a/cmd/arcrun-tray/build-mac.sh +++ b/cmd/arcrun-tray/build-mac.sh @@ -19,7 +19,9 @@ LDFLAGS_VER="-X main.version=${VERSION} -X main.buildTime=${BUILD_TIME}" echo "🏷 版本:${VERSION}(build ${BUILD_TIME})" echo "① 編 collector(同綑執行檔)" -( cd ../.. && go build -ldflags "-s -w" -o "cmd/arcrun-tray/arcrun-collector" . ) +# t150(leo 07-29「我的和下載下來的會是同一個嗎」):collector **也要**注入版本, +# 否則托盤顯示的是 tray 的版本、實際幹活的 collector 無版本可查(兩支獨立編譯)。 +( cd ../.. && go build -ldflags "-s -w ${LDFLAGS_VER}" -o "cmd/arcrun-tray/arcrun-collector" . ) echo "② fyne package" go run fyne.io/fyne/v2/cmd/fyne@latest package -os darwin -icon icon.png -name "Arcrun RAG" -appID dev.arcrun.rag.tray diff --git a/cmd/arcrun-tray/build-windows.sh b/cmd/arcrun-tray/build-windows.sh index 11827dd..3c1b3b4 100644 --- a/cmd/arcrun-tray/build-windows.sh +++ b/cmd/arcrun-tray/build-windows.sh @@ -43,7 +43,8 @@ rm -rf "$OUT" && mkdir -p "$OUT" echo "② 編 collector.exe 到本目錄(供 //go:embed 吃進 tray)" ( cd ../.. && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 \ - go build -ldflags "-s -w" -o "cmd/arcrun-tray/arcrun-collector.exe" . ) + # t150(leo 07-29):collector 也要注入版本,否則顯示的與實際跑的可能不同版。 + go build -ldflags "-s -w ${LDFLAGS_VER}" -o "cmd/arcrun-tray/arcrun-collector.exe" . ) echo "③ 編托盤 GUI(fyne=CGo+GL,走 mingw;collector.exe embed 進去成為單一 exe)" CGO_ENABLED=1 GOOS=windows GOARCH=amd64 CC="$CC_WIN" \ diff --git a/cmd/arcrun-tray/main.go b/cmd/arcrun-tray/main.go index a107c74..faec4dd 100644 --- a/cmd/arcrun-tray/main.go +++ b/cmd/arcrun-tray/main.go @@ -856,6 +856,43 @@ func main() { versionItem := fyne.NewMenuItem("版本 "+versionLabel(), nil) versionItem.Disabled = true + // t150(leo 07-29):更新提示+一鍵完成。 + // leo:「我要他下載多幾次他就放棄了,所以抓到一個客戶後不能讓他有機會離開」 + // 「如果它都掛着,那準備好就告訴他重啓更新」 + // ⇒ 背景自動下載備妥,這裡只呈現結果;使用者**完全不必再下載**,按一下重啟即完成。 + var updateItem *fyne.MenuItem + if lbl, show := updateMenuLabel(); show { + updateItem = fyne.NewMenuItem(lbl, func() { + if st := loadStaged(); st.Ready { + if err := applyStagedAndRestart(); err != nil { + dialog.ShowError(err, win) + } + return + } + // 尚在下載中:告知狀態,不重複觸發下載 + dialog.ShowInformation("更新下載中", + "新版正在背景下載,完成後這裡會變成「點此重新啟動完成更新」。", win) + }) + } + + // 手動「檢查更新」(leo:「他去按一下檢查更新然後更新就好」)—— + // 自動檢查每日一次,但使用者想立刻確認時要按得到,不必等。 + checkUpdateItem := fyne.NewMenuItem("檢查更新…", func() { + go func() { + st := checkForUpdate() + switch { + case st.Err != "": + dialog.ShowInformation("檢查更新", "暫時連不上更新伺服器:\n"+st.Err, win) + case st.Available: + go prepareUpdateInBackground() + dialog.ShowInformation("有新版本", + "發現新版 "+st.Latest+",正在背景下載。\n下載完成後,選單會出現「點此重新啟動完成更新」。", win) + default: + dialog.ShowInformation("檢查更新", "你已經是最新版本("+versionLabel()+")。", win) + } + }() + }) + var items []*fyne.MenuItem // t104:per-account 分組——每個帳號獨立一段(帳號名稱標題 + 資料夾 + 新增按鈕)。 @@ -951,6 +988,10 @@ func main() { } })) } + // t150:有更新才插這一項(沒新版時不佔位、不干擾) + if updateItem != nil { + items = append(items, fyne.NewMenuItemSeparator(), updateItem) + } items = append(items, fyne.NewMenuItemSeparator(), syncNowItem, // t98:立刻同步 @@ -958,6 +999,7 @@ func main() { fyne.NewMenuItem("+ 新增帳號…", func() { showConnectWizard() }), fyne.NewMenuItemSeparator(), versionItem, + checkUpdateItem, // t150:手動檢查更新(leo:「他去按一下檢查更新然後更新就好」) // t110 二修(leo 真機:v0.14.1 拿掉自訂項後選單「兩個都沒有」= // 「fyne 會自動附 Quit」是錯誤前提,桌面截圖才是權威):自訂結束項加回, // 這是唯一的結束入口。若某環境 fyne 真的又附了 Quit(v0.14.0 曾見雙項), @@ -1002,6 +1044,11 @@ func main() { showConnectWizard() } + // t150:背景更新檢查(每日一次,啟動後延遲 1 分鐘避免搶開機資源)。 + // 有新版就默默下載備妥,選單自動變成「點此重新啟動完成更新」—— + // 使用者不必再下載任何東西(leo:「我要他下載多幾次他就放棄了」)。 + startUpdateWatcher() + a.Lifecycle().SetOnStopped(func() { sup.Stop() }) a.Run() } diff --git a/cmd/arcrun-tray/selfupdate.go b/cmd/arcrun-tray/selfupdate.go new file mode 100644 index 0000000..e2efefa --- /dev/null +++ b/cmd/arcrun-tray/selfupdate.go @@ -0,0 +1,309 @@ +package main + +// selfupdate.go — 小幫手自我更新(t150,2026-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 +} diff --git a/main.go b/main.go index 78ec359..3c490eb 100644 --- a/main.go +++ b/main.go @@ -39,12 +39,34 @@ type runMode struct { withTrigger bool } +// 版本資訊(t150,2026-07-29 leo:「我的和下載下來的會是同一個嗎?」)。 +// +// 為什麼 collector 也要:先前**只有 tray 注入版本**(build-mac.sh L34), +// collector 是另一支獨立編譯的執行檔、完全沒有版本號(L22 不帶 LDFLAGS_VER)。 +// ⇒ 托盤選單顯示的是 tray 的版本,**實際幹活的 collector 可能是任何版本**—— +// 選單說 v0.14.1 也證明不了 collector 有沒有含某個修復(如 t149 多帳號同步)。 +// 兩支必須注入**同一個版本值**,「顯示的」才等於「跑的」。 +var ( + version = "dev" + buildTime = "" +) + +func versionString() string { + if buildTime == "" { + return version + } + return version + " (build " + buildTime + ")" +} + func main() { if len(os.Args) < 2 { usage() os.Exit(2) } switch os.Args[1] { + case "--version", "-v", "version": + fmt.Println(versionString()) + os.Exit(0) case "scan": os.Exit(run(os.Args[2:], runMode{})) case "upload":