package main // update.go 客户端软件自动更新:每 30 分钟检查服务端最新版,下载 NSIS 后拉起安装。 import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "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 } cmd := exec.Command(path, "/S") platform.ConfigureHidden(cmd) if e := cmd.Start(); e != nil { // 静默失败则普通启动 cmd2 := exec.Command(path) if e2 := cmd2.Start(); e2 != nil { return errors.New("INSTALLER_START_FAILED") } } a.store.Log("info", "系统", "开始安装更新", info.Version) go func() { time.Sleep(800 * time.Millisecond) 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") } if tok := strings.TrimSpace(a.store.Meta("sync_access_token")); tok != "" { req.Header.Set("Authorization", "Bearer "+tok) } 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)) } dir := filepath.Join(os.TempDir(), "code-count-updates") _ = os.MkdirAll(dir, 0755) outPath := filepath.Join(dir, info.Version+"-installer.exe") f, e := os.Create(outPath) if e != nil { return "", errors.New("SAVE_FAILED") } h := sha256.New() n, e := io.Copy(io.MultiWriter(f, h), 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 { a.store.Log("warning", "系统", "更新包大小与元数据不一致", fmt.Sprintf("%d vs %d", n, info.SizeBytes)) } return outPath, nil } // 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 }