Files
code-utils/update.go
李琦 7c4109f687 feat: 提交详情能看 diff,也能丢给 AI 审这一次改动。文件检查可单独排除路径,TODO 只认大写,避免把 todo 表和模块当成待办标记。
热力图按容器宽度铺满一年,不再横向滚动。托盘右键换成可换皮肤的弹层;更新下载显示进度,退出后再由脚本拉起安装器,避免还占着 exe。
2026-08-19 15:46:03 +08:00

310 lines
8.2 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"
"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
}