Files
MyGoNavi/internal/app/update_manifest.go
2026-07-23 14:45:31 +08:00

566 lines
19 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 (
"encoding/json"
"fmt"
"io"
"net/http"
neturl "net/url"
"os"
"path/filepath"
stdRuntime "runtime"
"strings"
"sync"
"time"
"GoNavi-Wails/internal/appdata"
"GoNavi-Wails/internal/logger"
)
const (
// 静态清单优先走自建下载镜像GitHub Release 作为故障回退。
updateMirrorLatestManifestURL = "https://download.syngnat.top/gonavi/releases/latest/latest.json"
updateMirrorDevManifestURL = "https://download.syngnat.top/gonavi/dev/releases/latest/latest-dev.json"
updateGitHubLatestManifestURL = "https://github.com/" + updateRepo + "/releases/latest/download/latest.json"
updateGitHubDevManifestURL = "https://github.com/" + updateRepo + "/releases/download/" + updateDevReleaseTag + "/latest-dev.json"
updateManifestSchemaVersion = 1
updateManifestFileName = "latest.json"
updateDevManifestFileName = "latest-dev.json"
// 磁盘缓存:跨重启保留;过期后仍可作为限流/网络失败时的 stale 回退
updateDiskCacheMaxAge = 7 * 24 * time.Hour
// 静默检查最短间隔,避免启动/前台切换反复打网
updateSilentCheckMinInterval = time.Hour
)
// updateReleaseManifest 是面向终端用户的静态更新清单(不依赖 GitHub REST API
type updateReleaseManifest struct {
SchemaVersion int `json:"schemaVersion"`
Channel string `json:"channel"`
TagName string `json:"tagName"`
Version string `json:"version"`
Name string `json:"name,omitempty"`
HTMLURL string `json:"htmlUrl,omitempty"`
PublishedAt string `json:"publishedAt,omitempty"`
Assets []updateManifestAsset `json:"assets"`
FetchedAt time.Time `json:"fetchedAt,omitempty"` // 仅本地缓存写入
Source string `json:"source,omitempty"` // static | api | disk-cache
}
type updateManifestAsset struct {
Name string `json:"name"`
URL string `json:"url"`
APIURL string `json:"apiUrl,omitempty"`
Size int64 `json:"size,omitempty"`
SHA256 string `json:"sha256,omitempty"`
}
type updateNetworkCheckMemory struct {
at time.Time
channel updateChannel
}
var (
updateFetchStaticManifest = fetchStaticUpdateManifest
updateFetchFreshestStaticManifest = fetchFreshestStaticUpdateManifest
updateNetworkCheckMu sync.Mutex
updateLastNetworkCheck updateNetworkCheckMemory
)
func swapUpdateFetchStaticManifest(next func(updateChannel) (*githubRelease, error)) func() {
originalStatic := updateFetchStaticManifest
originalFreshest := updateFetchFreshestStaticManifest
updateFetchStaticManifest = next
updateFetchFreshestStaticManifest = next
return func() {
updateFetchStaticManifest = originalStatic
updateFetchFreshestStaticManifest = originalFreshest
}
}
func swapUpdateFetchFreshestStaticManifest(next func(updateChannel) (*githubRelease, error)) func() {
original := updateFetchFreshestStaticManifest
updateFetchFreshestStaticManifest = next
return func() {
updateFetchFreshestStaticManifest = original
}
}
func updateManifestRemoteURLs(channel updateChannel) []string {
if channel == updateChannelDev {
return []string{updateMirrorDevManifestURL, updateGitHubDevManifestURL}
}
return []string{updateMirrorLatestManifestURL, updateGitHubLatestManifestURL}
}
func updateManifestCachePath(channel updateChannel) string {
name := updateManifestFileName
if channel == updateChannelDev {
name = updateDevManifestFileName
}
return filepath.Join(appdata.MustResolveActiveRoot(), "update-cache", name)
}
func releaseFromUpdateManifest(manifest *updateReleaseManifest) *githubRelease {
if manifest == nil {
return nil
}
assets := make([]githubAsset, 0, len(manifest.Assets))
for _, item := range manifest.Assets {
name := strings.TrimSpace(item.Name)
if name == "" {
continue
}
url := strings.TrimSpace(item.URL)
apiURL := strings.TrimSpace(item.APIURL)
digest := ""
if sha := normalizeGitHubAssetSHA256(item.SHA256); sha != "" {
digest = "sha256:" + sha
}
assets = append(assets, githubAsset{
Name: name,
BrowserDownloadURL: url,
URL: firstNonEmptyString(apiURL, url),
Digest: digest,
Size: item.Size,
})
}
tagName := strings.TrimSpace(manifest.TagName)
if tagName == "" && strings.TrimSpace(manifest.Version) != "" {
tagName = "v" + strings.TrimPrefix(strings.TrimSpace(manifest.Version), "v")
}
name := strings.TrimSpace(manifest.Name)
if name == "" {
name = tagName
}
return &githubRelease{
TagName: tagName,
Name: name,
HTMLURL: strings.TrimSpace(manifest.HTMLURL),
PublishedAt: strings.TrimSpace(manifest.PublishedAt),
Assets: assets,
}
}
func updateManifestFromGitHubRelease(channel updateChannel, release *githubRelease, hashes map[string]string) *updateReleaseManifest {
if release == nil {
return nil
}
version := resolveReleaseVersion(channel, release)
assets := make([]updateManifestAsset, 0, len(release.Assets))
for _, asset := range release.Assets {
name := strings.TrimSpace(asset.Name)
if name == "" {
continue
}
sha := normalizeGitHubAssetSHA256(asset.Digest)
if sha == "" && hashes != nil {
sha = normalizeGitHubAssetSHA256(hashes[name])
}
assets = append(assets, updateManifestAsset{
Name: name,
URL: firstNonEmptyString(asset.BrowserDownloadURL, asset.URL),
APIURL: strings.TrimSpace(asset.URL),
Size: asset.Size,
SHA256: sha,
})
}
return &updateReleaseManifest{
SchemaVersion: updateManifestSchemaVersion,
Channel: string(channel),
TagName: strings.TrimSpace(release.TagName),
Version: version,
Name: strings.TrimSpace(release.Name),
HTMLURL: strings.TrimSpace(release.HTMLURL),
PublishedAt: strings.TrimSpace(release.PublishedAt),
Assets: assets,
FetchedAt: time.Now().UTC(),
Source: "api",
}
}
func loadDiskUpdateManifest(channel updateChannel) (*updateReleaseManifest, bool /*stale*/) {
path := updateManifestCachePath(channel)
data, err := os.ReadFile(path)
if err != nil {
return nil, false
}
var manifest updateReleaseManifest
if err := json.Unmarshal(data, &manifest); err != nil {
return nil, false
}
if manifest.SchemaVersion != 0 && manifest.SchemaVersion != updateManifestSchemaVersion {
return nil, false
}
if strings.TrimSpace(manifest.TagName) == "" && strings.TrimSpace(manifest.Version) == "" {
return nil, false
}
stale := false
if !manifest.FetchedAt.IsZero() {
stale = time.Since(manifest.FetchedAt) > updateDiskCacheMaxAge
}
manifest.Source = "disk-cache"
return &manifest, stale
}
func storeDiskUpdateManifest(channel updateChannel, manifest *updateReleaseManifest) {
if manifest == nil {
return
}
path := updateManifestCachePath(channel)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
logger.Warnf("写入更新清单缓存目录失败:%v", err)
return
}
clone := *manifest
if clone.FetchedAt.IsZero() {
clone.FetchedAt = time.Now().UTC()
}
if strings.TrimSpace(clone.Source) == "" {
clone.Source = "static"
}
payload, err := json.MarshalIndent(clone, "", " ")
if err != nil {
return
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, payload, 0o644); err != nil {
logger.Warnf("写入更新清单缓存失败:%v", err)
return
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
logger.Warnf("提交更新清单缓存失败:%v", err)
}
}
func fetchStaticUpdateManifest(channel updateChannel) (*githubRelease, error) {
return fetchStaticUpdateManifestFromURLs(channel, updateManifestRemoteURLs(channel))
}
func fetchFreshestStaticUpdateManifest(channel updateChannel) (*githubRelease, error) {
return fetchFreshestStaticUpdateManifestFromURLs(channel, updateManifestRemoteURLs(channel))
}
func fetchStaticUpdateManifestFromURLs(channel updateChannel, manifestURLs []string) (*githubRelease, error) {
var failures []string
for _, manifestURL := range manifestURLs {
release, err := fetchStaticUpdateManifestFromURL(channel, manifestURL)
if err == nil && release != nil {
return release, nil
}
if err != nil {
failures = append(failures, fmt.Sprintf("%s: %v", manifestURL, err))
}
}
if len(failures) == 0 {
return nil, fmt.Errorf("no static update manifest URL configured")
}
return nil, fmt.Errorf("static update manifests unavailable: %s", strings.Join(failures, "; "))
}
func fetchStaticUpdateManifestFromURL(channel updateChannel, manifestURL string) (*githubRelease, error) {
manifest, err := fetchStaticUpdateManifestPayloadFromURL(channel, manifestURL)
if err != nil {
return nil, err
}
return commitStaticUpdateManifest(channel, manifest), nil
}
func fetchFreshestStaticUpdateManifestFromURLs(channel updateChannel, manifestURLs []string) (*githubRelease, error) {
type fetchResult struct {
index int
manifest *updateReleaseManifest
err error
}
results := make([]fetchResult, len(manifestURLs))
resultCh := make(chan fetchResult, len(manifestURLs))
for index, manifestURL := range manifestURLs {
go func(index int, manifestURL string) {
manifest, err := fetchStaticUpdateManifestPayloadFromURL(channel, manifestURL)
resultCh <- fetchResult{index: index, manifest: manifest, err: err}
}(index, manifestURL)
}
for range manifestURLs {
result := <-resultCh
results[result.index] = result
}
var selected *updateReleaseManifest
var failures []string
for index, result := range results {
if result.err != nil {
failures = append(failures, fmt.Sprintf("%s: %v", manifestURLs[index], result.err))
continue
}
if shouldPreferUpdateManifest(channel, selected, result.manifest) {
selected = result.manifest
}
}
if selected != nil {
return commitStaticUpdateManifest(channel, selected), nil
}
if len(failures) == 0 {
return nil, fmt.Errorf("no static update manifest URL configured")
}
return nil, fmt.Errorf("static update manifests unavailable: %s", strings.Join(failures, "; "))
}
func shouldPreferUpdateManifest(channel updateChannel, current *updateReleaseManifest, candidate *updateReleaseManifest) bool {
if candidate == nil {
return false
}
if current == nil {
return true
}
comparison, comparable := compareUpdateManifestRecency(channel, current, candidate)
return comparable && comparison < 0
}
// compareUpdateManifestRecency follows compareVersion: -1 means current is older.
// A false comparable result keeps source order stable, so the mirror wins ties.
func compareUpdateManifestRecency(channel updateChannel, current *updateReleaseManifest, candidate *updateReleaseManifest) (int, bool) {
if current == nil || candidate == nil {
return 0, false
}
if channel != updateChannelDev {
currentVersion := normalizeVersion(firstNonEmptyString(current.Version, current.TagName))
candidateVersion := normalizeVersion(firstNonEmptyString(candidate.Version, candidate.TagName))
if currentVersion != "" && candidateVersion != "" {
if comparison := compareVersion(currentVersion, candidateVersion); comparison != 0 {
return comparison, true
}
}
}
currentPublishedAt, currentErr := time.Parse(time.RFC3339, strings.TrimSpace(current.PublishedAt))
candidatePublishedAt, candidateErr := time.Parse(time.RFC3339, strings.TrimSpace(candidate.PublishedAt))
if currentErr == nil && candidateErr == nil {
switch {
case currentPublishedAt.Before(candidatePublishedAt):
return -1, true
case currentPublishedAt.After(candidatePublishedAt):
return 1, true
default:
return 0, true
}
}
return 0, false
}
func fetchStaticUpdateManifestPayloadFromURL(channel updateChannel, manifestURL string) (*updateReleaseManifest, error) {
url := strings.TrimSpace(manifestURL)
if url == "" {
return nil, fmt.Errorf("static update manifest URL is empty")
}
client := newHTTPClientWithGlobalProxy(15 * time.Second)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "GoNavi-Updater/"+strings.TrimSpace(getCurrentVersion()))
req.Header.Set("Accept", "application/json")
resp, err := doUpdateRequest(client, req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if readErr != nil {
return nil, wrapUpdateNetworkError(readErr)
}
if resp.StatusCode != http.StatusOK {
// 静态资产 404尚未发布 latest.json 的旧版本 Release正常回退 API
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("static update manifest not found: %s", url)
}
return nil, classifyGitHubUpdateHTTPError(resp.StatusCode, body, resp.Header, true)
}
var manifest updateReleaseManifest
if err := json.Unmarshal(body, &manifest); err != nil {
return nil, wrapUpdateNetworkError(err)
}
if strings.TrimSpace(manifest.TagName) == "" && strings.TrimSpace(manifest.Version) == "" {
return nil, localizedUpdateError{key: "app.update.backend.error.latest_version_unparseable"}
}
if manifest.SchemaVersion != 0 && manifest.SchemaVersion != updateManifestSchemaVersion {
return nil, fmt.Errorf("unsupported update manifest schema: %d", manifest.SchemaVersion)
}
if err := validateRemoteUpdateManifest(channel, &manifest); err != nil {
return nil, fmt.Errorf("invalid static update manifest %s: %w", url, err)
}
manifest.FetchedAt = time.Now().UTC()
manifest.Source = "static"
if strings.TrimSpace(manifest.Channel) == "" {
manifest.Channel = string(channel)
}
return &manifest, nil
}
func commitStaticUpdateManifest(channel updateChannel, manifest *updateReleaseManifest) *githubRelease {
if manifest == nil {
return nil
}
storeDiskUpdateManifest(channel, manifest)
release := releaseFromUpdateManifest(manifest)
// 同步进进程内 API 缓存,供限流回退
if channel == updateChannelDev {
storeCachedGitHubRelease(updateDevAPIURL, release)
} else {
storeCachedGitHubRelease(updateLatestAPIURL, release)
}
return release
}
func validateRemoteUpdateManifest(channel updateChannel, manifest *updateReleaseManifest) error {
if manifest == nil {
return fmt.Errorf("manifest is empty")
}
expectedChannel := string(updateChannelLatest)
if channel == updateChannelDev {
expectedChannel = string(updateChannelDev)
}
manifestChannel := strings.TrimSpace(manifest.Channel)
if manifestChannel != "" && !strings.EqualFold(manifestChannel, expectedChannel) {
return fmt.Errorf("channel %q does not match requested channel %q", manifestChannel, expectedChannel)
}
if len(manifest.Assets) == 0 {
return fmt.Errorf("manifest has no assets")
}
assetNames := make(map[string]struct{}, len(manifest.Assets))
for _, asset := range manifest.Assets {
name := strings.TrimSpace(asset.Name)
if name == "" {
return fmt.Errorf("manifest contains an unnamed asset")
}
nameKey := strings.ToLower(name)
if _, exists := assetNames[nameKey]; exists {
return fmt.Errorf("manifest contains duplicate asset %q", name)
}
assetNames[nameKey] = struct{}{}
if asset.Size <= 0 {
return fmt.Errorf("asset %q has invalid size", name)
}
if normalizeGitHubAssetSHA256(asset.SHA256) == "" {
return fmt.Errorf("asset %q has invalid sha256", name)
}
for label, rawURL := range map[string]string{"url": asset.URL, "apiUrl": asset.APIURL} {
value := strings.TrimSpace(rawURL)
if value == "" {
if label == "url" {
return fmt.Errorf("asset %q has no download URL", name)
}
continue
}
parsed, err := neturl.ParseRequestURI(value)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return fmt.Errorf("asset %q has invalid %s", name, label)
}
}
}
assetVersion := strings.TrimSpace(manifest.TagName)
if assetVersion == "" || strings.EqualFold(normalizeVersion(assetVersion), updateDevReleaseTag) {
assetVersion = strings.TrimSpace(manifest.Version)
}
expectedAsset, err := expectedAssetNameForInstallMode(
stdRuntime.GOOS,
stdRuntime.GOARCH,
assetVersion,
updateResolveInstallMode(),
)
if err != nil {
return err
}
if _, exists := assetNames[strings.ToLower(expectedAsset)]; !exists {
return fmt.Errorf("manifest does not contain current platform asset %q", expectedAsset)
}
return nil
}
// fetchReleaseForChannel prefers static manifest → GitHub API → disk cache.
// forceNetwork=false 时:静默检查若距上次成功拉网过近,直接用磁盘缓存。
func fetchReleaseForChannelPreferringStatic(channel updateChannel, forceNetwork bool) (*githubRelease, error) {
if channel != updateChannelDev {
channel = updateChannelLatest
}
if !forceNetwork {
if release := loadRecentNetworkOrDiskRelease(channel); release != nil {
return release, nil
}
}
staticFetcher := updateFetchStaticManifest
if forceNetwork {
staticFetcher = updateFetchFreshestStaticManifest
}
var staticErr error
if release, err := staticFetcher(channel); err == nil && release != nil {
markUpdateNetworkCheck(channel)
return release, nil
} else {
staticErr = err
if err != nil {
logger.Warnf("静态更新清单不可用,回退 GitHub APIchannel=%s err=%v", channel, err)
}
}
var apiErr error
release, err := fetchReleaseForChannel(channel)
if err == nil && release != nil {
// API 成功时落盘,供下次静态失败/限流时使用
storeDiskUpdateManifest(channel, updateManifestFromGitHubRelease(channel, release, nil))
markUpdateNetworkCheck(channel)
return release, nil
}
apiErr = err
if cached, stale := loadDiskUpdateManifest(channel); cached != nil {
logger.Warnf("更新检查回退磁盘清单channel=%s stale=%v staticErr=%v apiErr=%v", channel, stale, staticErr, apiErr)
return releaseFromUpdateManifest(cached), nil
}
if apiErr != nil {
return nil, apiErr
}
if staticErr != nil {
return nil, staticErr
}
return nil, localizedUpdateError{key: "app.update.backend.error.latest_version_unparseable"}
}
func loadRecentNetworkOrDiskRelease(channel updateChannel) *githubRelease {
updateNetworkCheckMu.Lock()
last := updateLastNetworkCheck
updateNetworkCheckMu.Unlock()
if last.channel == channel && !last.at.IsZero() && time.Since(last.at) < updateSilentCheckMinInterval {
if cached, stale := loadDiskUpdateManifest(channel); cached != nil && !stale {
logger.Warnf("静默更新检查节流:复用磁盘清单 channel=%s age=%s", channel, time.Since(last.at))
return releaseFromUpdateManifest(cached)
}
// 内存 API 缓存
apiURL := updateLatestAPIURL
if channel == updateChannelDev {
apiURL = updateDevAPIURL
}
if mem := loadCachedGitHubRelease(apiURL); mem != nil {
return mem
}
}
return nil
}
func markUpdateNetworkCheck(channel updateChannel) {
updateNetworkCheckMu.Lock()
updateLastNetworkCheck = updateNetworkCheckMemory{at: time.Now(), channel: channel}
updateNetworkCheckMu.Unlock()
}