Files
code-utils/update.go
2026-08-15 17:18:00 +08:00

227 lines
5.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
// 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
}