Files
u-tabs/internal/update.go
T
lxy 95f349bcd7 新增: 历史会话标题自定义编辑与摘要质量修复
- 标题自定义编辑:HISTORY 详情按 e 编辑标题,UserTitle 持久化到缓存且最高优先级
- AI 摘要质量修复:困惑回复检测词表收紧,消息提取过滤噪音,修复 JSON 包裹困惑回复漏网
- 数据维护命令:--fix-titles 清理低质量摘要,--regen-summaries 并发生成
- 修复代码审查 9 个问题:退格 UTF-8 损坏、中文输入、flush 调度、误杀合法摘要、
  按键全局拦截、UserTitle 未同步窗口标题、wt 启动回落、cmd 注入防护、前缀误隐藏会话
- 启动链 wt.exe 回退与 cmd start 标题转义
- gofmt 规范化,新增 launch_test.go
2026-07-31 21:22:17 +08:00

269 lines
5.8 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 internal
import (
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"charm.land/bubbletea/v2"
)
// Version 当前版本,发布时更新
const Version = "0.2.3"
// --- 远程 JSON 结构 ---
type platformInfo struct {
DownloadURL string `json:"download_url"`
FileSize int64 `json:"file_size"`
SHA256 string `json:"sha256"`
}
type versionInfo struct {
Version string `json:"version"`
ReleaseDate string `json:"release_date"`
Changelog string `json:"changelog"`
Platforms map[string]platformInfo `json:"platforms"`
}
// --- bubbletea 消息类型 ---
// UpdateAvailableMsg 发现新版本
type UpdateAvailableMsg struct {
NewVersion string
Changelog string
DownloadURL string
SHA256 string
FileSize int64
}
// UpdateCompleteMsg 更新完成
type UpdateCompleteMsg struct {
NewVersion string
}
// UpdateErrorMsg 更新失败
type UpdateErrorMsg struct {
Err error
}
// --- 更新状态 ---
// UpdateState 更新流程状态
type UpdateState struct {
Checking bool
Available bool
Updating bool
NewVersion string
Changelog string
DownloadURL string
SHA256 string
FileSize int64
Done bool
Error error
}
// --- Cmd 函数 ---
// CheckUpdateCmd 检查远程是否有新版本,返回 bubbletea Cmd
func CheckUpdateCmd() tea.Cmd {
return func() tea.Msg {
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest("GET", "https://c.1216.top/u-tabs/last-version.json", nil)
if err != nil {
return nil
}
req.Header.Set("User-Agent", "u-tabs/"+Version)
resp, err := client.Do(req)
if err != nil {
return nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil
}
var info versionInfo
if err := json.Unmarshal(body, &info); err != nil {
return nil
}
// 查找当前平台的下载信息
platformKey := platformKey
pi, ok := info.Platforms[platformKey]
if !ok {
return nil
}
// 比较版本
if !semverCompare(Version, info.Version) {
return nil
}
return UpdateAvailableMsg{
NewVersion: info.Version,
Changelog: info.Changelog,
DownloadURL: pi.DownloadURL,
SHA256: pi.SHA256,
FileSize: pi.FileSize,
}
}
}
// SelfUpdateCmd 下载并替换当前二进制,返回 bubbletea Cmd
func SelfUpdateCmd(downloadURL, expectedSHA256, newVersion string) tea.Cmd {
return func() tea.Msg {
// 下载到临时文件
client := &http.Client{Timeout: 5 * time.Minute}
req, err := http.NewRequest("GET", downloadURL, nil)
if err != nil {
return UpdateErrorMsg{Err: fmt.Errorf("create request failed: %w", err)}
}
req.Header.Set("User-Agent", "u-tabs/"+Version)
resp, err := client.Do(req)
if err != nil {
return UpdateErrorMsg{Err: fmt.Errorf("download failed: %w", err)}
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return UpdateErrorMsg{Err: fmt.Errorf("download returned status %d", resp.StatusCode)}
}
tmpFile, err := os.CreateTemp("", "u-tabs-update-*")
if err != nil {
return UpdateErrorMsg{Err: fmt.Errorf("create temp file failed: %w", err)}
}
tmpPath := tmpFile.Name()
hasher := sha256.New()
if _, err := io.Copy(io.MultiWriter(tmpFile, hasher), resp.Body); err != nil {
tmpFile.Close()
os.Remove(tmpPath)
return UpdateErrorMsg{Err: fmt.Errorf("download write failed: %w", err)}
}
tmpFile.Close()
// 校验 SHA256
actualSHA := fmt.Sprintf("%x", hasher.Sum(nil))
if !strings.EqualFold(actualSHA, expectedSHA256) {
os.Remove(tmpPath)
return UpdateErrorMsg{Err: fmt.Errorf("sha256 mismatch: expected %s, got %s", expectedSHA256, actualSHA)}
}
// 替换当前二进制
exePath, err := os.Executable()
if err != nil {
os.Remove(tmpPath)
return UpdateErrorMsg{Err: fmt.Errorf("get executable path failed: %w", err)}
}
// 备份旧二进制
oldPath := exePath + ".old"
_ = os.Remove(oldPath) // 清理可能残留的旧备份
if err := os.Rename(exePath, oldPath); err != nil {
os.Remove(tmpPath)
return UpdateErrorMsg{Err: fmt.Errorf("rename old binary failed: %w", err)}
}
// 复制新二进制到原路径
if err := copyFile(tmpPath, exePath); err != nil {
// 回滚:恢复旧二进制
os.Remove(exePath)
os.Rename(oldPath, exePath)
os.Remove(tmpPath)
return UpdateErrorMsg{Err: fmt.Errorf("copy new binary failed: %w", err)}
}
// Linux/macOS: 设置可执行权限
if !isWindows {
os.Chmod(exePath, 0755)
}
// 清理临时文件和旧备份
os.Remove(tmpPath)
_ = os.Remove(oldPath)
return UpdateCompleteMsg{NewVersion: newVersion}
}
}
// --- 辅助函数 ---
// semverCompare 比较 v1 和 v2,v2 > v1 时返回 true
func semverCompare(v1, v2 string) bool {
parts1 := strings.Split(v1, ".")
parts2 := strings.Split(v2, ".")
maxLen := len(parts1)
if len(parts2) > maxLen {
maxLen = len(parts2)
}
for i := 0; i < maxLen; i++ {
var n1, n2 int
if i < len(parts1) {
n1 = parseSemverPart(parts1[i])
}
if i < len(parts2) {
n2 = parseSemverPart(parts2[i])
}
if n2 > n1 {
return true
}
if n2 < n1 {
return false
}
}
return false // 版本相同
}
// parseSemverPart 解析语义版本的一段为数字,非数字前缀的部分返回 0
func parseSemverPart(s string) int {
// 去除可能的前缀字母 (如 "v1" → "1")
n := 0
for _, c := range s {
if c >= '0' && c <= '9' {
n = n*10 + int(c-'0')
} else {
break
}
}
return n
}
// copyFile 复制文件内容
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Sync()
}