Files
code-utils/update.go

310 lines
8.2 KiB
Go
Raw Normal View History

2026-08-15 17:18:00 +08:00
package main
// update.go 客户端软件自动更新:每 30 分钟检查服务端最新版,下载 NSIS 后拉起安装。
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
2026-08-15 17:18:00 +08:00
"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 下载最新安装包、校验后退出应用并由独立脚本拉起安装器。
2026-08-15 17:18:00 +08:00
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
2026-08-15 17:18:00 +08:00
}
a.store.Log("info", "系统", "开始安装更新", info.Version)
// 安装脚本会等本进程退出后再 start 安装器,避免占用 exe / 作业对象杀掉子进程。
a.QuitApp()
2026-08-15 17:18:00 +08:00
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")
2026-08-15 17:18:00 +08:00
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})
2026-08-15 17:18:00 +08:00
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()))
2026-08-15 17:18:00 +08:00
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)
2026-08-15 17:18:00 +08:00
_ = 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")
2026-08-15 17:18:00 +08:00
}
if !isWindowsPE(outPath) {
_ = os.Remove(outPath)
return "", errors.New("NOT_INSTALLER")
}
a.emit("app:update-progress", map[string]any{"received": n, "total": total})
2026-08-15 17:18:00 +08:00
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)
}
2026-08-15 17:18:00 +08:00
// 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
}