mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-13 00:13:33 +08:00
- 客户端优先下载 releases/latest/download/latest.json,再回退 REST API - 成功结果写入磁盘缓存,限流/断网时可 stale 回退 - 静默检查增加节流;限流文案改为用户无需配置 Token - CI 与 build-release 发版时生成 latest.json 清单 - 补充静态清单/缓存/节流单测与生成脚本测试
2010 lines
61 KiB
Go
2010 lines
61 KiB
Go
package app
|
||
|
||
import (
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"math"
|
||
"net"
|
||
"net/http"
|
||
urlpkg "net/url"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
stdRuntime "runtime"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"GoNavi-Wails/internal/connection"
|
||
"GoNavi-Wails/internal/logger"
|
||
"GoNavi-Wails/internal/uievents"
|
||
|
||
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||
)
|
||
|
||
const (
|
||
updateRepo = "Syngnat/GoNavi"
|
||
updateLatestAPIURL = "https://api.github.com/repos/" + updateRepo + "/releases/latest"
|
||
updateDevAPIURL = "https://api.github.com/repos/" + updateRepo + "/releases/tags/" + updateDevReleaseTag
|
||
updateChecksumAsset = "SHA256SUMS"
|
||
updateDownloadProgressEvent = "update:download-progress"
|
||
updateNetworkRetryDelay = 250 * time.Millisecond
|
||
updateReleaseCacheTTL = 10 * time.Minute
|
||
updateGitHubAPIVersion = "2022-11-28"
|
||
updateHTTPBodySnippetLimit = 240
|
||
)
|
||
|
||
type cachedGitHubRelease struct {
|
||
release *githubRelease
|
||
fetchedAt time.Time
|
||
}
|
||
|
||
var updateReleaseCache sync.Map // apiURL -> cachedGitHubRelease
|
||
|
||
var (
|
||
updateFetchLatestRelease = fetchLatestRelease
|
||
updateFetchDevRelease = fetchDevRelease
|
||
updateFetchReleaseSHA256 = fetchReleaseSHA256
|
||
updateLogCheckError = func(err error) { logger.Error(err, "检查更新失败") }
|
||
updateResolveInstallTarget = resolveUpdateInstallTarget
|
||
updateLaunchInstallScript = launchUpdateScript
|
||
)
|
||
|
||
type updateState struct {
|
||
lastCheck *UpdateInfo
|
||
downloading bool
|
||
staged *stagedUpdate
|
||
}
|
||
|
||
type UpdateInfo struct {
|
||
HasUpdate bool `json:"hasUpdate"`
|
||
Channel string `json:"channel"`
|
||
CurrentVersion string `json:"currentVersion"`
|
||
LatestVersion string `json:"latestVersion"`
|
||
ReleaseName string `json:"releaseName"`
|
||
ReleasePublishedAt string `json:"releasePublishedAt,omitempty"`
|
||
ReleaseNotesURL string `json:"releaseNotesUrl"`
|
||
AssetName string `json:"assetName"`
|
||
AssetURL string `json:"assetUrl"`
|
||
AssetAPIURL string `json:"assetApiUrl,omitempty"`
|
||
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 {
|
||
Channel updateChannel
|
||
Version string
|
||
AssetName string
|
||
FilePath string
|
||
StagedDir string
|
||
InstallLogPath string
|
||
}
|
||
|
||
type updatePathCandidate struct {
|
||
workspaceDir string
|
||
stagedDir string
|
||
assetPath string
|
||
}
|
||
|
||
type githubRelease struct {
|
||
TagName string `json:"tag_name"`
|
||
Name string `json:"name"`
|
||
HTMLURL string `json:"html_url"`
|
||
PublishedAt string `json:"published_at"`
|
||
Prerelease bool `json:"prerelease"`
|
||
Assets []githubAsset `json:"assets"`
|
||
}
|
||
|
||
type githubAsset struct {
|
||
Name string `json:"name"`
|
||
BrowserDownloadURL string `json:"browser_download_url"`
|
||
URL string `json:"url"`
|
||
Digest string `json:"digest"`
|
||
Size int64 `json:"size"`
|
||
}
|
||
|
||
type localizedUpdateError struct {
|
||
key string
|
||
params map[string]any
|
||
}
|
||
|
||
func (e localizedUpdateError) Error() string {
|
||
return e.key
|
||
}
|
||
|
||
func (a *App) localizedUpdateError(err error) string {
|
||
if err == nil {
|
||
return ""
|
||
}
|
||
var localized localizedUpdateError
|
||
if errors.As(err, &localized) {
|
||
return a.appText(localized.key, localized.params)
|
||
}
|
||
return err.Error()
|
||
}
|
||
|
||
func (a *App) CheckForUpdates() connection.QueryResult {
|
||
// 用户手动检查:强制走网络(静态清单优先,API 回退)
|
||
return a.checkForUpdates(true, true)
|
||
}
|
||
|
||
func (a *App) CheckForUpdatesSilently() connection.QueryResult {
|
||
// 静默检查:允许节流,优先磁盘/短时缓存,避免启动刷爆网络
|
||
return a.checkForUpdates(false, false)
|
||
}
|
||
|
||
func (a *App) checkForUpdates(logFailure bool, forceNetwork bool) connection.QueryResult {
|
||
a.ensurePersistedGlobalProxyRuntime()
|
||
channel := a.currentUpdateChannel()
|
||
info, err := fetchLatestUpdateInfoWithOptions(channel, forceNetwork)
|
||
if err != nil {
|
||
if logFailure {
|
||
updateLogCheckError(err)
|
||
}
|
||
return connection.QueryResult{Success: false, Message: a.localizedUpdateError(err)}
|
||
}
|
||
|
||
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.Channel != updateChannel(info.Channel)) {
|
||
currentStaged = nil
|
||
}
|
||
} else {
|
||
currentStaged = nil
|
||
}
|
||
|
||
a.updateMu.Lock()
|
||
a.updateState.lastCheck = &info
|
||
a.updateState.staged = currentStaged
|
||
a.updateMu.Unlock()
|
||
|
||
msg := a.appText("app.update.backend.message.latest", nil)
|
||
if info.HasUpdate {
|
||
msg = a.appText("app.update.backend.message.update_found", map[string]any{"version": 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: a.appText("app.update.backend.message.download_in_progress", nil)}
|
||
}
|
||
info := a.updateState.lastCheck
|
||
if info == nil {
|
||
a.updateMu.Unlock()
|
||
return connection.QueryResult{Success: false, Message: a.appText("app.update.backend.message.check_first", nil)}
|
||
}
|
||
if !info.HasUpdate {
|
||
a.updateMu.Unlock()
|
||
return connection.QueryResult{Success: false, Message: a.appText("app.update.backend.message.latest", nil)}
|
||
}
|
||
if info.AssetURL == "" || info.AssetName == "" {
|
||
a.updateMu.Unlock()
|
||
return connection.QueryResult{Success: false, Message: a.appText("app.update.backend.message.no_update_package", nil)}
|
||
}
|
||
staged := resolveReusableStagedUpdate(*info, a.updateState.staged)
|
||
if staged != nil {
|
||
a.updateState.staged = staged
|
||
a.updateMu.Unlock()
|
||
return connection.QueryResult{Success: true, Message: a.appText("app.update.backend.message.package_already_downloaded", nil), 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: a.appText("app.update.backend.message.no_downloaded_package", nil)}
|
||
}
|
||
|
||
if stdRuntime.GOOS == "windows" {
|
||
if err := ensureWindowsUpdateTargetWritable(updateResolveInstallTarget()); err != nil {
|
||
return connection.QueryResult{
|
||
Success: false,
|
||
Message: a.appText("app.update.backend.message.install_launch_failed", map[string]any{
|
||
"detail": a.localizedUpdateError(err),
|
||
}),
|
||
}
|
||
}
|
||
}
|
||
|
||
if err := updateLaunchInstallScript(staged); err != nil {
|
||
logger.Error(err, "启动更新脚本失败")
|
||
detail := a.localizedUpdateError(err)
|
||
msg := a.appText("app.update.backend.message.install_launch_failed", map[string]any{"detail": detail})
|
||
if staged.InstallLogPath != "" {
|
||
msg = a.appText("app.update.backend.message.install_launch_failed_with_log", map[string]any{
|
||
"detail": detail,
|
||
"path": 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 := a.appText("app.update.backend.message.install_started", nil)
|
||
if staged.InstallLogPath != "" {
|
||
msg = a.appText("app.update.backend.message.install_started_with_log", map[string]any{"path": 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: a.appText("app.update.backend.message.no_downloaded_package", nil)}
|
||
}
|
||
assetPath := strings.TrimSpace(staged.FilePath)
|
||
if assetPath == "" {
|
||
return connection.QueryResult{Success: false, Message: a.appText("app.update.backend.message.package_path_empty", nil)}
|
||
}
|
||
dirPath := strings.TrimSpace(filepath.Dir(assetPath))
|
||
if dirPath == "" || dirPath == "." {
|
||
return connection.QueryResult{Success: false, Message: a.appText("app.update.backend.message.package_directory_unresolved", nil)}
|
||
}
|
||
if stat, err := os.Stat(dirPath); err != nil || !stat.IsDir() {
|
||
return connection.QueryResult{Success: false, Message: a.appText("app.update.backend.message.package_directory_unavailable", nil)}
|
||
}
|
||
|
||
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: a.appText("app.update.backend.message.open_directory_unsupported", map[string]any{"platform": stdRuntime.GOOS})}
|
||
}
|
||
if err := cmd.Start(); err != nil {
|
||
logger.Error(err, "打开更新目录失败")
|
||
return connection.QueryResult{Success: false, Message: a.appText("app.update.backend.message.open_directory_failed", map[string]any{"detail": err.Error()})}
|
||
}
|
||
return connection.QueryResult{
|
||
Success: true,
|
||
Message: a.appText("app.update.backend.message.opened_install_directory", map[string]any{"path": dirPath}),
|
||
Data: map[string]any{
|
||
"path": dirPath,
|
||
},
|
||
}
|
||
}
|
||
|
||
func (a *App) downloadAndStageUpdate(info UpdateInfo) connection.QueryResult {
|
||
workspaceDir := strings.TrimSpace(resolveUpdateWorkspaceDir(info.LatestVersion))
|
||
if workspaceDir == "" {
|
||
message := a.appText("app.update.backend.message.app_directory_unresolved_download", nil)
|
||
a.emitUpdateDownloadProgress("error", 0, info.AssetSize, message)
|
||
return connection.QueryResult{Success: false, Message: message}
|
||
}
|
||
if err := os.MkdirAll(workspaceDir, 0o755); err != nil {
|
||
errMsg := a.appText("app.update.backend.message.app_directory_unavailable", map[string]any{"path": workspaceDir})
|
||
a.emitUpdateDownloadProgress("error", 0, info.AssetSize, errMsg)
|
||
return connection.QueryResult{Success: false, Message: errMsg}
|
||
}
|
||
|
||
// 使用版本号命名的工作目录,便于识别和调试
|
||
stagedDir := resolveUpdateStagedDir(workspaceDir, info.Channel, info.LatestVersion)
|
||
stageBaseDir := filepath.Dir(stagedDir)
|
||
// 清理可能残留的旧目录(上次下载失败后未清理)
|
||
// 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(stageBaseDir, fmt.Sprintf("%s-%d", buildUpdateStageDirName(info.Channel, info.LatestVersion), time.Now().UnixNano()))
|
||
}
|
||
}
|
||
if err := os.MkdirAll(stagedDir, 0o755); err != nil {
|
||
errMsg := a.appText("app.update.backend.message.create_workspace_failed", map[string]any{"path": stagedDir})
|
||
a.emitUpdateDownloadProgress("error", 0, info.AssetSize, errMsg)
|
||
return connection.QueryResult{Success: false, Message: errMsg}
|
||
}
|
||
|
||
// 安装包本体放在工作区根级,staging 目录只保留更新脚本和临时展开物。
|
||
assetPath := resolveUpdateAssetPath(workspaceDir, stagedDir, info.AssetName)
|
||
progressCB := func(downloaded, total int64) {
|
||
reportTotal := total
|
||
if reportTotal <= 0 {
|
||
reportTotal = info.AssetSize
|
||
}
|
||
a.emitUpdateDownloadProgress("downloading", downloaded, reportTotal, "")
|
||
}
|
||
actualHash, err := downloadFileWithHash(info.AssetURL, assetPath, progressCB)
|
||
if err != nil && strings.TrimSpace(info.AssetAPIURL) != "" && !strings.EqualFold(strings.TrimSpace(info.AssetAPIURL), strings.TrimSpace(info.AssetURL)) {
|
||
logger.Warnf("更新包主下载地址失败,尝试 assets API 回退:err=%v", err)
|
||
_ = os.Remove(assetPath)
|
||
actualHash, err = downloadFileWithHash(info.AssetAPIURL, assetPath, progressCB)
|
||
}
|
||
if err != nil {
|
||
_ = os.Remove(assetPath)
|
||
_ = os.RemoveAll(stagedDir)
|
||
message := a.localizedUpdateError(err)
|
||
a.emitUpdateDownloadProgress("error", 0, info.AssetSize, message)
|
||
return connection.QueryResult{Success: false, Message: message}
|
||
}
|
||
|
||
if info.SHA256 == "" {
|
||
_ = os.Remove(assetPath)
|
||
_ = os.RemoveAll(stagedDir)
|
||
message := a.appText("app.update.backend.message.checksum_missing", nil)
|
||
a.emitUpdateDownloadProgress("error", 0, info.AssetSize, message)
|
||
return connection.QueryResult{Success: false, Message: message}
|
||
}
|
||
if !strings.EqualFold(info.SHA256, actualHash) {
|
||
_ = os.Remove(assetPath)
|
||
_ = os.RemoveAll(stagedDir)
|
||
message := a.appText("app.update.backend.message.checksum_failed", nil)
|
||
a.emitUpdateDownloadProgress("error", 0, info.AssetSize, message)
|
||
return connection.QueryResult{Success: false, Message: message}
|
||
}
|
||
|
||
staged := &stagedUpdate{
|
||
Channel: updateChannel(info.Channel),
|
||
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: a.appText("app.update.backend.message.package_downloaded", nil), Data: buildUpdateDownloadResult(info, staged)}
|
||
}
|
||
|
||
func fetchLatestUpdateInfo(channel updateChannel) (UpdateInfo, error) {
|
||
return fetchLatestUpdateInfoWithOptions(channel, true)
|
||
}
|
||
|
||
func fetchLatestUpdateInfoWithOptions(channel updateChannel, forceNetwork bool) (UpdateInfo, error) {
|
||
if channel != updateChannelDev {
|
||
channel = updateChannelLatest
|
||
}
|
||
// 优先静态 latest.json(不占 api.github.com 配额)→ GitHub API → 磁盘缓存
|
||
release, err := fetchReleaseForChannelPreferringStatic(channel, forceNetwork)
|
||
if err != nil {
|
||
return UpdateInfo{}, err
|
||
}
|
||
|
||
currentVersion := getCurrentVersion()
|
||
latestVersion := resolveReleaseVersion(channel, release)
|
||
if latestVersion == "" {
|
||
return UpdateInfo{}, localizedUpdateError{key: "app.update.backend.error.latest_version_unparseable"}
|
||
}
|
||
|
||
hasUpdate := false
|
||
if channel == updateChannelDev {
|
||
hasUpdate = normalizeVersion(currentVersion) != latestVersion
|
||
} else {
|
||
hasUpdate = compareVersion(currentVersion, latestVersion) < 0
|
||
}
|
||
if !hasUpdate {
|
||
return UpdateInfo{
|
||
HasUpdate: false,
|
||
Channel: string(channel),
|
||
CurrentVersion: currentVersion,
|
||
LatestVersion: latestVersion,
|
||
ReleaseName: release.Name,
|
||
ReleasePublishedAt: strings.TrimSpace(release.PublishedAt),
|
||
ReleaseNotesURL: release.HTMLURL,
|
||
}, nil
|
||
}
|
||
|
||
assetVersion := strings.TrimSpace(release.TagName)
|
||
if assetVersion == "" || strings.EqualFold(normalizeVersion(assetVersion), updateDevReleaseTag) {
|
||
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
|
||
}
|
||
|
||
sha256Value := normalizeGitHubAssetSHA256(asset.Digest)
|
||
if sha256Value == "" {
|
||
hashMap, err := updateFetchReleaseSHA256(release.Assets)
|
||
if err != nil {
|
||
return UpdateInfo{}, err
|
||
}
|
||
sha256Value = strings.TrimSpace(hashMap[assetName])
|
||
}
|
||
if sha256Value == "" {
|
||
return UpdateInfo{}, localizedUpdateError{key: "app.update.backend.error.sha256_missing_current_package"}
|
||
}
|
||
return UpdateInfo{
|
||
HasUpdate: hasUpdate,
|
||
Channel: string(channel),
|
||
CurrentVersion: currentVersion,
|
||
LatestVersion: latestVersion,
|
||
ReleaseName: release.Name,
|
||
ReleasePublishedAt: strings.TrimSpace(release.PublishedAt),
|
||
ReleaseNotesURL: release.HTMLURL,
|
||
AssetName: asset.Name,
|
||
AssetURL: firstNonEmptyString(asset.BrowserDownloadURL, asset.URL),
|
||
AssetAPIURL: strings.TrimSpace(asset.URL),
|
||
AssetSize: asset.Size,
|
||
SHA256: sha256Value,
|
||
}, nil
|
||
}
|
||
|
||
func fetchReleaseForChannel(channel updateChannel) (*githubRelease, error) {
|
||
if channel == updateChannelDev {
|
||
return updateFetchDevRelease()
|
||
}
|
||
return updateFetchLatestRelease()
|
||
}
|
||
|
||
func swapUpdateFetchLatestRelease(next func() (*githubRelease, error)) func() {
|
||
original := updateFetchLatestRelease
|
||
updateFetchLatestRelease = next
|
||
return func() {
|
||
updateFetchLatestRelease = original
|
||
}
|
||
}
|
||
|
||
func swapUpdateFetchDevRelease(next func() (*githubRelease, error)) func() {
|
||
original := updateFetchDevRelease
|
||
updateFetchDevRelease = next
|
||
return func() {
|
||
updateFetchDevRelease = original
|
||
}
|
||
}
|
||
|
||
func swapUpdateFetchReleaseSHA256(next func([]githubAsset) (map[string]string, error)) func() {
|
||
original := updateFetchReleaseSHA256
|
||
updateFetchReleaseSHA256 = next
|
||
return func() {
|
||
updateFetchReleaseSHA256 = original
|
||
}
|
||
}
|
||
|
||
func swapUpdateCheckErrorLogger(next func(error)) func() {
|
||
original := updateLogCheckError
|
||
updateLogCheckError = next
|
||
return func() {
|
||
updateLogCheckError = original
|
||
}
|
||
}
|
||
|
||
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) {
|
||
return fetchReleaseByURL(updateLatestAPIURL)
|
||
}
|
||
|
||
func fetchDevRelease() (*githubRelease, error) {
|
||
return fetchReleaseByURL(updateDevAPIURL)
|
||
}
|
||
|
||
func fetchReleaseByURL(apiURL string) (*githubRelease, error) {
|
||
apiURL = strings.TrimSpace(apiURL)
|
||
if apiURL == "" {
|
||
return nil, localizedUpdateError{key: "app.update.backend.error.latest_version_unparseable"}
|
||
}
|
||
|
||
client := newHTTPClientWithGlobalProxy(15 * time.Second)
|
||
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
applyGitHubAPIRequestHeaders(req)
|
||
|
||
resp, err := doUpdateRequest(client, req)
|
||
if err != nil {
|
||
if cached := loadCachedGitHubRelease(apiURL); cached != nil {
|
||
logger.Warnf("检查更新网络失败,回退缓存发布信息:url=%s err=%v", apiURL, err)
|
||
return cached, nil
|
||
}
|
||
return nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||
if readErr != nil {
|
||
if cached := loadCachedGitHubRelease(apiURL); cached != nil {
|
||
logger.Warnf("检查更新读取响应失败,回退缓存发布信息:url=%s err=%v", apiURL, readErr)
|
||
return cached, nil
|
||
}
|
||
return nil, wrapUpdateNetworkError(readErr)
|
||
}
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
|
||
if cached := loadCachedGitHubRelease(apiURL); cached != nil {
|
||
logger.Warnf("检查更新被限流/拒绝 (HTTP %d),回退缓存发布信息:url=%s", resp.StatusCode, apiURL)
|
||
return cached, nil
|
||
}
|
||
}
|
||
return nil, classifyGitHubUpdateHTTPError(resp.StatusCode, body, resp.Header, true)
|
||
}
|
||
|
||
var release githubRelease
|
||
if err := json.Unmarshal(body, &release); err != nil {
|
||
return nil, wrapUpdateNetworkError(err)
|
||
}
|
||
storeCachedGitHubRelease(apiURL, &release)
|
||
return &release, nil
|
||
}
|
||
|
||
func applyGitHubAPIRequestHeaders(req *http.Request) {
|
||
if req == nil {
|
||
return
|
||
}
|
||
req.Header.Set("User-Agent", "GoNavi-Updater/"+strings.TrimSpace(getCurrentVersion()))
|
||
req.Header.Set("Accept", "application/vnd.github+json")
|
||
req.Header.Set("X-GitHub-Api-Version", updateGitHubAPIVersion)
|
||
if token := resolveGitHubAPIToken(); token != "" {
|
||
req.Header.Set("Authorization", "Bearer "+token)
|
||
}
|
||
}
|
||
|
||
func applyGitHubDownloadRequestHeaders(req *http.Request, assetAPIURL bool) {
|
||
if req == nil {
|
||
return
|
||
}
|
||
req.Header.Set("User-Agent", "GoNavi-Updater/"+strings.TrimSpace(getCurrentVersion()))
|
||
if assetAPIURL {
|
||
req.Header.Set("Accept", "application/octet-stream")
|
||
req.Header.Set("X-GitHub-Api-Version", updateGitHubAPIVersion)
|
||
if token := resolveGitHubAPIToken(); token != "" {
|
||
req.Header.Set("Authorization", "Bearer "+token)
|
||
}
|
||
return
|
||
}
|
||
// browser_download_url 通常走 objects/release-assets CDN,不强制 github+json
|
||
req.Header.Set("Accept", "*/*")
|
||
}
|
||
|
||
func resolveGitHubAPIToken() string {
|
||
for _, key := range []string{"GONAVI_GITHUB_TOKEN", "GITHUB_TOKEN"} {
|
||
if token := strings.TrimSpace(os.Getenv(key)); token != "" {
|
||
return token
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func loadCachedGitHubRelease(apiURL string) *githubRelease {
|
||
value, ok := updateReleaseCache.Load(strings.TrimSpace(apiURL))
|
||
if !ok {
|
||
return nil
|
||
}
|
||
entry, ok := value.(cachedGitHubRelease)
|
||
if !ok || entry.release == nil {
|
||
return nil
|
||
}
|
||
if time.Since(entry.fetchedAt) > updateReleaseCacheTTL {
|
||
return nil
|
||
}
|
||
// 浅拷贝,避免调用方意外改写缓存
|
||
cloned := *entry.release
|
||
if entry.release.Assets != nil {
|
||
cloned.Assets = append([]githubAsset(nil), entry.release.Assets...)
|
||
}
|
||
return &cloned
|
||
}
|
||
|
||
func storeCachedGitHubRelease(apiURL string, release *githubRelease) {
|
||
if strings.TrimSpace(apiURL) == "" || release == nil {
|
||
return
|
||
}
|
||
cloned := *release
|
||
if release.Assets != nil {
|
||
cloned.Assets = append([]githubAsset(nil), release.Assets...)
|
||
}
|
||
updateReleaseCache.Store(strings.TrimSpace(apiURL), cachedGitHubRelease{
|
||
release: &cloned,
|
||
fetchedAt: time.Now(),
|
||
})
|
||
}
|
||
|
||
func classifyGitHubUpdateHTTPError(status int, body []byte, headers http.Header, isCheck bool) error {
|
||
snippet := strings.TrimSpace(string(body))
|
||
if len(snippet) > updateHTTPBodySnippetLimit {
|
||
snippet = snippet[:updateHTTPBodySnippetLimit] + "…"
|
||
}
|
||
lower := strings.ToLower(snippet)
|
||
remaining := strings.TrimSpace(headers.Get("X-RateLimit-Remaining"))
|
||
reset := strings.TrimSpace(headers.Get("X-RateLimit-Reset"))
|
||
detailParts := make([]string, 0, 3)
|
||
if snippet != "" {
|
||
// 尽量抽出 GitHub JSON message 字段
|
||
var payload struct {
|
||
Message string `json:"message"`
|
||
}
|
||
if json.Unmarshal(body, &payload) == nil && strings.TrimSpace(payload.Message) != "" {
|
||
detailParts = append(detailParts, strings.TrimSpace(payload.Message))
|
||
} else {
|
||
detailParts = append(detailParts, snippet)
|
||
}
|
||
}
|
||
if remaining != "" {
|
||
detailParts = append(detailParts, "X-RateLimit-Remaining="+remaining)
|
||
}
|
||
if reset != "" {
|
||
detailParts = append(detailParts, "X-RateLimit-Reset="+reset)
|
||
}
|
||
detail := strings.Join(detailParts, " | ")
|
||
|
||
rateLimited := status == http.StatusTooManyRequests ||
|
||
strings.Contains(lower, "rate limit") ||
|
||
strings.Contains(lower, "secondary rate limit") ||
|
||
(status == http.StatusForbidden && remaining == "0")
|
||
|
||
if rateLimited {
|
||
return localizedUpdateError{
|
||
key: "app.update.backend.error.check_http_rate_limited",
|
||
params: map[string]any{"detail": detail},
|
||
}
|
||
}
|
||
if status == http.StatusForbidden {
|
||
if isCheck {
|
||
return localizedUpdateError{
|
||
key: "app.update.backend.error.check_http_forbidden",
|
||
params: map[string]any{"detail": detail},
|
||
}
|
||
}
|
||
return localizedUpdateError{
|
||
key: "app.update.backend.error.package_download_forbidden",
|
||
params: map[string]any{"detail": detail},
|
||
}
|
||
}
|
||
if isCheck {
|
||
return localizedUpdateError{
|
||
key: "app.update.backend.error.check_http_status",
|
||
params: map[string]any{"status": status},
|
||
}
|
||
}
|
||
return localizedUpdateError{
|
||
key: "app.update.backend.error.package_download_http_failed",
|
||
params: map[string]any{"status": status},
|
||
}
|
||
}
|
||
|
||
func expectedAssetName(goos, goarch, version string) (string, error) {
|
||
executablePath := ""
|
||
if goos == "linux" {
|
||
if path, err := os.Executable(); err == nil {
|
||
if resolved, resolveErr := filepath.EvalSymlinks(path); resolveErr == nil && strings.TrimSpace(resolved) != "" {
|
||
path = resolved
|
||
}
|
||
executablePath = path
|
||
}
|
||
}
|
||
return expectedAssetNameForExecutable(goos, goarch, version, executablePath)
|
||
}
|
||
|
||
func expectedAssetNameForExecutable(goos, goarch, version, executablePath string) (string, error) {
|
||
version = strings.TrimSpace(version)
|
||
version = strings.TrimPrefix(version, "v")
|
||
version = strings.TrimPrefix(version, "V")
|
||
if version == "" {
|
||
return "", localizedUpdateError{key: "app.update.backend.error.release_version_unparseable"}
|
||
}
|
||
|
||
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%s.tar.gz", version, resolveLinuxReleaseArtifactSuffix(executablePath)), nil
|
||
}
|
||
if goarch == "arm64" {
|
||
return fmt.Sprintf("GoNavi-%s-Linux-Arm64%s.tar.gz", version, resolveLinuxReleaseArtifactSuffix(executablePath)), nil
|
||
}
|
||
}
|
||
return "", localizedUpdateError{
|
||
key: "app.update.backend.error.online_update_unsupported",
|
||
params: map[string]any{"platform": goos + "/" + goarch},
|
||
}
|
||
}
|
||
|
||
func resolveLinuxReleaseArtifactSuffix(executablePath string) string {
|
||
normalizedPath := strings.ToLower(strings.TrimSpace(executablePath))
|
||
if normalizedPath == "" {
|
||
return ""
|
||
}
|
||
normalizedPath = strings.ReplaceAll(normalizedPath, "\\", "/")
|
||
compactPath := strings.ReplaceAll(normalizedPath, "_", "")
|
||
compactPath = strings.ReplaceAll(compactPath, "-", "")
|
||
if strings.Contains(normalizedPath, "webkit41") || strings.Contains(compactPath, "webkit241") || strings.Contains(compactPath, "webkit41") {
|
||
return "-WebKit41"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func findReleaseAsset(assets []githubAsset, name string) (*githubAsset, error) {
|
||
for _, asset := range assets {
|
||
if asset.Name == name {
|
||
return &asset, nil
|
||
}
|
||
}
|
||
return nil, localizedUpdateError{
|
||
key: "app.update.backend.error.update_package_not_found",
|
||
params: map[string]any{"name": name},
|
||
}
|
||
}
|
||
|
||
func normalizeGitHubAssetSHA256(digest string) string {
|
||
digest = strings.TrimSpace(digest)
|
||
if digest == "" {
|
||
return ""
|
||
}
|
||
if algorithm, value, ok := strings.Cut(digest, ":"); ok {
|
||
if !strings.EqualFold(strings.TrimSpace(algorithm), "sha256") {
|
||
return ""
|
||
}
|
||
digest = strings.TrimSpace(value)
|
||
}
|
||
return strings.ToLower(digest)
|
||
}
|
||
|
||
func fetchReleaseSHA256(assets []githubAsset) (map[string]string, error) {
|
||
var candidates []string
|
||
seen := map[string]struct{}{}
|
||
addCandidate := func(raw string) {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
return
|
||
}
|
||
if _, ok := seen[raw]; ok {
|
||
return
|
||
}
|
||
seen[raw] = struct{}{}
|
||
candidates = append(candidates, raw)
|
||
}
|
||
for _, asset := range assets {
|
||
if strings.EqualFold(asset.Name, updateChecksumAsset) || strings.Contains(strings.ToLower(asset.Name), "sha256sums") {
|
||
addCandidate(asset.BrowserDownloadURL)
|
||
addCandidate(asset.URL)
|
||
break
|
||
}
|
||
}
|
||
if len(candidates) == 0 {
|
||
return nil, localizedUpdateError{key: "app.update.backend.error.sha256sums_missing"}
|
||
}
|
||
|
||
client := newHTTPClientWithGlobalProxy(15 * time.Second)
|
||
var lastStatus int
|
||
for _, candidate := range candidates {
|
||
resp, err := doGitHubDownload(client, candidate)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||
_ = resp.Body.Close()
|
||
if readErr != nil {
|
||
continue
|
||
}
|
||
if resp.StatusCode == http.StatusOK {
|
||
return parseSHA256Sums(string(body)), nil
|
||
}
|
||
lastStatus = resp.StatusCode
|
||
}
|
||
if lastStatus == 0 {
|
||
lastStatus = http.StatusForbidden
|
||
}
|
||
return nil, localizedUpdateError{
|
||
key: "app.update.backend.error.sha256sums_download_failed",
|
||
params: map[string]any{"status": lastStatus},
|
||
}
|
||
}
|
||
|
||
func doGitHubDownload(client *http.Client, rawURL string) (*http.Response, error) {
|
||
rawURL = strings.TrimSpace(rawURL)
|
||
if rawURL == "" {
|
||
return nil, localizedUpdateError{
|
||
key: "app.update.backend.error.package_download_http_failed",
|
||
params: map[string]any{"status": 0},
|
||
}
|
||
}
|
||
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
applyGitHubDownloadRequestHeaders(req, isGitHubReleaseAssetAPIURL(rawURL))
|
||
return doUpdateRequest(client, req)
|
||
}
|
||
|
||
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) {
|
||
return downloadFileWithHashWithTimeout(url, filePath, onProgress, 10*time.Minute)
|
||
}
|
||
|
||
func downloadFileWithHashWithTimeout(url, filePath string, onProgress func(downloaded, total int64), timeout time.Duration) (string, error) {
|
||
if timeout <= 0 {
|
||
timeout = 10 * time.Minute
|
||
}
|
||
client := newHTTPClientWithGlobalProxy(timeout)
|
||
resp, err := doGitHubDownload(client, url)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||
return "", classifyGitHubUpdateHTTPError(resp.StatusCode, body, resp.Header, false)
|
||
}
|
||
|
||
// 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 "", localizedUpdateError{
|
||
key: "app.update.backend.error.package_file_busy",
|
||
params: map[string]any{"detail": err.Error()},
|
||
}
|
||
}
|
||
|
||
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 "", wrapUpdateNetworkError(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 doUpdateRequest(client *http.Client, req *http.Request) (*http.Response, error) {
|
||
resp, err := client.Do(req)
|
||
if err == nil {
|
||
return resp, nil
|
||
}
|
||
if !shouldRetryUpdateNetworkError(err) {
|
||
return nil, wrapUpdateNetworkError(err)
|
||
}
|
||
time.Sleep(updateNetworkRetryDelay)
|
||
retryReq := req.Clone(req.Context())
|
||
resp, err = client.Do(retryReq)
|
||
if err != nil {
|
||
return nil, wrapUpdateNetworkError(err)
|
||
}
|
||
return resp, nil
|
||
}
|
||
|
||
func shouldRetryUpdateNetworkError(err error) bool {
|
||
if err == nil {
|
||
return false
|
||
}
|
||
if isUpdateEOFError(err) {
|
||
return true
|
||
}
|
||
var netErr net.Error
|
||
if errors.As(err, &netErr) && netErr.Timeout() {
|
||
return true
|
||
}
|
||
lower := strings.ToLower(err.Error())
|
||
return strings.Contains(lower, "connection reset by peer") ||
|
||
strings.Contains(lower, "connection refused") ||
|
||
strings.Contains(lower, "server closed idle connection")
|
||
}
|
||
|
||
func wrapUpdateNetworkError(err error) error {
|
||
if err == nil {
|
||
return nil
|
||
}
|
||
var dnsErr *net.DNSError
|
||
if errors.As(err, &dnsErr) {
|
||
host := strings.TrimSpace(dnsErr.Name)
|
||
if host == "" {
|
||
host = "api.github.com"
|
||
}
|
||
return localizedUpdateError{
|
||
key: "app.update.backend.error.network_dns",
|
||
params: map[string]any{
|
||
"host": host,
|
||
"detail": err.Error(),
|
||
},
|
||
}
|
||
}
|
||
if isUpdateEOFError(err) {
|
||
return localizedUpdateError{
|
||
key: "app.update.backend.error.network_eof",
|
||
params: map[string]any{"detail": err.Error()},
|
||
}
|
||
}
|
||
return localizedUpdateError{
|
||
key: "app.update.backend.error.network_failed",
|
||
params: map[string]any{"detail": err.Error()},
|
||
}
|
||
}
|
||
|
||
func isUpdateEOFError(err error) bool {
|
||
return errors.Is(err, io.EOF) ||
|
||
errors.Is(err, io.ErrUnexpectedEOF) ||
|
||
strings.Contains(strings.ToLower(err.Error()), "eof")
|
||
}
|
||
|
||
func isGitHubReleaseAssetAPIURL(urlText string) bool {
|
||
parsed, err := urlpkg.Parse(strings.TrimSpace(urlText))
|
||
if err != nil {
|
||
return false
|
||
}
|
||
if !strings.EqualFold(parsed.Host, "api.github.com") {
|
||
return false
|
||
}
|
||
return strings.Contains(strings.ToLower(strings.TrimSpace(parsed.Path)), "/releases/assets/")
|
||
}
|
||
|
||
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 buildUpdateStageDirName(channel string, version string) string {
|
||
return buildUpdateStageDirNameForPlatform(stdRuntime.GOOS, channel, version)
|
||
}
|
||
|
||
func buildUpdateStageDirNameForPlatform(goos string, channel string, version string) string {
|
||
normalizedChannel, err := normalizeUpdateChannel(channel)
|
||
if err != nil {
|
||
normalizedChannel = updateChannelLatest
|
||
}
|
||
return fmt.Sprintf(
|
||
".gonavi-update-%s-%s-%s",
|
||
strings.TrimSpace(strings.ToLower(goos)),
|
||
sanitizeVersionForPath(string(normalizedChannel)),
|
||
sanitizeVersionForPath(version),
|
||
)
|
||
}
|
||
|
||
func resolveReleaseVersion(channel updateChannel, release *githubRelease) string {
|
||
if release == nil {
|
||
return ""
|
||
}
|
||
|
||
tagVersion := normalizeVersion(release.TagName)
|
||
if channel != updateChannelDev && tagVersion != "" && !strings.EqualFold(tagVersion, updateDevReleaseTag) {
|
||
return tagVersion
|
||
}
|
||
|
||
if nameVersion := extractVersionFromReleaseName(release.Name); nameVersion != "" {
|
||
return nameVersion
|
||
}
|
||
if assetVersion := extractVersionFromReleaseAssets(release.Assets); assetVersion != "" {
|
||
return assetVersion
|
||
}
|
||
if tagVersion != "" && !strings.EqualFold(tagVersion, updateDevReleaseTag) {
|
||
return tagVersion
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func extractVersionFromReleaseName(name string) string {
|
||
trimmed := strings.TrimSpace(name)
|
||
if trimmed == "" {
|
||
return ""
|
||
}
|
||
|
||
if strings.HasPrefix(strings.ToLower(trimmed), "dev-") {
|
||
return normalizeVersion(trimmed)
|
||
}
|
||
|
||
if left := strings.LastIndex(trimmed, "("); left >= 0 && strings.HasSuffix(trimmed, ")") {
|
||
candidate := strings.TrimSpace(trimmed[left+1 : len(trimmed)-1])
|
||
if candidate != "" {
|
||
return normalizeVersion(candidate)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func extractVersionFromReleaseAssets(assets []githubAsset) string {
|
||
const assetPrefix = "GoNavi-"
|
||
osMarkers := []string{"-Windows-", "-MacOS-", "-Linux-"}
|
||
|
||
for _, asset := range assets {
|
||
name := strings.TrimSpace(asset.Name)
|
||
if !strings.HasPrefix(name, assetPrefix) {
|
||
continue
|
||
}
|
||
rest := strings.TrimPrefix(name, assetPrefix)
|
||
for _, marker := range osMarkers {
|
||
index := strings.Index(rest, marker)
|
||
if index <= 0 {
|
||
continue
|
||
}
|
||
candidate := normalizeVersion(rest[:index])
|
||
if candidate != "" {
|
||
return candidate
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
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 更新包继续保存在桌面版本目录根级,方便用户直接处理 DMG。
|
||
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)))
|
||
}
|
||
}
|
||
}
|
||
|
||
// Windows / Linux 更新包优先落到当前应用运行目录,方便用户直接找到下载产物。
|
||
targetPath := strings.TrimSpace(updateResolveInstallTarget())
|
||
if targetPath != "" {
|
||
targetDir := strings.TrimSpace(filepath.Dir(targetPath))
|
||
if targetDir != "" && targetDir != "." {
|
||
return targetDir
|
||
}
|
||
}
|
||
|
||
return resolveLegacyUpdateWorkspaceDir()
|
||
}
|
||
|
||
func resolveUpdateAssetPath(workspaceDir string, stagedDir string, assetName string) string {
|
||
name := strings.TrimSpace(assetName)
|
||
if shouldStoreUpdateAssetInWorkspaceRoot(stdRuntime.GOOS) {
|
||
return filepath.Join(workspaceDir, name)
|
||
}
|
||
return filepath.Join(stagedDir, name)
|
||
}
|
||
|
||
func shouldStoreUpdateAssetInWorkspaceRoot(goos string) bool {
|
||
switch strings.TrimSpace(strings.ToLower(goos)) {
|
||
case "darwin", "windows", "linux":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func resolveUpdateStagedDir(workspaceDir string, channel string, version string) string {
|
||
return resolveUpdateStagedDirForPlatform(stdRuntime.GOOS, workspaceDir, channel, version)
|
||
}
|
||
|
||
func resolveUpdateStagedDirForPlatform(goos string, workspaceDir string, channel string, version string) string {
|
||
baseDir := strings.TrimSpace(workspaceDir)
|
||
if strings.EqualFold(strings.TrimSpace(goos), "windows") || baseDir == "" {
|
||
baseDir = resolveLegacyUpdateWorkspaceDir()
|
||
}
|
||
return filepath.Join(baseDir, buildUpdateStageDirNameForPlatform(goos, channel, version))
|
||
}
|
||
|
||
func shouldReuseUpdateAssetFromStagedDirForPlatform(goos string, assetName string) bool {
|
||
return !(strings.EqualFold(strings.TrimSpace(goos), "windows") &&
|
||
shouldWindowsUpdateLaunchDownloadedAssetDirectly(assetName))
|
||
}
|
||
|
||
func normalizeUpdatePathForPrefixCheck(path string) string {
|
||
normalized := strings.ReplaceAll(strings.TrimSpace(path), "\\", "/")
|
||
normalized = filepath.ToSlash(filepath.Clean(normalized))
|
||
if normalized == "." {
|
||
return ""
|
||
}
|
||
return strings.TrimRight(normalized, "/")
|
||
}
|
||
|
||
func isUpdateAssetPathInsideStagedDir(filePath string, stagedDir string) bool {
|
||
normalizedFilePath := normalizeUpdatePathForPrefixCheck(filePath)
|
||
normalizedStagedDir := normalizeUpdatePathForPrefixCheck(stagedDir)
|
||
if normalizedFilePath == "" || normalizedStagedDir == "" {
|
||
return false
|
||
}
|
||
return normalizedFilePath == normalizedStagedDir || strings.HasPrefix(normalizedFilePath, normalizedStagedDir+"/")
|
||
}
|
||
|
||
func buildReusableUpdatePathCandidatesForPlatform(goos string, preferredWorkspaceDir string, legacyWorkspaceDir string, channel string, version string, assetName string) []updatePathCandidate {
|
||
preferredWorkspaceDir = strings.TrimSpace(preferredWorkspaceDir)
|
||
legacyWorkspaceDir = strings.TrimSpace(legacyWorkspaceDir)
|
||
assetName = strings.TrimSpace(assetName)
|
||
preferredStagedDir := resolveUpdateStagedDirForPlatform(goos, preferredWorkspaceDir, channel, version)
|
||
stagedDirNames := []string{
|
||
buildUpdateStageDirNameForPlatform(goos, channel, version),
|
||
fmt.Sprintf(".gonavi-update-%s-%s", strings.TrimSpace(strings.ToLower(goos)), version),
|
||
}
|
||
workspaceCandidates := []string{preferredWorkspaceDir, legacyWorkspaceDir}
|
||
stageBaseCandidates := []string{preferredWorkspaceDir, legacyWorkspaceDir}
|
||
seenWorkspace := make(map[string]struct{}, len(workspaceCandidates))
|
||
seenStageBase := make(map[string]struct{}, len(stageBaseCandidates))
|
||
candidates := make([]updatePathCandidate, 0, 8)
|
||
|
||
for _, workspaceDir := range workspaceCandidates {
|
||
workspaceDir = strings.TrimSpace(workspaceDir)
|
||
if workspaceDir == "" {
|
||
continue
|
||
}
|
||
if _, exists := seenWorkspace[workspaceDir]; exists {
|
||
continue
|
||
}
|
||
seenWorkspace[workspaceDir] = struct{}{}
|
||
if shouldStoreUpdateAssetInWorkspaceRoot(goos) {
|
||
candidates = append(candidates, updatePathCandidate{
|
||
workspaceDir: workspaceDir,
|
||
stagedDir: preferredStagedDir,
|
||
assetPath: filepath.Join(workspaceDir, assetName),
|
||
})
|
||
}
|
||
}
|
||
|
||
if !shouldReuseUpdateAssetFromStagedDirForPlatform(goos, assetName) {
|
||
return candidates
|
||
}
|
||
|
||
for _, stageBaseDir := range stageBaseCandidates {
|
||
stageBaseDir = strings.TrimSpace(stageBaseDir)
|
||
if stageBaseDir == "" {
|
||
continue
|
||
}
|
||
if _, exists := seenStageBase[stageBaseDir]; exists {
|
||
continue
|
||
}
|
||
seenStageBase[stageBaseDir] = struct{}{}
|
||
for _, stagedDirName := range stagedDirNames {
|
||
stagedDir := filepath.Join(stageBaseDir, stagedDirName)
|
||
candidates = append(candidates, updatePathCandidate{
|
||
workspaceDir: stageBaseDir,
|
||
stagedDir: stagedDir,
|
||
assetPath: filepath.Join(stagedDir, assetName),
|
||
})
|
||
}
|
||
}
|
||
|
||
return candidates
|
||
}
|
||
|
||
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 {
|
||
return resolveReusableStagedUpdateForPlatform(
|
||
stdRuntime.GOOS,
|
||
resolveUpdateWorkspaceDir(strings.TrimSpace(info.LatestVersion)),
|
||
resolveLegacyUpdateWorkspaceDir(),
|
||
info,
|
||
current,
|
||
)
|
||
}
|
||
|
||
func resolveReusableStagedUpdateForPlatform(goos string, preferredWorkspaceDir string, legacyWorkspaceDir string, info UpdateInfo, current *stagedUpdate) *stagedUpdate {
|
||
channel, err := normalizeUpdateChannel(info.Channel)
|
||
if err != nil {
|
||
channel = updateChannelLatest
|
||
}
|
||
version := strings.TrimSpace(info.LatestVersion)
|
||
assetName := strings.TrimSpace(info.AssetName)
|
||
if version == "" || assetName == "" {
|
||
return nil
|
||
}
|
||
allowStagedDirReuse := shouldReuseUpdateAssetFromStagedDirForPlatform(goos, assetName)
|
||
|
||
if current != nil {
|
||
currentChannel := current.Channel
|
||
if currentChannel == "" {
|
||
currentChannel = updateChannelLatest
|
||
}
|
||
if currentChannel == channel && strings.TrimSpace(current.Version) == version {
|
||
currentPath := strings.TrimSpace(current.FilePath)
|
||
if isExistingDownloadedAsset(currentPath, info.AssetSize) {
|
||
if !allowStagedDirReuse && isUpdateAssetPathInsideStagedDir(currentPath, current.StagedDir) {
|
||
current = nil
|
||
} else {
|
||
if strings.TrimSpace(current.InstallLogPath) == "" {
|
||
current.InstallLogPath = buildUpdateInstallLogPath(filepath.Dir(currentPath))
|
||
}
|
||
current.Channel = channel
|
||
return current
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
candidates := buildReusableUpdatePathCandidatesForPlatform(
|
||
goos,
|
||
preferredWorkspaceDir,
|
||
legacyWorkspaceDir,
|
||
string(channel),
|
||
version,
|
||
assetName,
|
||
)
|
||
for _, candidate := range candidates {
|
||
if !isExistingDownloadedAsset(candidate.assetPath, info.AssetSize) {
|
||
continue
|
||
}
|
||
return &stagedUpdate{
|
||
Channel: channel,
|
||
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 ensureWindowsUpdateTargetWritable(targetExe string) error {
|
||
targetExe = strings.TrimSpace(targetExe)
|
||
targetDir := strings.TrimSpace(filepath.Dir(targetExe))
|
||
if targetExe == "" || targetDir == "" || targetDir == "." {
|
||
return localizedUpdateError{key: "app.update.backend.error.install_target_unresolved"}
|
||
}
|
||
|
||
probePath := filepath.Join(targetDir, fmt.Sprintf(".gonavi-update-write-probe-%d.tmp", time.Now().UnixNano()))
|
||
file, err := os.OpenFile(probePath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||
if err != nil {
|
||
return localizedUpdateError{
|
||
key: "app.update.backend.error.install_target_not_writable",
|
||
params: map[string]any{
|
||
"path": targetDir,
|
||
"detail": err.Error(),
|
||
},
|
||
}
|
||
}
|
||
if closeErr := file.Close(); closeErr != nil {
|
||
logger.Warnf("关闭 Windows 更新写入探针失败:%v", closeErr)
|
||
}
|
||
if removeErr := os.Remove(probePath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {
|
||
logger.Warnf("清理 Windows 更新写入探针失败:%v", removeErr)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func shouldWindowsUpdateLaunchDownloadedAssetDirectly(assetPath string) bool {
|
||
return strings.EqualFold(strings.TrimSpace(filepath.Ext(strings.TrimSpace(assetPath))), ".exe")
|
||
}
|
||
|
||
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
|
||
}
|
||
uievents.Emit(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 localizedUpdateError{
|
||
key: "app.update.backend.error.install_unsupported",
|
||
params: map[string]any{"platform": stdRuntime.GOOS},
|
||
}
|
||
}
|
||
}
|
||
|
||
func launchWindowsUpdate(staged *stagedUpdate, targetExe string, pid int) error {
|
||
if err := os.MkdirAll(staged.StagedDir, 0o755); err != nil {
|
||
return err
|
||
}
|
||
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)
|
||
if err := cmd.Start(); err != nil {
|
||
return err
|
||
}
|
||
if cmd.Process != nil {
|
||
if err := cmd.Process.Release(); err != nil {
|
||
logger.Warnf("释放 Windows 更新脚本进程句柄失败:%v", err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
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__
|
||
set /a WAIT_PID_SECONDS=0
|
||
|
||
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 ("%TARGET%") do set "TARGET_DIR=%%~dpI"
|
||
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%"
|
||
)
|
||
for %%I in ("%SOURCE_EXE%") do set "SOURCE_DIR=%%~dpI"
|
||
|
||
:waitloop
|
||
tasklist /FI "PID eq %PID%" | find "%PID%" >nul
|
||
if %ERRORLEVEL%==0 (
|
||
if !WAIT_PID_SECONDS! GEQ 90 (
|
||
call :log host process still running after !WAIT_PID_SECONDS! seconds, aborting update
|
||
exit /b 1
|
||
)
|
||
timeout /t 1 /nobreak >nul
|
||
set /a WAIT_PID_SECONDS+=1
|
||
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
|
||
|
||
:replace_binary
|
||
if /I "%SOURCE_EXE%"=="%TARGET%" (
|
||
call :log downloaded executable already at target path, skip replace
|
||
goto move_done
|
||
)
|
||
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
|
||
if exist "%SOURCE%" del /F /Q "%SOURCE%" >> "%LOG_FILE%" 2>&1
|
||
start "" /D "%TARGET_DIR%" "%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%' -WorkingDirectory '%TARGET_DIR%'" >> "%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 {
|
||
cmd := exec.Command("cmd.exe", "/D", "/C", "call", scriptPath)
|
||
configureWindowsUpdateCommand(cmd)
|
||
return cmd
|
||
}
|
||
|
||
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"
|
||
TARGET_NAME="$(basename "$TARGET")"
|
||
NEWBIN="$TMPDIR/$TARGET_NAME"
|
||
if [ ! -f "$NEWBIN" ]; then
|
||
NEWBIN=$(find "$TMPDIR" -type f -name "$TARGET_NAME" | head -n 1)
|
||
fi
|
||
if [ -z "$NEWBIN" ] || [ ! -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
|
||
}
|