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"` 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", 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) 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-/。 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 := exec.Command("cmd", "/C", "start", "", 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 "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 set /a RETRY=0 :move_retry 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 20 ( timeout /t 1 /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 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(script) } 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 }