新增: 自动更新与一键自升级
This commit is contained in:
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
# Binary
|
||||
u-tabs
|
||||
u-tabs.exe
|
||||
u-tabs-linux
|
||||
whale-windows-amd64.exe
|
||||
dist/
|
||||
|
||||
# Config
|
||||
config.yaml
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
@@ -30,6 +30,7 @@ type Model struct {
|
||||
launched string
|
||||
history HistoryState
|
||||
pendingCmd string // Linux: 退出后执行的命令
|
||||
update UpdateState
|
||||
}
|
||||
|
||||
func NewModel() *Model {
|
||||
@@ -40,7 +41,8 @@ func NewModel() *Model {
|
||||
}
|
||||
|
||||
func (m *Model) Init() tea.Cmd {
|
||||
return nil
|
||||
m.update.Checking = true
|
||||
return CheckUpdateCmd()
|
||||
}
|
||||
|
||||
// totalTabs 包含 HISTORY 的总 Tab 数
|
||||
@@ -71,6 +73,22 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case SummaryResultMsg:
|
||||
m.applySummary(msg.SessionID, msg.Summary, msg.Completed, msg.Pending)
|
||||
return m, m.nextSummaryCmd()
|
||||
case UpdateAvailableMsg:
|
||||
m.update.Checking = false
|
||||
m.update.Available = true
|
||||
m.update.NewVersion = msg.NewVersion
|
||||
m.update.Changelog = msg.Changelog
|
||||
m.update.DownloadURL = msg.DownloadURL
|
||||
m.update.SHA256 = msg.SHA256
|
||||
m.update.FileSize = msg.FileSize
|
||||
case UpdateCompleteMsg:
|
||||
m.update.Updating = false
|
||||
m.update.Done = true
|
||||
m.update.Available = false
|
||||
case UpdateErrorMsg:
|
||||
m.update.Updating = false
|
||||
m.update.Error = msg.Err
|
||||
m.update.Checking = false
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -82,6 +100,11 @@ func (m *Model) handleKey(s string) (*Model, tea.Cmd) {
|
||||
switch s {
|
||||
case "q", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "u":
|
||||
if m.update.Available && !m.update.Updating {
|
||||
m.update.Updating = true
|
||||
return m, SelfUpdateCmd(m.update.DownloadURL, m.update.SHA256, m.update.NewVersion)
|
||||
}
|
||||
}
|
||||
|
||||
// 数字键跳转 Tab
|
||||
@@ -400,7 +423,7 @@ func (m *Model) render() string {
|
||||
var b strings.Builder
|
||||
|
||||
// ── header: title + tabs ──
|
||||
b.WriteString(style.TitleStyle.Render(" u-tabs "))
|
||||
b.WriteString(style.TitleStyle.Render(" u-tabs v" + Version + " "))
|
||||
sep := style.TabSep.Render(" | ")
|
||||
|
||||
// Workspace Tabs
|
||||
@@ -455,6 +478,20 @@ func (m *Model) render() string {
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(style.Accent).Render(fmt.Sprintf(" ✓ %s", m.launched)))
|
||||
m.launched = ""
|
||||
}
|
||||
// update status
|
||||
if m.update.Done {
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(style.Success).Bold(true).
|
||||
Render(fmt.Sprintf(" updated to v%s, restart to apply", m.update.NewVersion)))
|
||||
} else if m.update.Error != nil {
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(style.Red).
|
||||
Render(fmt.Sprintf(" update failed: %v", m.update.Error)))
|
||||
} else if m.update.Updating {
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(style.Warning).
|
||||
Render(" updating..."))
|
||||
} else if m.update.Available {
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(style.Warning).Bold(true).
|
||||
Render(fmt.Sprintf(" v%s available [u]update", m.update.NewVersion)))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
helpParts := m.renderHelp()
|
||||
|
||||
269
internal/update.go
Normal file
269
internal/update.go
Normal file
@@ -0,0 +1,269 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// Version 当前版本,发布时更新
|
||||
const Version = "0.1.0"
|
||||
|
||||
// --- 远程 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 := runtime.GOOS + "-" + runtime.GOARCH
|
||||
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 runtime.GOOS != "windows" {
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user