package main // update.go 客户端软件自动更新:每 30 分钟检查服务端最新版,下载 NSIS 后拉起安装。 import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" "time" "view/platform" ) type appLatestInfo struct { Version string `json:"version"` Channel string `json:"channel"` SHA256 string `json:"sha256"` SizeBytes int64 `json:"sizeBytes"` Changelog string `json:"changelog"` CreatedAt string `json:"createdAt"` } // CheckAppUpdate 手动/定时检查更新;force=true 忽略“已忽略版本”。 func (a *App) CheckAppUpdate(force bool) (map[string]any, error) { if e := a.ready(); e != nil { return nil, e } info, e := a.fetchLatestRelease() if e != nil { if e.Error() == "NO_RELEASE" { return map[string]any{"upToDate": true, "current": appVersion}, nil } return nil, e } if !versionNewer(info.Version, appVersion) { return map[string]any{"upToDate": true, "current": appVersion, "latest": info.Version}, nil } if !force { if skipped := strings.TrimSpace(a.store.Meta("app_update_skipped")); skipped == info.Version { return map[string]any{"upToDate": false, "skipped": true, "current": appVersion, "latest": info.Version}, nil } } payload := map[string]any{ "upToDate": false, "current": appVersion, "latest": info.Version, "changelog": info.Changelog, "sizeBytes": info.SizeBytes, "sha256": info.SHA256, "channel": info.Channel, } a.emit("app:update-available", payload) return payload, nil } // SkipAppUpdate 本次跳过该版本提示。 func (a *App) SkipAppUpdate(version string) error { if e := a.ready(); e != nil { return e } return a.store.SetMeta("app_update_skipped", strings.TrimSpace(version)) } // DownloadAndInstallUpdate 下载最新安装包、校验后退出应用并由独立脚本拉起安装器。 func (a *App) DownloadAndInstallUpdate() error { if e := a.ready(); e != nil { return e } info, e := a.fetchLatestRelease() if e != nil { return e } if !versionNewer(info.Version, appVersion) { return errors.New("ALREADY_LATEST") } path, e := a.downloadRelease(info) if e != nil { return e } if e := launchDownloadedInstaller(path); e != nil { return e } a.store.Log("info", "系统", "开始安装更新", info.Version) // 安装脚本会等本进程退出后再 start 安装器,避免占用 exe / 作业对象杀掉子进程。 a.QuitApp() return nil } func (a *App) runAppUpdateLoop() { time.Sleep(60 * time.Second) a.checkAppUpdateQuiet() t := time.NewTicker(30 * time.Minute) defer t.Stop() for { select { case <-a.ctx.Done(): return case <-t.C: a.checkAppUpdateQuiet() } } } func (a *App) checkAppUpdateQuiet() { if a.store == nil || a.bootstrap.State != BootstrapReady { return } if a.syncUserID() <= 0 || a.store.Meta("sync_access_token") == "" { return } _, _ = a.CheckAppUpdate(false) } func (a *App) fetchLatestRelease() (*appLatestInfo, error) { if a.apiBaseURL() == "" { return nil, errors.New("SYNC_NOT_CONFIGURED") } var info appLatestInfo if e := a.apiDecode(http.MethodGet, "/api/v1/app/latest?channel=stable", nil, &info, false); e != nil { return nil, e } if strings.TrimSpace(info.Version) == "" { return nil, errors.New("NO_RELEASE") } return &info, nil } func (a *App) downloadRelease(info *appLatestInfo) (string, error) { base := a.apiBaseURL() if base == "" { return "", errors.New("SYNC_NOT_CONFIGURED") } url := base + "/api/v1/app/download/" + pathEscape(info.Version) + "?channel=stable" req, e := http.NewRequest(http.MethodGet, url, nil) if e != nil { return "", errors.New("SYNC_OFFLINE") } // 公开接口不带过期 JWT,避免网关/反代因 Authorization 直接 401。 req.Header.Set("Accept-Encoding", "identity") client := &http.Client{Timeout: 30 * time.Minute} resp, e := client.Do(req) if e != nil { return "", errors.New("SYNC_OFFLINE") } defer resp.Body.Close() if resp.StatusCode >= 400 { raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) var er struct { Error string `json:"error"` } if json.Unmarshal(raw, &er) == nil && er.Error != "" { return "", errors.New(er.Error) } return "", errors.New("SYNC_HTTP_" + strconv.Itoa(resp.StatusCode)) } total := info.SizeBytes if resp.ContentLength > 0 { total = resp.ContentLength } a.emit("app:update-progress", map[string]any{"received": int64(0), "total": total}) dir := filepath.Join(os.TempDir(), "code-count-updates") if e := os.MkdirAll(dir, 0755); e != nil { return "", errors.New("SAVE_FAILED") } outPath := filepath.Join(dir, fmt.Sprintf("%s-installer-%d.exe", info.Version, os.Getpid())) f, e := os.Create(outPath) if e != nil { return "", errors.New("SAVE_FAILED") } h := sha256.New() prog := &updateProgressWriter{a: a, total: total} n, e := io.Copy(io.MultiWriter(f, h, prog), resp.Body) _ = f.Close() if e != nil { _ = os.Remove(outPath) return "", errors.New("DOWNLOAD_FAILED") } sum := hex.EncodeToString(h.Sum(nil)) if info.SHA256 != "" && !strings.EqualFold(sum, info.SHA256) { _ = os.Remove(outPath) return "", errors.New("SHA256_MISMATCH") } if info.SizeBytes > 0 && n != info.SizeBytes { _ = os.Remove(outPath) return "", errors.New("SIZE_MISMATCH") } if !isWindowsPE(outPath) { _ = os.Remove(outPath) return "", errors.New("NOT_INSTALLER") } a.emit("app:update-progress", map[string]any{"received": n, "total": total}) return outPath, nil } type updateProgressWriter struct { a *App total int64 written int64 lastEmit time.Time } func (w *updateProgressWriter) Write(p []byte) (int, error) { n := len(p) w.written += int64(n) now := time.Now() if w.lastEmit.IsZero() || now.Sub(w.lastEmit) >= 200*time.Millisecond || (w.total > 0 && w.written >= w.total) { w.lastEmit = now if w.a != nil { w.a.emit("app:update-progress", map[string]any{"received": w.written, "total": w.total}) } } return n, nil } func isWindowsPE(path string) bool { f, err := os.Open(path) if err != nil { return false } defer f.Close() var hdr [2]byte if _, err := io.ReadFull(f, hdr[:]); err != nil { return false } return hdr[0] == 'M' && hdr[1] == 'Z' } // launchDownloadedInstaller 用独立脚本等本进程退出后再打开安装器。 // 不能对安装器本身用 CREATE_NO_WINDOW + /S:需要 UAC 的 NSIS 会静默失败,且文件仍被占用。 func launchDownloadedInstaller(path string) error { path = strings.TrimPrefix(filepath.Clean(path), `\\?\`) if runtime.GOOS != "windows" { cmd := exec.Command(path) if e := cmd.Start(); e != nil { return errors.New("INSTALLER_START_FAILED") } return nil } bat := filepath.Join(os.TempDir(), fmt.Sprintf("cc-update-%d.bat", os.Getpid())) if e := os.WriteFile(bat, []byte(updateLaunchScript(os.Getpid(), path)), 0644); e != nil { return errors.New("SAVE_FAILED") } cmd := exec.Command("cmd", "/C", bat) platform.ConfigureUpdaterHelper(cmd) if e := cmd.Start(); e != nil { cmd = exec.Command("cmd", "/C", bat) platform.ConfigureHidden(cmd) if e2 := cmd.Start(); e2 != nil { return errors.New("INSTALLER_START_FAILED") } } return nil } func updateLaunchScript(pid int, installer string) string { return fmt.Sprintf(""+ "@echo off\r\n"+ "set \"PID=%d\"\r\n"+ "set /a N=0\r\n"+ ":wait\r\n"+ "set /a N+=1\r\n"+ "if %%N%% GEQ 90 goto launch\r\n"+ "ping -n 2 127.0.0.1 >nul\r\n"+ "tasklist /FI \"PID eq %%PID%%\" /NH 2>nul | find \"%%PID%%\" >nul\r\n"+ "if not errorlevel 1 goto wait\r\n"+ ":launch\r\n"+ "ping -n 3 127.0.0.1 >nul\r\n"+ "start \"\" \"%s\"\r\n"+ "del \"%%~f0\"\r\n", pid, installer) } // versionNewer 判断 remote 是否比 local 新(简单 x.y.z 比较)。 func versionNewer(remote, local string) bool { rp := parseSemver(remote) lp := parseSemver(local) for i := 0; i < 3; i++ { if rp[i] > lp[i] { return true } if rp[i] < lp[i] { return false } } return false } func parseSemver(v string) [3]int { v = strings.TrimPrefix(strings.TrimSpace(v), "v") if i := strings.IndexAny(v, "-+"); i >= 0 { v = v[:i] } parts := strings.Split(v, ".") var out [3]int for i := 0; i < 3 && i < len(parts); i++ { n, _ := strconv.Atoi(parts[i]) out[i] = n } return out }