227 lines
5.7 KiB
Go
227 lines
5.7 KiB
Go
|
|
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
|
|||
|
|
}
|