mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-12 05:29:40 +08:00
- 架构优化:移除 CellEditModeContext,避免 Context 变化触发全表重渲染 - 事件委托:在容器级别处理鼠标事件,减少事件监听器数量从 O(n*m) 到 O(1) - DOM查询优化:使用 data-row-key/data-col-name 属性直接定位单元格 - RAF节流:拖拽选择使用 requestAnimationFrame 节流,保证 60fps 流畅度 - CSS类控制:批量编辑模式样式通过 CSS 类切换,而非内联 style
946 lines
26 KiB
Go
946 lines
26 KiB
Go
package app
|
||
|
||
import (
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"math"
|
||
"net/http"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
stdRuntime "runtime"
|
||
"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"`
|
||
}
|
||
|
||
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()}
|
||
}
|
||
|
||
a.updateMu.Lock()
|
||
a.updateState.lastCheck = &info
|
||
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 := a.updateState.staged
|
||
if staged != nil && staged.Version == info.LatestVersion {
|
||
a.updateMu.Unlock()
|
||
return connection.QueryResult{Success: true, Message: "更新包已下载完成", Data: buildUpdateDownloadResult(*info, staged)}
|
||
}
|
||
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())
|
||
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))
|
||
// 清理可能残留的旧目录(上次下载失败后未清理)
|
||
_ = os.RemoveAll(stagedDir)
|
||
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}
|
||
}
|
||
|
||
// 下载到 staging 目录,避免覆盖正在运行的可执行文件
|
||
assetPath := filepath.Join(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),
|
||
}
|
||
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("无法解析最新版本号")
|
||
}
|
||
|
||
assetName, err := expectedAssetName(stdRuntime.GOOS, stdRuntime.GOARCH)
|
||
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 := &http.Client{Timeout: 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 string) (string, error) {
|
||
switch goos {
|
||
case "windows":
|
||
if goarch == "amd64" {
|
||
return "GoNavi-windows-amd64.exe", nil
|
||
}
|
||
if goarch == "arm64" {
|
||
return "GoNavi-windows-arm64.exe", nil
|
||
}
|
||
case "darwin":
|
||
if goarch == "amd64" {
|
||
return "GoNavi-mac-amd64.dmg", nil
|
||
}
|
||
if goarch == "arm64" {
|
||
return "GoNavi-mac-arm64.dmg", nil
|
||
}
|
||
case "linux":
|
||
if goarch == "amd64" {
|
||
return "GoNavi-linux-amd64.tar.gz", 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 := &http.Client{Timeout: 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 := &http.Client{Timeout: 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)
|
||
}
|
||
|
||
out, err := os.Create(filePath)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer out.Close()
|
||
|
||
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 {
|
||
return "", err
|
||
}
|
||
if onProgress != nil {
|
||
onProgress(progressWriter.written, total)
|
||
}
|
||
|
||
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 resolveUpdateWorkspaceDir() string {
|
||
exePath, err := os.Executable()
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
exePath, _ = filepath.EvalSymlinks(exePath)
|
||
if stdRuntime.GOOS == "darwin" {
|
||
appPath := detectMacAppPath(exePath)
|
||
if appPath != "" {
|
||
return filepath.Dir(appPath)
|
||
}
|
||
}
|
||
return filepath.Dir(exePath)
|
||
}
|
||
|
||
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 {
|
||
return fmt.Sprintf(`@echo off
|
||
setlocal EnableExtensions EnableDelayedExpansion
|
||
set "SOURCE=%s"
|
||
set "TARGET=%s"
|
||
set "STAGED=%s"
|
||
set "LOG_FILE=%s"
|
||
set PID=%d
|
||
|
||
call :log updater started
|
||
if not exist "%%SOURCE%%" (
|
||
call :log source file not found: %%SOURCE%%
|
||
exit /b 1
|
||
)
|
||
|
||
: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%%" "%%TARGET%%" >> "%%LOG_FILE%%" 2>&1
|
||
if %%ERRORLEVEL%%==0 goto move_done
|
||
|
||
copy /Y "%%SOURCE%%" "%%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
|
||
`, source, target, stagedDir, logPath, pid)
|
||
}
|
||
|
||
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
|
||
}
|