Files
u-desk/internal/service/update.go
T
lxy c7978a5bfd 修复: 版本更新与设置面板走查问题
- 更新链路: 检查记录远端SHA256,下载完成事件回传,安装时带哈希校验
- 续传: 服务器忽略Range返回200时截断重下防追加损坏,文件名去query
- 定时器: 自动检查可重入,配置变更免重启生效,关闭即停,下载加互斥
- 前端: 支持跳过此版本并按版本去重,手动检查不受门禁限制,启动未就绪延时重试
- 更新面板: 新增自动检查开关/间隔/地址配置与进度条状态映射
- 版本历史: cache参数强制刷新与加载超时重试兜底
- 配置: 侧边栏分区全链路持久化,保存请求复用api类型防字段丢失
- 侧边栏: 区块定位改显式类名,不再依赖排序位置
- 绑定: 重新生成并与镜像目录同步
2026-10-03 10:18:27 +08:00

393 lines
10 KiB
Go

package service
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"u-desk/internal/common"
)
// ==================== 类型定义 ====================
// UpdateService 更新服务
type UpdateService struct {
checkURL string // 版本检查接口 URL
}
// RemoteVersionInfo 远程版本信息
type RemoteVersionInfo struct {
Version string `json:"version"`
DownloadURL string `json:"download_url"`
Changelog string `json:"changelog"`
ForceUpdate bool `json:"force_update"`
ReleaseDate string `json:"release_date"`
FileSize int64 `json:"file_size"`
SHA256 string `json:"sha256"`
}
// UpdateCheckResult 更新检查结果
type UpdateCheckResult struct {
HasUpdate bool `json:"has_update"`
CurrentVersion string `json:"current_version"`
LatestVersion string `json:"latest_version"`
DownloadURL string `json:"download_url"`
Changelog string `json:"changelog"`
ForceUpdate bool `json:"force_update"`
ReleaseDate string `json:"release_date"`
FileSize int64 `json:"file_size"`
SHA256 string `json:"sha256"`
}
// InstallResult 安装结果
type InstallResult struct {
Success bool `json:"success"`
Message string `json:"message"`
}
// ==================== 更新服务 ====================
// NewUpdateService 创建更新服务
func NewUpdateService(checkURL string) *UpdateService {
return &UpdateService{
checkURL: checkURL,
}
}
// CheckUpdate 检查更新
func (s *UpdateService) CheckUpdate() (*UpdateCheckResult, error) {
config, err := LoadUpdateConfig()
if err != nil {
return nil, fmt.Errorf("加载配置失败: %v", err)
}
// 获取当前版本(使用缓存)
currentVersion, err := ParseVersion(GetCurrentVersion())
if err != nil {
return nil, fmt.Errorf("解析当前版本失败: %v", err)
}
// 请求远程版本信息
remoteInfo, err := s.fetchRemoteVersionInfo()
if err != nil {
return nil, fmt.Errorf("获取远程版本信息失败: %v", err)
}
// 解析远程版本号
remoteVersion, err := ParseVersion(remoteInfo.Version)
if err != nil {
return nil, fmt.Errorf("解析远程版本号失败: %v", err)
}
// 比较版本
hasUpdate := remoteVersion.IsNewerThan(currentVersion)
// 更新最后检查时间
config.UpdateLastCheckTime()
return &UpdateCheckResult{
HasUpdate: hasUpdate,
CurrentVersion: GetCurrentVersion(),
LatestVersion: remoteInfo.Version,
DownloadURL: remoteInfo.DownloadURL,
Changelog: remoteInfo.Changelog,
ForceUpdate: remoteInfo.ForceUpdate,
ReleaseDate: remoteInfo.ReleaseDate,
FileSize: remoteInfo.FileSize,
SHA256: remoteInfo.SHA256,
}, nil
}
// fetchRemoteVersionInfo 获取远程版本信息
func (s *UpdateService) fetchRemoteVersionInfo() (*RemoteVersionInfo, error) {
if s.checkURL == "" {
return nil, fmt.Errorf("版本检查 URL 未配置,请先设置检查地址")
}
// 添加时间戳参数防止缓存
timestamp := time.Now().UnixMilli()
var requestURL string
if strings.Contains(s.checkURL, "?") {
requestURL = fmt.Sprintf("%s&t=%d", s.checkURL, timestamp)
} else {
requestURL = fmt.Sprintf("%s?t=%d", s.checkURL, timestamp)
}
// 创建 HTTP 客户端,设置超时
client := &http.Client{
Timeout: 10 * time.Second,
}
// 发送请求
resp, err := client.Get(requestURL)
if err != nil {
return nil, fmt.Errorf("网络请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("服务器返回错误状态码: %d", resp.StatusCode)
}
// 读取响应
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %v", err)
}
// 解析 JSON
var remoteInfo RemoteVersionInfo
if err := json.Unmarshal(body, &remoteInfo); err != nil {
return nil, fmt.Errorf("解析响应失败: %v", err)
}
if remoteInfo.Version == "" {
return nil, fmt.Errorf("远程版本信息不完整")
}
return &remoteInfo, nil
}
// ==================== 安装更新 ====================
// InstallUpdate 安装更新包
func InstallUpdate(installerPath string, autoRestart bool) (*InstallResult, error) {
return InstallUpdateWithHash(installerPath, autoRestart, "", "")
}
// InstallUpdateWithHash 安装更新包(带哈希验证)
func InstallUpdateWithHash(installerPath string, autoRestart bool, expectedHash string, hashType string) (*InstallResult, error) {
if _, err := os.Stat(installerPath); os.IsNotExist(err) {
return nil, fmt.Errorf("安装文件不存在: %s", installerPath)
}
// 哈希验证
if expectedHash != "" && hashType != "" {
valid, err := VerifyFileHash(installerPath, expectedHash, hashType)
if err != nil {
return &InstallResult{Success: false, Message: "文件验证失败: " + err.Error()}, nil
}
if !valid {
return &InstallResult{Success: false, Message: "文件哈希值不匹配,文件可能已损坏或被篡改"}, nil
}
}
// 备份
backupPath, err := BackupApplication()
if err != nil {
return &InstallResult{Success: false, Message: fmt.Sprintf("备份失败: %v", err)}, nil
}
// 安装
ext := filepath.Ext(installerPath)
switch ext {
case ".exe":
if runtime.GOOS != "windows" {
return &InstallResult{Success: false, Message: "当前系统不是 Windows,无法安装 .exe 文件"}, nil
}
err = installExe(installerPath)
case ".zip":
err = installZip(installerPath)
default:
return nil, fmt.Errorf("不支持的安装包格式: %s", ext)
}
// 处理安装结果
if err != nil {
// 安装失败,尝试回滚
if backupPath != "" {
_ = rollbackFromBackup(backupPath)
}
return &InstallResult{Success: false, Message: fmt.Sprintf("安装失败: %v", err)}, nil
}
// 自动重启
if autoRestart {
go func() {
time.Sleep(2 * time.Second)
restartApplication()
}()
}
return &InstallResult{Success: true, Message: "安装成功"}, nil
}
// ==================== 安装相关辅助函数 ====================
// installExe 安装 exe 文件
func installExe(exePath string) error {
execPath, err := os.Executable()
if err != nil {
return err
}
return replaceExecutableFile(exePath, execPath)
}
// installZip 安装 ZIP 压缩包
func installZip(zipPath string) error {
// 这里需要导入 archive/zip 包
return fmt.Errorf("ZIP 安装暂未实现")
}
// replaceExecutableFile 替换可执行文件(Windows 和 Unix 通用逻辑)
func replaceExecutableFile(newFilePath, execPath string) error {
if runtime.GOOS == "windows" {
return replaceExecutableFileWindows(newFilePath, execPath)
}
// Unix-like: 直接替换
if err := common.CopyFile(newFilePath, execPath); err != nil {
return fmt.Errorf("复制文件失败: %v", err)
}
return os.Chmod(execPath, 0755)
}
// replaceExecutableFileWindows Windows 平台替换可执行文件
func replaceExecutableFileWindows(newFilePath, execPath string) error {
oldExecPath := execPath + ".old"
newExecPathTemp := execPath + ".new"
// 清理旧文件
os.Remove(oldExecPath)
os.Remove(newExecPathTemp)
// 复制新文件到临时位置
if err := common.CopyFile(newFilePath, newExecPathTemp); err != nil {
return fmt.Errorf("复制新文件失败: %v", err)
}
// 尝试重命名当前文件(如果失败,说明文件正在使用)
if err := os.Rename(execPath, oldExecPath); err != nil {
return fmt.Errorf("当前文件正在使用,无法替换: %w", err)
}
// 替换文件
if err := os.Rename(newExecPathTemp, execPath); err != nil {
os.Rename(oldExecPath, execPath) // 恢复
return fmt.Errorf("替换文件失败: %v", err)
}
// 延迟删除旧文件
go func() {
time.Sleep(10 * time.Second)
os.Remove(oldExecPath)
}()
return nil
}
// restartApplication 重启应用
func restartApplication() {
execPath, err := os.Executable()
if err != nil {
return
}
currentPID := os.Getpid()
if runtime.GOOS != "windows" {
return
}
replacePendingFile(execPath)
// 创建并执行重启脚本
tempDir := os.TempDir()
batFile := filepath.Join(tempDir, fmt.Sprintf("restart_%d.bat", currentPID))
execDir := filepath.Dir(execPath)
batContent := fmt.Sprintf(`@echo off
cd /d "%s"
start "" "%s"
timeout /t 3 /nobreak >nul
taskkill /PID %d /F >nul 2>&1
del "%%~f0"
`, execDir, execPath, currentPID)
if err := os.WriteFile(batFile, []byte(batContent), 0644); err != nil {
fallbackRestart(execPath)
return
}
cmd := exec.Command("cmd", "/C", batFile)
cmd.Stdout = nil
cmd.Stderr = nil
if err := cmd.Start(); err != nil {
fallbackRestart(execPath)
return
}
os.Exit(0)
}
// fallbackRestart 降级重启方案
func fallbackRestart(execPath string) {
exec.Command("cmd", "/C", "start", "", execPath).Start()
time.Sleep(2 * time.Second)
os.Exit(0)
}
// replacePendingFile 替换待替换的文件(.new -> 可执行文件)
func replacePendingFile(execPath string) error {
newExecPathTemp := execPath + ".new"
if _, err := os.Stat(newExecPathTemp); os.IsNotExist(err) {
return nil // 没有待替换文件
}
oldExecPath := execPath + ".old"
os.Remove(oldExecPath)
if err := os.Rename(newExecPathTemp, execPath); err != nil {
return fmt.Errorf("文件替换失败: %v", err)
}
return nil
}
// rollbackFromBackup 从备份恢复
func rollbackFromBackup(backupPath string) error {
execPath, err := os.Executable()
if err != nil {
return err
}
return common.CopyFile(backupPath, execPath)
}
// BackupApplication 备份当前应用
func BackupApplication() (string, error) {
execPath, err := os.Executable()
if err != nil {
return "", err
}
backupDir := filepath.Join(common.GetUserDataDir(), "backups")
if err := os.MkdirAll(backupDir, 0755); err != nil {
return "", fmt.Errorf("创建备份目录失败: %v", err)
}
timestamp := time.Now().Format("20060102-150405")
backupFileName := fmt.Sprintf("u-desk-backup-%s%s", timestamp, filepath.Ext(execPath))
backupPath := filepath.Join(backupDir, backupFileName)
if err := common.CopyFile(execPath, backupPath); err != nil {
return "", fmt.Errorf("复制文件失败: %v", err)
}
return backupPath, nil
}
// ReplacePendingFile 启动时完成上次更新遗留的 exe 替换。
// 此时进程刚启动,exe 文件无占用,可安全完成上次因文件占用未完成的替换,
// 并清理残留的 .old 备份。
func ReplacePendingFile() {
execPath, err := os.Executable()
if err != nil {
return
}
_ = replacePendingFile(execPath) // 完成遗留的 .new -> exe 替换
os.Remove(execPath + ".old") // 清理残留备份
}