Files
ssq-desk/internal/service/update_install.go
2026-01-14 14:17:38 +08:00

329 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 service
import (
"archive/zip"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
)
// InstallResult 安装结果
type InstallResult struct {
Success bool `json:"success"`
Message string `json:"message"`
}
// 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
}
// getExecutablePath 获取当前可执行文件路径
func getExecutablePath() (string, error) {
execPath, err := os.Executable()
if err != nil {
return "", fmt.Errorf("获取可执行文件路径失败: %v", err)
}
return execPath, nil
}
// installExe 安装 exe 文件
func installExe(exePath string) error {
execPath, err := getExecutablePath()
if err != nil {
return err
}
return replaceExecutableFile(exePath, execPath)
}
// installZip 安装 ZIP 压缩包
func installZip(zipPath string) error {
zipReader, err := zip.OpenReader(zipPath)
if err != nil {
return fmt.Errorf("打开 ZIP 文件失败: %v", err)
}
defer zipReader.Close()
execPath, err := getExecutablePath()
if err != nil {
return err
}
execDir := filepath.Dir(execPath)
execName := filepath.Base(execPath)
// 解压到临时目录
tempDir := filepath.Join(execDir, ".update-temp")
if err := os.MkdirAll(tempDir, 0755); err != nil {
return fmt.Errorf("创建临时目录失败: %v", err)
}
defer os.RemoveAll(tempDir)
// 解压文件
for _, file := range zipReader.File {
filePath := filepath.Join(tempDir, file.Name)
if file.FileInfo().IsDir() {
os.MkdirAll(filePath, file.FileInfo().Mode())
continue
}
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
return fmt.Errorf("创建目录失败: %v", err)
}
rc, err := file.Open()
if err != nil {
return fmt.Errorf("打开 ZIP 文件项失败: %v", err)
}
targetFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.FileInfo().Mode())
if err != nil {
rc.Close()
return fmt.Errorf("创建目标文件失败: %v", err)
}
if _, err := io.Copy(targetFile, rc); err != nil {
targetFile.Close()
rc.Close()
return fmt.Errorf("复制文件失败: %v", err)
}
targetFile.Close()
rc.Close()
}
// 查找新的可执行文件
newExecPath := filepath.Join(tempDir, execName)
if _, err := os.Stat(newExecPath); os.IsNotExist(err) {
files, _ := os.ReadDir(tempDir)
for _, f := range files {
if !f.IsDir() {
newExecPath = filepath.Join(tempDir, f.Name())
break
}
}
}
// 替换文件(使用与 installExe 相同的逻辑)
return replaceExecutableFile(newExecPath, execPath)
}
// replaceExecutableFile 替换可执行文件Windows 和 Unix 通用逻辑)
func replaceExecutableFile(newFilePath, execPath string) error {
if runtime.GOOS == "windows" {
return replaceExecutableFileWindows(newFilePath, execPath)
}
// Unix-like: 直接替换
if err := 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 := copyFile(newFilePath, newExecPathTemp); err != nil {
return fmt.Errorf("复制新文件失败: %v", err)
}
// 尝试重命名当前文件(如果失败,说明文件正在使用)
if err := os.Rename(execPath, oldExecPath); err != nil {
return nil
}
// 替换文件
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 := getExecutablePath()
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 := getExecutablePath()
if err != nil {
return err
}
return copyFile(backupPath, execPath)
}
// BackupApplication 备份当前应用
func BackupApplication() (string, error) {
execPath, err := getExecutablePath()
if err != nil {
return "", err
}
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("获取用户目录失败: %v", err)
}
backupDir := filepath.Join(homeDir, ".ssq-desk", "backups")
if err := os.MkdirAll(backupDir, 0755); err != nil {
return "", fmt.Errorf("创建备份目录失败: %v", err)
}
timestamp := time.Now().Format("20060102-150405")
backupFileName := fmt.Sprintf("ssq-desk-backup-%s%s", timestamp, filepath.Ext(execPath))
backupPath := filepath.Join(backupDir, backupFileName)
sourceFile, err := os.Open(execPath)
if err != nil {
return "", fmt.Errorf("打开源文件失败: %v", err)
}
defer sourceFile.Close()
backupFile, err := os.Create(backupPath)
if err != nil {
return "", fmt.Errorf("创建备份文件失败: %v", err)
}
defer backupFile.Close()
if _, err := backupFile.ReadFrom(sourceFile); err != nil {
return "", fmt.Errorf("复制文件失败: %v", err)
}
return backupPath, nil
}