Files
MyGoNavi/internal/app/methods_update.go
2026-04-11 21:53:52 +08:00

1254 lines
36 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 app
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"os"
"os/exec"
"path/filepath"
stdRuntime "runtime"
"strconv"
"strings"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/logger"
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
const (
updateRepo = "Syngnat/GoNavi"
updateAPIURL = "https://api.github.com/repos/" + updateRepo + "/releases/latest"
updateChecksumAsset = "SHA256SUMS"
updateDownloadProgressEvent = "update:download-progress"
)
type updateState struct {
lastCheck *UpdateInfo
downloading bool
staged *stagedUpdate
}
type UpdateInfo struct {
HasUpdate bool `json:"hasUpdate"`
CurrentVersion string `json:"currentVersion"`
LatestVersion string `json:"latestVersion"`
ReleaseName string `json:"releaseName"`
ReleaseNotesURL string `json:"releaseNotesUrl"`
AssetName string `json:"assetName"`
AssetURL string `json:"assetUrl"`
AssetSize int64 `json:"assetSize"`
SHA256 string `json:"sha256"`
Downloaded bool `json:"downloaded"`
DownloadPath string `json:"downloadPath,omitempty"`
}
type AppInfo struct {
Version string `json:"version"`
Author string `json:"author"`
RepoURL string `json:"repoUrl,omitempty"`
IssueURL string `json:"issueUrl,omitempty"`
ReleaseURL string `json:"releaseUrl,omitempty"`
CommunityURL string `json:"communityUrl,omitempty"`
BuildTime string `json:"buildTime,omitempty"`
}
type updateDownloadResult struct {
Info UpdateInfo `json:"info"`
DownloadPath string `json:"downloadPath,omitempty"`
InstallLogPath string `json:"installLogPath,omitempty"`
InstallTarget string `json:"installTarget,omitempty"`
Platform string `json:"platform"`
AutoRelaunch bool `json:"autoRelaunch"`
}
type updateDownloadProgressPayload struct {
Status string `json:"status"`
Percent float64 `json:"percent"`
Downloaded int64 `json:"downloaded"`
Total int64 `json:"total"`
Message string `json:"message,omitempty"`
}
type stagedUpdate struct {
Version string
AssetName string
FilePath string
StagedDir string
InstallLogPath string
}
type githubRelease struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
HTMLURL string `json:"html_url"`
Prerelease bool `json:"prerelease"`
Assets []githubAsset `json:"assets"`
}
type githubAsset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
Size int64 `json:"size"`
}
func (a *App) CheckForUpdates() connection.QueryResult {
info, err := fetchLatestUpdateInfo()
if err != nil {
logger.Error(err, "检查更新失败")
return connection.QueryResult{Success: false, Message: err.Error()}
}
var currentStaged *stagedUpdate
a.updateMu.Lock()
currentStaged = a.updateState.staged
a.updateMu.Unlock()
if info.HasUpdate {
reusable := resolveReusableStagedUpdate(info, currentStaged)
if reusable != nil {
info.Downloaded = true
info.DownloadPath = reusable.FilePath
currentStaged = reusable
} else if currentStaged != nil && currentStaged.Version != info.LatestVersion {
currentStaged = nil
}
} else {
currentStaged = nil
}
a.updateMu.Lock()
a.updateState.lastCheck = &info
a.updateState.staged = currentStaged
a.updateMu.Unlock()
msg := "已是最新版本"
if info.HasUpdate {
msg = fmt.Sprintf("发现新版本:%s", info.LatestVersion)
}
return connection.QueryResult{Success: true, Message: msg, Data: info}
}
func (a *App) GetAppInfo() connection.QueryResult {
info := AppInfo{
Version: getCurrentVersion(),
Author: getCurrentAuthor(),
RepoURL: "https://github.com/" + updateRepo,
IssueURL: "https://github.com/" + updateRepo + "/issues",
ReleaseURL: "https://github.com/" + updateRepo + "/releases",
CommunityURL: "https://aibook.ren",
BuildTime: strings.TrimSpace(AppBuildTime),
}
return connection.QueryResult{Success: true, Message: "OK", Data: info}
}
func (a *App) DownloadUpdate() connection.QueryResult {
a.updateMu.Lock()
if a.updateState.downloading {
a.updateMu.Unlock()
return connection.QueryResult{Success: false, Message: "更新包正在下载中,请稍后重试"}
}
info := a.updateState.lastCheck
if info == nil {
a.updateMu.Unlock()
return connection.QueryResult{Success: false, Message: "请先检查更新"}
}
if !info.HasUpdate {
a.updateMu.Unlock()
return connection.QueryResult{Success: false, Message: "当前已是最新版本"}
}
if info.AssetURL == "" || info.AssetName == "" {
a.updateMu.Unlock()
return connection.QueryResult{Success: false, Message: "未找到可用的更新包"}
}
staged := resolveReusableStagedUpdate(*info, a.updateState.staged)
if staged != nil {
a.updateState.staged = staged
a.updateMu.Unlock()
return connection.QueryResult{Success: true, Message: "更新包已下载完成", Data: buildUpdateDownloadResult(*info, staged)}
}
a.updateState.staged = nil
a.updateState.downloading = true
a.updateMu.Unlock()
a.emitUpdateDownloadProgress("start", 0, info.AssetSize, "")
result := a.downloadAndStageUpdate(*info)
a.updateMu.Lock()
a.updateState.downloading = false
a.updateMu.Unlock()
return result
}
func (a *App) InstallUpdateAndRestart() connection.QueryResult {
a.updateMu.Lock()
staged := a.updateState.staged
if staged != nil && strings.TrimSpace(staged.InstallLogPath) == "" {
staged.InstallLogPath = buildUpdateInstallLogPath(filepath.Dir(staged.FilePath))
}
a.updateMu.Unlock()
if staged == nil {
return connection.QueryResult{Success: false, Message: "未找到已下载的更新包"}
}
if err := launchUpdateScript(staged); err != nil {
logger.Error(err, "启动更新脚本失败")
msg := err.Error()
if staged.InstallLogPath != "" {
msg = fmt.Sprintf("%s更新日志%s", msg, staged.InstallLogPath)
}
return connection.QueryResult{
Success: false,
Message: msg,
Data: map[string]any{
"logPath": staged.InstallLogPath,
},
}
}
go func() {
time.Sleep(300 * time.Millisecond)
wailsRuntime.Quit(a.ctx)
// 兜底退出,避免某些平台/窗口状态下 Quit 未真正结束进程,导致更新脚本一直等待。
time.Sleep(2 * time.Second)
os.Exit(0)
}()
msg := "更新已开始安装"
if staged.InstallLogPath != "" {
msg = fmt.Sprintf("更新已开始安装,日志路径:%s", staged.InstallLogPath)
}
return connection.QueryResult{
Success: true,
Message: msg,
Data: map[string]any{
"logPath": staged.InstallLogPath,
},
}
}
func (a *App) OpenDownloadedUpdateDirectory() connection.QueryResult {
a.updateMu.Lock()
staged := a.updateState.staged
a.updateMu.Unlock()
if staged == nil {
return connection.QueryResult{Success: false, Message: "未找到已下载的更新包"}
}
assetPath := strings.TrimSpace(staged.FilePath)
if assetPath == "" {
return connection.QueryResult{Success: false, Message: "更新包路径为空"}
}
dirPath := strings.TrimSpace(filepath.Dir(assetPath))
if dirPath == "" || dirPath == "." {
return connection.QueryResult{Success: false, Message: "无法解析更新目录"}
}
if stat, err := os.Stat(dirPath); err != nil || !stat.IsDir() {
return connection.QueryResult{Success: false, Message: "更新目录不存在或不可访问"}
}
var cmd *exec.Cmd
switch stdRuntime.GOOS {
case "darwin":
cmd = exec.Command("open", dirPath)
case "windows":
cmd = exec.Command("explorer", dirPath)
case "linux":
cmd = exec.Command("xdg-open", dirPath)
default:
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前平台暂不支持打开目录:%s", stdRuntime.GOOS)}
}
if err := cmd.Start(); err != nil {
logger.Error(err, "打开更新目录失败")
return connection.QueryResult{Success: false, Message: fmt.Sprintf("打开更新目录失败:%v", err)}
}
return connection.QueryResult{
Success: true,
Message: fmt.Sprintf("已打开安装目录:%s", dirPath),
Data: map[string]any{
"path": dirPath,
},
}
}
func (a *App) downloadAndStageUpdate(info UpdateInfo) connection.QueryResult {
workspaceDir := strings.TrimSpace(resolveUpdateWorkspaceDir(info.LatestVersion))
if workspaceDir == "" {
a.emitUpdateDownloadProgress("error", 0, info.AssetSize, "无法确定当前应用目录")
return connection.QueryResult{Success: false, Message: "无法确定当前应用目录,无法下载更新"}
}
if err := os.MkdirAll(workspaceDir, 0o755); err != nil {
errMsg := fmt.Sprintf("无法访问应用目录:%s", workspaceDir)
a.emitUpdateDownloadProgress("error", 0, info.AssetSize, errMsg)
return connection.QueryResult{Success: false, Message: errMsg}
}
// 使用版本号命名的工作目录,便于识别和调试
stagedDir := filepath.Join(workspaceDir, fmt.Sprintf(".gonavi-update-%s-%s", stdRuntime.GOOS, info.LatestVersion))
// 清理可能残留的旧目录(上次下载失败后未清理)
// Windows 上文件可能被杀毒软件/索引服务占用,需要重试
for retry := 0; retry < 5; retry++ {
err := os.RemoveAll(stagedDir)
if err == nil {
break
}
if retry < 4 {
time.Sleep(time.Duration(retry+1) * 500 * time.Millisecond)
} else {
// 最后一次仍然失败,换一个带时间戳的目录名避免冲突
stagedDir = filepath.Join(workspaceDir, fmt.Sprintf(".gonavi-update-%s-%s-%d", stdRuntime.GOOS, info.LatestVersion, time.Now().UnixNano()))
}
}
if err := os.MkdirAll(stagedDir, 0o755); err != nil {
errMsg := fmt.Sprintf("无法在应用目录创建更新工作目录:%s", stagedDir)
a.emitUpdateDownloadProgress("error", 0, info.AssetSize, errMsg)
return connection.QueryResult{Success: false, Message: errMsg}
}
// macOS 下载包放在桌面版本目录根级;其他平台继续放在 staging 目录。
assetPath := resolveUpdateAssetPath(workspaceDir, stagedDir, info.AssetName)
actualHash, err := downloadFileWithHash(info.AssetURL, assetPath, func(downloaded, total int64) {
reportTotal := total
if reportTotal <= 0 {
reportTotal = info.AssetSize
}
a.emitUpdateDownloadProgress("downloading", downloaded, reportTotal, "")
})
if err != nil {
_ = os.Remove(assetPath)
_ = os.RemoveAll(stagedDir)
a.emitUpdateDownloadProgress("error", 0, info.AssetSize, err.Error())
return connection.QueryResult{Success: false, Message: err.Error()}
}
if info.SHA256 == "" {
_ = os.Remove(assetPath)
_ = os.RemoveAll(stagedDir)
a.emitUpdateDownloadProgress("error", 0, info.AssetSize, "缺少更新包校验值SHA256SUMS")
return connection.QueryResult{Success: false, Message: "缺少更新包校验值SHA256SUMS"}
}
if !strings.EqualFold(info.SHA256, actualHash) {
_ = os.Remove(assetPath)
_ = os.RemoveAll(stagedDir)
a.emitUpdateDownloadProgress("error", 0, info.AssetSize, "更新包校验失败,请重试")
return connection.QueryResult{Success: false, Message: "更新包校验失败,请重试"}
}
staged := &stagedUpdate{
Version: info.LatestVersion,
AssetName: info.AssetName,
FilePath: assetPath,
StagedDir: stagedDir,
InstallLogPath: buildUpdateInstallLogPath(workspaceDir),
}
info.Downloaded = true
info.DownloadPath = assetPath
a.updateMu.Lock()
a.updateState.staged = staged
a.updateMu.Unlock()
a.emitUpdateDownloadProgress("done", info.AssetSize, info.AssetSize, "")
return connection.QueryResult{Success: true, Message: "更新包下载完成", Data: buildUpdateDownloadResult(info, staged)}
}
func fetchLatestUpdateInfo() (UpdateInfo, error) {
release, err := fetchLatestRelease()
if err != nil {
return UpdateInfo{}, err
}
currentVersion := getCurrentVersion()
latestVersion := normalizeVersion(release.TagName)
if latestVersion == "" {
return UpdateInfo{}, errors.New("无法解析最新版本号")
}
assetVersion := strings.TrimSpace(release.TagName)
if assetVersion == "" {
assetVersion = latestVersion
}
assetName, err := expectedAssetName(stdRuntime.GOOS, stdRuntime.GOARCH, assetVersion)
if err != nil {
return UpdateInfo{}, err
}
asset, err := findReleaseAsset(release.Assets, assetName)
if err != nil {
return UpdateInfo{}, err
}
hashMap, err := fetchReleaseSHA256(release.Assets)
if err != nil {
return UpdateInfo{}, err
}
sha256Value := strings.TrimSpace(hashMap[assetName])
if sha256Value == "" {
return UpdateInfo{}, errors.New("SHA256SUMS 未包含当前平台更新包")
}
hasUpdate := compareVersion(currentVersion, latestVersion) < 0
return UpdateInfo{
HasUpdate: hasUpdate,
CurrentVersion: currentVersion,
LatestVersion: latestVersion,
ReleaseName: release.Name,
ReleaseNotesURL: release.HTMLURL,
AssetName: asset.Name,
AssetURL: asset.BrowserDownloadURL,
AssetSize: asset.Size,
SHA256: sha256Value,
}, nil
}
func getCurrentAuthor() string {
if env := strings.TrimSpace(os.Getenv("GONAVI_AUTHOR")); env != "" {
return env
}
parts := strings.Split(updateRepo, "/")
if len(parts) > 0 {
return parts[0]
}
return ""
}
func fetchLatestRelease() (*githubRelease, error) {
client := newHTTPClientWithGlobalProxy(15 * time.Second)
req, err := http.NewRequest(http.MethodGet, updateAPIURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "GoNavi-Updater")
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("检查更新失败HTTP %d", resp.StatusCode)
}
var release githubRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, err
}
return &release, nil
}
func expectedAssetName(goos, goarch, version string) (string, error) {
version = strings.TrimSpace(version)
version = strings.TrimPrefix(version, "v")
version = strings.TrimPrefix(version, "V")
if version == "" {
return "", errors.New("无法解析发布版本号")
}
switch goos {
case "windows":
if goarch == "amd64" {
return fmt.Sprintf("GoNavi-%s-Windows-Amd64.exe", version), nil
}
if goarch == "arm64" {
return fmt.Sprintf("GoNavi-%s-Windows-Arm64.exe", version), nil
}
case "darwin":
if goarch == "amd64" {
return fmt.Sprintf("GoNavi-%s-MacOS-Amd64.dmg", version), nil
}
if goarch == "arm64" {
return fmt.Sprintf("GoNavi-%s-MacOS-Arm64.dmg", version), nil
}
case "linux":
if goarch == "amd64" {
return fmt.Sprintf("GoNavi-%s-Linux-Amd64.tar.gz", version), nil
}
}
return "", fmt.Errorf("当前平台暂不支持在线更新:%s/%s", goos, goarch)
}
func findReleaseAsset(assets []githubAsset, name string) (*githubAsset, error) {
for _, asset := range assets {
if asset.Name == name {
return &asset, nil
}
}
return nil, fmt.Errorf("未找到更新包:%s", name)
}
func fetchReleaseSHA256(assets []githubAsset) (map[string]string, error) {
var checksumURL string
for _, asset := range assets {
if strings.EqualFold(asset.Name, updateChecksumAsset) || strings.Contains(strings.ToLower(asset.Name), "sha256sums") {
checksumURL = asset.BrowserDownloadURL
break
}
}
if checksumURL == "" {
return nil, errors.New("Release 未提供 SHA256SUMS")
}
client := newHTTPClientWithGlobalProxy(15 * time.Second)
req, err := http.NewRequest(http.MethodGet, checksumURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "GoNavi-Updater")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("下载 SHA256SUMS 失败HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return parseSHA256Sums(string(body)), nil
}
func parseSHA256Sums(content string) map[string]string {
result := make(map[string]string)
lines := strings.Split(content, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
hash := fields[0]
name := fields[len(fields)-1]
name = strings.TrimPrefix(name, "*")
name = strings.TrimPrefix(name, "./")
result[name] = hash
}
return result
}
type downloadProgressWriter struct {
total int64
written int64
lastEmit time.Time
emitEvery time.Duration
onProgress func(downloaded, total int64)
}
func (w *downloadProgressWriter) Write(p []byte) (int, error) {
n := len(p)
if n == 0 {
return 0, nil
}
w.written += int64(n)
if w.onProgress == nil {
return n, nil
}
now := time.Now()
if w.lastEmit.IsZero() || now.Sub(w.lastEmit) >= w.emitEvery || (w.total > 0 && w.written >= w.total) {
w.lastEmit = now
w.onProgress(w.written, w.total)
}
return n, nil
}
func downloadFileWithHash(url, filePath string, onProgress func(downloaded, total int64)) (string, error) {
client := newHTTPClientWithGlobalProxy(10 * time.Minute)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", "GoNavi-Updater")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("下载更新包失败HTTP %d", resp.StatusCode)
}
// Windows 上旧文件可能被杀毒软件/索引服务占用,先尝试删除并重试
_ = os.Remove(filePath)
var out *os.File
for retry := 0; retry < 5; retry++ {
out, err = os.Create(filePath)
if err == nil {
break
}
if retry < 4 {
time.Sleep(time.Duration(retry+1) * 500 * time.Millisecond)
}
}
if err != nil {
return "", fmt.Errorf("更新下载失败,文件被占用:%w", err)
}
hasher := sha256.New()
total := resp.ContentLength
progressWriter := &downloadProgressWriter{
total: total,
emitEvery: 120 * time.Millisecond,
onProgress: onProgress,
}
writers := []io.Writer{out, hasher, progressWriter}
if onProgress != nil {
onProgress(0, total)
}
if _, err := io.Copy(io.MultiWriter(writers...), resp.Body); err != nil {
out.Close()
return "", err
}
if onProgress != nil {
onProgress(progressWriter.written, total)
}
// 显式 Sync + Close确保数据落盘且文件句柄释放
if err := out.Sync(); err != nil {
out.Close()
return "", err
}
if err := out.Close(); err != nil {
return "", err
}
return hex.EncodeToString(hasher.Sum(nil)), nil
}
func buildUpdateDownloadResult(info UpdateInfo, staged *stagedUpdate) updateDownloadResult {
result := updateDownloadResult{
Info: info,
Platform: stdRuntime.GOOS,
InstallTarget: resolveUpdateInstallTarget(),
AutoRelaunch: true,
}
if staged != nil {
result.DownloadPath = staged.FilePath
result.InstallLogPath = staged.InstallLogPath
}
return result
}
func buildUpdateInstallLogPath(baseDir string) string {
platform := stdRuntime.GOOS
if platform == "darwin" {
platform = "macos"
}
logDir := strings.TrimSpace(baseDir)
if logDir == "" {
logDir = os.TempDir()
}
return filepath.Join(logDir, fmt.Sprintf("gonavi-update-%s-%d.log", platform, time.Now().UnixNano()))
}
func sanitizeVersionForPath(version string) string {
trimmed := strings.TrimSpace(version)
if trimmed == "" {
return "latest"
}
var builder strings.Builder
lastDash := false
for _, r := range trimmed {
isAllowed := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-'
if isAllowed {
builder.WriteRune(r)
lastDash = false
continue
}
if !lastDash {
builder.WriteRune('-')
lastDash = true
}
}
result := strings.Trim(builder.String(), "-")
if result == "" {
return "latest"
}
return result
}
func resolveLegacyUpdateWorkspaceDir() string {
return filepath.Join(os.TempDir(), "gonavi-updates")
}
func resolveUpdateWorkspaceDir(version string) string {
// 默认使用系统临时目录作为更新工作区,避免目录权限与锁冲突。
// macOS 用户要求更新包默认保存在桌面Desktop/GoNavi-<version>/。
if stdRuntime.GOOS == "darwin" {
homeDir, err := os.UserHomeDir()
if err == nil && strings.TrimSpace(homeDir) != "" {
desktopDir := filepath.Join(homeDir, "Desktop")
if st, statErr := os.Stat(desktopDir); statErr == nil && st.IsDir() {
return filepath.Join(desktopDir, fmt.Sprintf("GoNavi-%s", sanitizeVersionForPath(version)))
}
}
}
return resolveLegacyUpdateWorkspaceDir()
}
func resolveUpdateAssetPath(workspaceDir string, stagedDir string, assetName string) string {
name := strings.TrimSpace(assetName)
if stdRuntime.GOOS == "darwin" {
return filepath.Join(workspaceDir, name)
}
return filepath.Join(stagedDir, name)
}
func isExistingDownloadedAsset(filePath string, expectedSize int64) bool {
path := strings.TrimSpace(filePath)
if path == "" {
return false
}
stat, err := os.Stat(path)
if err != nil || stat.IsDir() {
return false
}
if expectedSize > 0 && stat.Size() != expectedSize {
return false
}
return true
}
func resolveReusableStagedUpdate(info UpdateInfo, current *stagedUpdate) *stagedUpdate {
version := strings.TrimSpace(info.LatestVersion)
assetName := strings.TrimSpace(info.AssetName)
if version == "" || assetName == "" {
return nil
}
if current != nil && strings.TrimSpace(current.Version) == version {
currentPath := strings.TrimSpace(current.FilePath)
if isExistingDownloadedAsset(currentPath, info.AssetSize) {
if strings.TrimSpace(current.InstallLogPath) == "" {
current.InstallLogPath = buildUpdateInstallLogPath(filepath.Dir(currentPath))
}
return current
}
}
type pathCandidate struct {
workspaceDir string
stagedDir string
assetPath string
}
stagedDirName := fmt.Sprintf(".gonavi-update-%s-%s", stdRuntime.GOOS, version)
workspaceCandidates := []string{
resolveUpdateWorkspaceDir(version),
resolveLegacyUpdateWorkspaceDir(),
}
seenWorkspace := make(map[string]struct{}, len(workspaceCandidates))
candidates := make([]pathCandidate, 0, 4)
for _, workspaceDir := range workspaceCandidates {
workspaceDir = strings.TrimSpace(workspaceDir)
if workspaceDir == "" {
continue
}
if _, exists := seenWorkspace[workspaceDir]; exists {
continue
}
seenWorkspace[workspaceDir] = struct{}{}
stagedDir := filepath.Join(workspaceDir, stagedDirName)
assetPath := resolveUpdateAssetPath(workspaceDir, stagedDir, assetName)
candidates = append(candidates, pathCandidate{
workspaceDir: workspaceDir,
stagedDir: stagedDir,
assetPath: assetPath,
})
legacyAssetPath := filepath.Join(stagedDir, assetName)
if legacyAssetPath != assetPath {
candidates = append(candidates, pathCandidate{
workspaceDir: workspaceDir,
stagedDir: stagedDir,
assetPath: legacyAssetPath,
})
}
}
for _, candidate := range candidates {
if !isExistingDownloadedAsset(candidate.assetPath, info.AssetSize) {
continue
}
return &stagedUpdate{
Version: version,
AssetName: assetName,
FilePath: candidate.assetPath,
StagedDir: candidate.stagedDir,
InstallLogPath: buildUpdateInstallLogPath(candidate.workspaceDir),
}
}
return nil
}
func resolveUpdateInstallTarget() string {
exePath, err := os.Executable()
if err != nil {
return ""
}
exePath, _ = filepath.EvalSymlinks(exePath)
if stdRuntime.GOOS == "darwin" {
return resolveMacUpdateTarget(exePath)
}
return exePath
}
func (a *App) emitUpdateDownloadProgress(status string, downloaded, total int64, message string) {
if a.ctx == nil {
return
}
payload := updateDownloadProgressPayload{
Status: status,
Percent: 0,
Downloaded: downloaded,
Total: total,
Message: strings.TrimSpace(message),
}
if total > 0 {
payload.Percent = math.Min(100, (float64(downloaded)/float64(total))*100)
}
if status == "done" && payload.Percent < 100 {
payload.Percent = 100
}
wailsRuntime.EventsEmit(a.ctx, updateDownloadProgressEvent, payload)
}
func launchUpdateScript(staged *stagedUpdate) error {
exePath, err := os.Executable()
if err != nil {
return err
}
exePath, _ = filepath.EvalSymlinks(exePath)
pid := os.Getpid()
switch stdRuntime.GOOS {
case "windows":
return launchWindowsUpdate(staged, exePath, pid)
case "darwin":
return launchMacUpdate(staged, exePath, pid)
case "linux":
return launchLinuxUpdate(staged, exePath, pid)
default:
return fmt.Errorf("当前平台暂不支持更新安装:%s", stdRuntime.GOOS)
}
}
func launchWindowsUpdate(staged *stagedUpdate, targetExe string, pid int) error {
scriptPath := filepath.Join(staged.StagedDir, "update.cmd")
logPath := strings.TrimSpace(staged.InstallLogPath)
if logPath == "" {
logPath = buildUpdateInstallLogPath(filepath.Dir(staged.FilePath))
staged.InstallLogPath = logPath
}
content := buildWindowsScript(staged.FilePath, targetExe, staged.StagedDir, logPath, pid)
if err := os.WriteFile(scriptPath, []byte(content), 0o644); err != nil {
return err
}
logger.Infof("启动 Windows 更新脚本target=%s script=%s log=%s", targetExe, scriptPath, logPath)
cmd := buildWindowsLaunchCommand(scriptPath)
return cmd.Start()
}
func launchMacUpdate(staged *stagedUpdate, targetExe string, pid int) error {
targetApp := resolveMacUpdateTarget(targetExe)
mountDir := filepath.Join(staged.StagedDir, "mnt")
if err := os.MkdirAll(mountDir, 0o755); err != nil {
return err
}
logPath := strings.TrimSpace(staged.InstallLogPath)
if logPath == "" {
logPath = buildUpdateInstallLogPath(filepath.Dir(staged.FilePath))
staged.InstallLogPath = logPath
}
scriptPath := filepath.Join(staged.StagedDir, "update.sh")
content := buildMacScript(staged.FilePath, targetApp, staged.StagedDir, mountDir, logPath, pid)
if err := os.WriteFile(scriptPath, []byte(content), 0o755); err != nil {
return err
}
cmd := exec.Command("/bin/bash", scriptPath)
logger.Infof("启动 macOS 更新脚本target=%s script=%s log=%s", targetApp, scriptPath, logPath)
return cmd.Start()
}
func launchLinuxUpdate(staged *stagedUpdate, targetExe string, pid int) error {
scriptPath := filepath.Join(staged.StagedDir, "update.sh")
content := buildLinuxScript(staged.FilePath, targetExe, staged.StagedDir, pid)
if err := os.WriteFile(scriptPath, []byte(content), 0o755); err != nil {
return err
}
cmd := exec.Command("/bin/sh", scriptPath)
return cmd.Start()
}
func buildWindowsScript(source, target, stagedDir, logPath string, pid int) string {
script := `@echo off
setlocal EnableExtensions EnableDelayedExpansion
set "SOURCE=__GONAVI_UPDATE_SOURCE__"
set "TARGET=__GONAVI_UPDATE_TARGET__"
set "TARGET_OLD=%TARGET%.old"
set "STAGED=__GONAVI_UPDATE_STAGED__"
set "LOG_FILE=__GONAVI_UPDATE_LOG__"
set PID=__GONAVI_UPDATE_PID__
call :log updater started
if not exist "%SOURCE%" (
call :log source file not found: %SOURCE%
exit /b 1
)
for %%I in ("%TARGET%") do set "TARGET_NAME=%%~nxI"
for %%I in ("%SOURCE%") do set "SOURCE_EXT=%%~xI"
set "SOURCE_EXE="
if /I "%SOURCE_EXT%"==".zip" (
set "EXTRACT_DIR=%STAGED%\_extract"
if exist "%EXTRACT_DIR%" (
rmdir /S /Q "%EXTRACT_DIR%" >> "%LOG_FILE%" 2>&1
)
mkdir "%EXTRACT_DIR%" >> "%LOG_FILE%" 2>&1
powershell -NoProfile -ExecutionPolicy Bypass -Command "$src=$env:SOURCE; $dst=$env:EXTRACT_DIR; Expand-Archive -LiteralPath $src -DestinationPath $dst -Force" >> "%LOG_FILE%" 2>&1
if !ERRORLEVEL! NEQ 0 (
call :log expand zip failed: %SOURCE%
exit /b 1
)
if exist "%EXTRACT_DIR%\%TARGET_NAME%" (
set "SOURCE_EXE=%EXTRACT_DIR%\%TARGET_NAME%"
) else (
for /R "%EXTRACT_DIR%" %%F in (*.exe) do (
if not defined SOURCE_EXE (
set "SOURCE_EXE=%%~fF"
)
)
)
if not defined SOURCE_EXE (
call :log no executable found in portable zip: %SOURCE%
exit /b 1
)
) else (
set "SOURCE_EXE=%SOURCE%"
)
:waitloop
tasklist /FI "PID eq %PID%" | find "%PID%" >nul
if %ERRORLEVEL%==0 (
timeout /t 1 /nobreak >nul
goto waitloop
)
call :log host process exited
rem -- Win10 needs extra time for kernel to release exe file handles --
timeout /t 3 /nobreak >nul
call :log cooldown finished, starting file replace
set /a RETRY=0
:move_retry
call :log attempt !RETRY!: trying rename-then-copy strategy
move /Y "%TARGET%" "%TARGET_OLD%" >> "%LOG_FILE%" 2>&1
if !ERRORLEVEL!==0 (
copy /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1
if !ERRORLEVEL!==0 (
del /F /Q "%TARGET_OLD%" >> "%LOG_FILE%" 2>&1
goto move_done
)
call :log copy after rename failed, restoring old file
move /Y "%TARGET_OLD%" "%TARGET%" >> "%LOG_FILE%" 2>&1
)
call :log rename strategy failed, trying direct move
move /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1
if %ERRORLEVEL%==0 goto move_done
copy /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1
if %ERRORLEVEL%==0 goto move_done
set /a RETRY+=1
if !RETRY! LSS 15 (
set /a WAIT=1
if !RETRY! GEQ 3 set /a WAIT=2
if !RETRY! GEQ 6 set /a WAIT=3
if !RETRY! GEQ 9 set /a WAIT=5
call :log waiting !WAIT! seconds before retry
timeout /t !WAIT! /nobreak >nul
goto move_retry
)
call :log replace failed after retries (portable mode, no elevation): check directory write permission or file lock
exit /b 1
:move_done
del /F /Q "%TARGET_OLD%" >> "%LOG_FILE%" 2>&1
start "" "%TARGET%" >> "%LOG_FILE%" 2>&1
if %ERRORLEVEL% NEQ 0 (
call :log cmd start failed, trying powershell Start-Process
powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '%TARGET%'" >> "%LOG_FILE%" 2>&1
if !ERRORLEVEL! NEQ 0 (
call :log relaunch failed
exit /b 1
)
)
rmdir /S /Q "%STAGED%" >> "%LOG_FILE%" 2>&1
call :log update finished
exit /b 0
:log
echo [%date% %time%] %*>>"%LOG_FILE%"
exit /b 0
`
return strings.NewReplacer(
"__GONAVI_UPDATE_SOURCE__", source,
"__GONAVI_UPDATE_TARGET__", target,
"__GONAVI_UPDATE_STAGED__", stagedDir,
"__GONAVI_UPDATE_LOG__", logPath,
"__GONAVI_UPDATE_PID__", strconv.Itoa(pid),
).Replace(strings.ReplaceAll(script, "\n", "\r\n"))
}
func buildWindowsLaunchCommand(scriptPath string) *exec.Cmd {
return exec.Command("cmd.exe", "/D", "/C", "call", scriptPath)
}
func buildMacScript(dmgPath, targetApp, stagedDir, mountDir, logPath string, pid int) string {
return fmt.Sprintf(`#!/bin/bash
set -euo pipefail
PID=%d
DMG="%s"
TARGET_APP="%s"
STAGED="%s"
MOUNT_DIR="%s"
LOG_FILE="%s"
TMP_APP="${TARGET_APP}.new"
BACKUP_APP="${TARGET_APP}.backup"
APP_BIN_NAME=$(basename "$TARGET_APP" .app)
APP_BIN_REL="Contents/MacOS/$APP_BIN_NAME"
log() {
echo "[$(date '+%%Y-%%m-%%d %%H:%%M:%%S')] $*" >> "$LOG_FILE"
}
run_admin_replace() {
/usr/bin/osascript <<'APPLESCRIPT' "$APP_SRC" "$TARGET_APP" "$TMP_APP" "$BACKUP_APP" "$APP_BIN_REL" "$LOG_FILE"
on run argv
set srcPath to item 1 of argv
set dstPath to item 2 of argv
set tmpPath to item 3 of argv
set bakPath to item 4 of argv
set binRel to item 5 of argv
set logPath to item 6 of argv
set cmd to "set -eu; " & ¬
"rm -rf " & quoted form of tmpPath & " " & quoted form of bakPath & "; " & ¬
"/usr/bin/ditto " & quoted form of srcPath & " " & quoted form of tmpPath & "; " & ¬
"if [ ! -x " & quoted form of (tmpPath & "/" & binRel) & " ]; then echo 'tmp app binary missing' >> " & quoted form of logPath & "; exit 1; fi; " & ¬
"xattr -rd com.apple.quarantine " & quoted form of tmpPath & " >> " & quoted form of logPath & " 2>&1 || true; " & ¬
"if [ -d " & quoted form of dstPath & " ]; then mv " & quoted form of dstPath & " " & quoted form of bakPath & "; fi; " & ¬
"mv " & quoted form of tmpPath & " " & quoted form of dstPath & "; " & ¬
"rm -rf " & quoted form of bakPath & "; " & ¬
"xattr -rd com.apple.quarantine " & quoted form of dstPath & " >> " & quoted form of logPath & " 2>&1 || true"
do shell script cmd with administrator privileges
end run
APPLESCRIPT
}
replace_app_direct() {
rm -rf "$TMP_APP" "$BACKUP_APP" >>"$LOG_FILE" 2>&1 || true
/usr/bin/ditto "$APP_SRC" "$TMP_APP" >>"$LOG_FILE" 2>&1
if [ ! -x "$TMP_APP/$APP_BIN_REL" ]; then
log "tmp app binary missing: $TMP_APP/$APP_BIN_REL"
return 1
fi
xattr -rd com.apple.quarantine "$TMP_APP" >>"$LOG_FILE" 2>&1 || true
if [ -d "$TARGET_APP" ]; then
mv "$TARGET_APP" "$BACKUP_APP" >>"$LOG_FILE" 2>&1
fi
if ! mv "$TMP_APP" "$TARGET_APP" >>"$LOG_FILE" 2>&1; then
log "move new app failed, trying rollback"
rm -rf "$TARGET_APP" >>"$LOG_FILE" 2>&1 || true
if [ -d "$BACKUP_APP" ]; then
mv "$BACKUP_APP" "$TARGET_APP" >>"$LOG_FILE" 2>&1 || true
fi
return 1
fi
rm -rf "$BACKUP_APP" >>"$LOG_FILE" 2>&1 || true
xattr -rd com.apple.quarantine "$TARGET_APP" >>"$LOG_FILE" 2>&1 || true
return 0
}
relaunch_app() {
if /usr/bin/open -n "$TARGET_APP" >>"$LOG_FILE" 2>&1; then
return 0
fi
log "open -n failed, trying binary launch"
"$TARGET_APP/$APP_BIN_REL" >>"$LOG_FILE" 2>&1 &
return 0
}
log "updater started"
while kill -0 $PID 2>/dev/null; do
sleep 1
done
log "host process exited"
hdiutil attach "$DMG" -nobrowse -quiet -mountpoint "$MOUNT_DIR" >>"$LOG_FILE" 2>&1
APP_SRC=$(ls "$MOUNT_DIR"/*.app 2>/dev/null | head -n 1 || true)
if [ -z "$APP_SRC" ]; then
log "no .app found inside dmg"
hdiutil detach "$MOUNT_DIR" -quiet >>"$LOG_FILE" 2>&1 || true
exit 1
fi
log "install target: $TARGET_APP"
if ! replace_app_direct; then
log "direct replace failed, trying admin replace"
run_admin_replace >>"$LOG_FILE" 2>&1
fi
if [ ! -x "$TARGET_APP/$APP_BIN_REL" ]; then
log "target app binary missing after replace: $TARGET_APP/$APP_BIN_REL"
hdiutil detach "$MOUNT_DIR" -quiet >>"$LOG_FILE" 2>&1 || true
exit 1
fi
hdiutil detach "$MOUNT_DIR" -quiet >>"$LOG_FILE" 2>&1 || true
rm -rf "$MOUNT_DIR" "$DMG" "$STAGED" >>"$LOG_FILE" 2>&1 || true
relaunch_app
log "relaunch requested"
`, pid, dmgPath, targetApp, stagedDir, mountDir, logPath)
}
func buildLinuxScript(tarPath, targetExe, stagedDir string, pid int) string {
return fmt.Sprintf(`#!/bin/bash
set -e
PID=%d
ARCHIVE="%s"
TARGET="%s"
STAGED="%s"
while kill -0 $PID 2>/dev/null; do
sleep 1
done
TMPDIR=$(mktemp -d)
tar -xzf "$ARCHIVE" -C "$TMPDIR"
NEWBIN="$TMPDIR/GoNavi"
if [ ! -f "$NEWBIN" ]; then
NEWBIN=$(find "$TMPDIR" -type f -name "GoNavi" | head -n 1)
fi
if [ -z "$NEWBIN" ] || [ ! -f "$NEWBIN" ]; then
exit 1
fi
cp -f "$NEWBIN" "$TARGET"
chmod +x "$TARGET"
rm -rf "$TMPDIR" "$ARCHIVE" "$STAGED"
"$TARGET" &
`, pid, tarPath, targetExe, stagedDir)
}
func detectMacAppPath(exePath string) string {
parts := strings.Split(exePath, string(filepath.Separator))
for i := len(parts) - 1; i >= 0; i-- {
if strings.HasSuffix(parts[i], ".app") {
appPath := filepath.Join(parts[:i+1]...)
// 确保返回绝对路径
if !filepath.IsAbs(appPath) {
appPath = string(filepath.Separator) + appPath
}
return appPath
}
}
return ""
}
func resolveMacUpdateTarget(exePath string) string {
targetApp := detectMacAppPath(exePath)
if targetApp == "" {
return "/Applications/GoNavi.app"
}
targetApp = filepath.Clean(targetApp)
// Gatekeeper App Translocation 路径不可用于稳定覆盖更新,统一回退到 /Applications。
if strings.Contains(targetApp, string(filepath.Separator)+"AppTranslocation"+string(filepath.Separator)) {
logger.Warnf("检测到 AppTranslocation 运行路径,更新目标回退至 /Applications/GoNavi.app%s", targetApp)
return "/Applications/GoNavi.app"
}
return targetApp
}
func normalizeVersion(version string) string {
version = strings.TrimSpace(version)
version = strings.TrimPrefix(version, "v")
return version
}
func compareVersion(current, latest string) int {
current = normalizeVersion(current)
latest = normalizeVersion(latest)
if current == "" {
return -1
}
if current == latest {
return 0
}
curParts := splitVersionParts(current)
latParts := splitVersionParts(latest)
max := len(curParts)
if len(latParts) > max {
max = len(latParts)
}
for i := 0; i < max; i++ {
cur := 0
lat := 0
if i < len(curParts) {
cur = curParts[i]
}
if i < len(latParts) {
lat = latParts[i]
}
if cur < lat {
return -1
}
if cur > lat {
return 1
}
}
return 0
}
func splitVersionParts(version string) []int {
parts := strings.Split(version, ".")
result := make([]int, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
result = append(result, 0)
continue
}
num := 0
for _, ch := range part {
if ch < '0' || ch > '9' {
break
}
num = num*10 + int(ch-'0')
}
result = append(result, num)
}
return result
}