Files
MyGoNavi/internal/app/methods_update.go
Syngnat 3beab030f4 feat(update): 检查更新优先静态 latest.json,避免用户撞 GitHub API 限流
- 客户端优先下载 releases/latest/download/latest.json,再回退 REST API
- 成功结果写入磁盘缓存,限流/断网时可 stale 回退
- 静默检查增加节流;限流文案改为用户无需配置 Token
- CI 与 build-release 发版时生成 latest.json 清单
- 补充静态清单/缓存/节流单测与生成脚本测试
2026-07-09 12:35:12 +08:00

2010 lines
61 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net"
"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
}