feat: 提交详情能看 diff,也能丢给 AI 审这一次改动。文件检查可单独排除路径,TODO 只认大写,避免把 todo 表和模块当成待办标记。

热力图按容器宽度铺满一年,不再横向滚动。托盘右键换成可换皮肤的弹层;更新下载显示进度,退出后再由脚本拉起安装器,避免还占着 exe。
This commit is contained in:
李琦
2026-08-19 15:46:03 +08:00
parent 1694397245
commit 7c4109f687
38 changed files with 2618 additions and 129 deletions

123
update.go
View File

@@ -13,6 +13,7 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
@@ -70,7 +71,7 @@ func (a *App) SkipAppUpdate(version string) error {
return a.store.SetMeta("app_update_skipped", strings.TrimSpace(version))
}
// DownloadAndInstallUpdate 下载最新安装包、校验哈希后启动并退出应用
// DownloadAndInstallUpdate 下载最新安装包、校验后退出应用并由独立脚本拉起安装器
func (a *App) DownloadAndInstallUpdate() error {
if e := a.ready(); e != nil {
return e
@@ -86,20 +87,12 @@ func (a *App) DownloadAndInstallUpdate() error {
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")
}
if e := launchDownloadedInstaller(path); e != nil {
return e
}
a.store.Log("info", "系统", "开始安装更新", info.Version)
go func() {
time.Sleep(800 * time.Millisecond)
a.QuitApp()
}()
// 安装脚本会等本进程退出后再 start 安装器,避免占用 exe / 作业对象杀掉子进程。
a.QuitApp()
return nil
}
@@ -152,9 +145,8 @@ func (a *App) downloadRelease(info *appLatestInfo) (string, error) {
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)
}
// 公开接口不带过期 JWT避免网关/反代因 Authorization 直接 401。
req.Header.Set("Accept-Encoding", "identity")
client := &http.Client{Timeout: 30 * time.Minute}
resp, e := client.Do(req)
if e != nil {
@@ -171,15 +163,23 @@ func (a *App) downloadRelease(info *appLatestInfo) (string, 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")
_ = os.MkdirAll(dir, 0755)
outPath := filepath.Join(dir, info.Version+"-installer.exe")
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()
n, e := io.Copy(io.MultiWriter(f, h), resp.Body)
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)
@@ -191,11 +191,94 @@ func (a *App) downloadRelease(info *appLatestInfo) (string, error) {
return "", errors.New("SHA256_MISMATCH")
}
if info.SizeBytes > 0 && n != info.SizeBytes {
a.store.Log("warning", "系统", "更新包大小与元数据不一致", fmt.Sprintf("%d vs %d", 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)