From 3beab030f48358bca2580761ff2a0d8906cf337e Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 9 Jul 2026 12:35:12 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(update):=20=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E4=BC=98=E5=85=88=E9=9D=99=E6=80=81=20latest?= =?UTF-8?q?.json=EF=BC=8C=E9=81=BF=E5=85=8D=E7=94=A8=E6=88=B7=E6=92=9E=20G?= =?UTF-8?q?itHub=20API=20=E9=99=90=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 客户端优先下载 releases/latest/download/latest.json,再回退 REST API - 成功结果写入磁盘缓存,限流/断网时可 stale 回退 - 静默检查增加节流;限流文案改为用户无需配置 Token - CI 与 build-release 发版时生成 latest.json 清单 - 补充静态清单/缓存/节流单测与生成脚本测试 --- .github/workflows/release.yml | 18 + build-release.sh | 30 ++ internal/app/methods_update.go | 17 +- internal/app/methods_update_test.go | 35 ++ internal/app/update_manifest.go | 349 ++++++++++++++++++ internal/app/update_manifest_test.go | 188 ++++++++++ shared/i18n/de-DE.json | 2 +- shared/i18n/en-US.json | 2 +- shared/i18n/ja-JP.json | 2 +- shared/i18n/ru-RU.json | 2 +- shared/i18n/zh-CN.json | 2 +- shared/i18n/zh-TW.json | 2 +- tools/generate-update-latest-manifest.py | 177 +++++++++ tools/generate-update-latest-manifest.test.py | 58 +++ 14 files changed, 873 insertions(+), 11 deletions(-) create mode 100644 internal/app/update_manifest.go create mode 100644 internal/app/update_manifest_test.go create mode 100755 tools/generate-update-latest-manifest.py create mode 100644 tools/generate-update-latest-manifest.test.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0379a280..6405f4fa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1081,6 +1081,24 @@ jobs: sha256sum "${FILES[@]}" > SHA256SUMS fi + # 静态更新清单:客户端优先下载 github.com/.../latest/download/latest.json, + # 避免终端用户检查更新打爆 api.github.com 未认证配额。 + - name: Generate static update manifest (latest.json) + shell: bash + run: | + set -euo pipefail + TAG="${{ github.ref_name }}" + VERSION="${TAG#v}" + python3 tools/generate-update-latest-manifest.py \ + --assets-dir release-assets \ + --version "$VERSION" \ + --tag "$TAG" \ + --channel latest \ + --output release-assets/latest.json + test -s release-assets/latest.json + echo "📄 latest.json ready:" + head -n 40 release-assets/latest.json + - name: Generate Driver SHA256SUMS if: steps.driver_assets.outputs.has_driver_assets == 'true' shell: bash diff --git a/build-release.sh b/build-release.sh index 13cd9bb6..e1e44f6e 100755 --- a/build-release.sh +++ b/build-release.sh @@ -355,6 +355,9 @@ if command -v sha256sum &> /dev/null; then : > SHA256SUMS for f in *; do [ -f "$f" ] || continue + case "$f" in + SHA256SUMS|latest.json|latest-dev.json) continue ;; + esac sha256sum "$f" >> SHA256SUMS done cd .. @@ -363,6 +366,9 @@ elif command -v shasum &> /dev/null; then : > SHA256SUMS for f in *; do [ -f "$f" ] || continue + case "$f" in + SHA256SUMS|latest.json|latest-dev.json) continue ;; + esac shasum -a 256 "$f" >> SHA256SUMS done cd .. @@ -370,6 +376,30 @@ else echo -e "${YELLOW} ⚠️ 未找到 sha256sum/shasum,跳过校验文件生成。${NC}" fi +echo -e "${GREEN}📄 生成静态更新清单 latest.json...${NC}" +if command -v python3 &> /dev/null; then + TAG_FOR_MANIFEST="v${VERSION#v}" + CHANNEL_FOR_MANIFEST="latest" + OUT_MANIFEST="$DIST_DIR/latest.json" + case "$VERSION" in + *dev*|*test*|*-*) + # 本地/测试版本默认打 latest.json,发正式版时由 CI 使用 git tag + ;; + esac + if python3 tools/generate-update-latest-manifest.py \ + --assets-dir "$DIST_DIR" \ + --version "$VERSION" \ + --tag "$TAG_FOR_MANIFEST" \ + --channel "$CHANNEL_FOR_MANIFEST" \ + --output "$OUT_MANIFEST"; then + echo -e "${GREEN} ✅ 已生成 $OUT_MANIFEST${NC}" + else + echo -e "${YELLOW} ⚠️ 生成 latest.json 失败(不影响本地产物,正式发版由 CI 生成)${NC}" + fi +else + echo -e "${YELLOW} ⚠️ 未找到 python3,跳过 latest.json${NC}" +fi + echo "" if [ "${#BUILD_FAILURES[@]}" -gt 0 ]; then echo -e "${RED}❌ 构建未完全成功,失败平台:${BUILD_FAILURES[*]}${NC}" diff --git a/internal/app/methods_update.go b/internal/app/methods_update.go index 3237b141..fb6d0706 100644 --- a/internal/app/methods_update.go +++ b/internal/app/methods_update.go @@ -158,17 +158,19 @@ func (a *App) localizedUpdateError(err error) string { } func (a *App) CheckForUpdates() connection.QueryResult { - return a.checkForUpdates(true) + // 用户手动检查:强制走网络(静态清单优先,API 回退) + return a.checkForUpdates(true, true) } func (a *App) CheckForUpdatesSilently() connection.QueryResult { - return a.checkForUpdates(false) + // 静默检查:允许节流,优先磁盘/短时缓存,避免启动刷爆网络 + return a.checkForUpdates(false, false) } -func (a *App) checkForUpdates(logFailure bool) connection.QueryResult { +func (a *App) checkForUpdates(logFailure bool, forceNetwork bool) connection.QueryResult { a.ensurePersistedGlobalProxyRuntime() channel := a.currentUpdateChannel() - info, err := fetchLatestUpdateInfo(channel) + info, err := fetchLatestUpdateInfoWithOptions(channel, forceNetwork) if err != nil { if logFailure { updateLogCheckError(err) @@ -456,10 +458,15 @@ func (a *App) downloadAndStageUpdate(info UpdateInfo) connection.QueryResult { } func fetchLatestUpdateInfo(channel updateChannel) (UpdateInfo, error) { + return fetchLatestUpdateInfoWithOptions(channel, true) +} + +func fetchLatestUpdateInfoWithOptions(channel updateChannel, forceNetwork bool) (UpdateInfo, error) { if channel != updateChannelDev { channel = updateChannelLatest } - release, err := fetchReleaseForChannel(channel) + // 优先静态 latest.json(不占 api.github.com 配额)→ GitHub API → 磁盘缓存 + release, err := fetchReleaseForChannelPreferringStatic(channel, forceNetwork) if err != nil { return UpdateInfo{}, err } diff --git a/internal/app/methods_update_test.go b/internal/app/methods_update_test.go index 00f67a91..955590a3 100644 --- a/internal/app/methods_update_test.go +++ b/internal/app/methods_update_test.go @@ -28,6 +28,11 @@ func TestFetchLatestUpdateInfoSkipsChecksumWhenCurrentVersionIsAlreadyLatest(t * }() releaseCalled := false + restoreStatic := swapUpdateFetchStaticManifest(func(channel updateChannel) (*githubRelease, error) { + // 单测走 API 路径,模拟尚无 latest.json 的历史 Release + return nil, errors.New("static manifest unavailable in test") + }) + defer restoreStatic() restoreRelease := swapUpdateFetchLatestRelease(func() (*githubRelease, error) { releaseCalled = true return &githubRelease{ @@ -84,6 +89,10 @@ func TestFetchLatestUpdateInfoUsesAssetDigestWhenUpdateIsAvailable(t *testing.T) AppVersion = originalVersion }() + restoreStatic := swapUpdateFetchStaticManifest(func(channel updateChannel) (*githubRelease, error) { + return nil, errors.New("static manifest unavailable in test") + }) + defer restoreStatic() restoreRelease := swapUpdateFetchLatestRelease(func() (*githubRelease, error) { return &githubRelease{ TagName: "v0.6.5", @@ -139,6 +148,10 @@ func TestFetchLatestUpdateInfoFallsBackToChecksumFileWhenAssetDigestMissing(t *t AppVersion = originalVersion }() + restoreStatic := swapUpdateFetchStaticManifest(func(channel updateChannel) (*githubRelease, error) { + return nil, errors.New("static manifest unavailable in test") + }) + defer restoreStatic() restoreRelease := swapUpdateFetchLatestRelease(func() (*githubRelease, error) { return &githubRelease{ TagName: "v0.6.5", @@ -181,7 +194,12 @@ func TestFetchLatestUpdateInfoFallsBackToChecksumFileWhenAssetDigestMissing(t *t func TestCheckForUpdatesLogsFailuresForManualChecks(t *testing.T) { app := &App{configDir: t.TempDir()} + t.Setenv("GONAVI_DATA_ROOT", t.TempDir()) + restoreStatic := swapUpdateFetchStaticManifest(func(channel updateChannel) (*githubRelease, error) { + return nil, errors.New("static unavailable") + }) + defer restoreStatic() restoreRelease := swapUpdateFetchLatestRelease(func() (*githubRelease, error) { return nil, errors.New("request timed out") }) @@ -204,7 +222,12 @@ func TestCheckForUpdatesLogsFailuresForManualChecks(t *testing.T) { func TestCheckForUpdatesSilentlySkipsFailureLogs(t *testing.T) { app := &App{configDir: t.TempDir()} + t.Setenv("GONAVI_DATA_ROOT", t.TempDir()) + restoreStatic := swapUpdateFetchStaticManifest(func(channel updateChannel) (*githubRelease, error) { + return nil, errors.New("static unavailable") + }) + defer restoreStatic() restoreRelease := swapUpdateFetchLatestRelease(func() (*githubRelease, error) { return nil, errors.New("request timed out") }) @@ -273,6 +296,10 @@ func TestCheckForUpdatesRestoresPersistedGlobalProxyRuntime(t *testing.T) { AppVersion = originalVersion }() + restoreStatic := swapUpdateFetchStaticManifest(func(channel updateChannel) (*githubRelease, error) { + return nil, errors.New("static unavailable; exercise API proxy path") + }) + defer restoreStatic() restoreRelease := swapUpdateFetchDevRelease(func() (*githubRelease, error) { return fetchReleaseByURL("http://api.github.invalid/repos/Syngnat/GoNavi/releases/tags/dev-latest") }) @@ -304,6 +331,10 @@ func TestFetchLatestUpdateInfoForDevChannelUsesReleaseBuildVersion(t *testing.T) AppVersion = originalVersion }() + restoreStatic := swapUpdateFetchStaticManifest(func(channel updateChannel) (*githubRelease, error) { + return nil, errors.New("static unavailable in test") + }) + defer restoreStatic() restoreRelease := swapUpdateFetchDevRelease(func() (*githubRelease, error) { return &githubRelease{ TagName: "dev-latest", @@ -362,6 +393,10 @@ func TestFetchLatestUpdateInfoForDevChannelSkipsChecksumWhenBuildMatches(t *test AppVersion = originalVersion }() + restoreStatic := swapUpdateFetchStaticManifest(func(channel updateChannel) (*githubRelease, error) { + return nil, errors.New("static unavailable in test") + }) + defer restoreStatic() restoreRelease := swapUpdateFetchDevRelease(func() (*githubRelease, error) { return &githubRelease{ TagName: "dev-latest", diff --git a/internal/app/update_manifest.go b/internal/app/update_manifest.go new file mode 100644 index 00000000..239457e2 --- /dev/null +++ b/internal/app/update_manifest.go @@ -0,0 +1,349 @@ +package app + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "GoNavi-Wails/internal/appdata" + "GoNavi-Wails/internal/logger" +) + +const ( + // 静态清单:挂在 GitHub Release 资产上,走 github.com 下载链路,不消耗 api.github.com 配额。 + // 发版时由 CI/build-release 生成 latest.json 并上传到当前 latest 发布。 + updateLatestManifestURL = "https://github.com/" + updateRepo + "/releases/latest/download/latest.json" + updateDevManifestURL = "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 + updateNetworkCheckMu sync.Mutex + updateLastNetworkCheck updateNetworkCheckMemory +) + +func swapUpdateFetchStaticManifest(next func(updateChannel) (*githubRelease, error)) func() { + original := updateFetchStaticManifest + updateFetchStaticManifest = next + return func() { + updateFetchStaticManifest = original + } +} + +func updateManifestRemoteURL(channel updateChannel) string { + if channel == updateChannelDev { + return updateDevManifestURL + } + return updateLatestManifestURL +} + +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) { + url := updateManifestRemoteURL(channel) + 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) + } + manifest.FetchedAt = time.Now().UTC() + manifest.Source = "static" + if strings.TrimSpace(manifest.Channel) == "" { + manifest.Channel = string(channel) + } + storeDiskUpdateManifest(channel, &manifest) + release := releaseFromUpdateManifest(&manifest) + // 同步进进程内 API 缓存,供限流回退 + if channel == updateChannelDev { + storeCachedGitHubRelease(updateDevAPIURL, release) + } else { + storeCachedGitHubRelease(updateLatestAPIURL, release) + } + return release, 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 + } + } + + var staticErr error + if release, err := updateFetchStaticManifest(channel); err == nil && release != nil { + markUpdateNetworkCheck(channel) + return release, nil + } else { + staticErr = err + if err != nil { + logger.Warnf("静态更新清单不可用,回退 GitHub API:channel=%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() +} diff --git a/internal/app/update_manifest_test.go b/internal/app/update_manifest_test.go new file mode 100644 index 00000000..a43bd3bf --- /dev/null +++ b/internal/app/update_manifest_test.go @@ -0,0 +1,188 @@ +package app + +import ( + "encoding/json" + "errors" + "os" + "strings" + "sync" + "testing" + "time" +) + +func TestReleaseFromUpdateManifestMapsAssets(t *testing.T) { + release := releaseFromUpdateManifest(&updateReleaseManifest{ + TagName: "v1.2.3", + Version: "1.2.3", + Name: "GoNavi 1.2.3", + HTMLURL: "https://github.com/Syngnat/GoNavi/releases/tag/v1.2.3", + PublishedAt: "2026-07-09T00:00:00Z", + Assets: []updateManifestAsset{ + { + Name: "GoNavi-1.2.3-Windows-Amd64.exe", + URL: "https://example.com/app.exe", + SHA256: "Aa" + strings.Repeat("b", 62), + Size: 99, + }, + }, + }) + if release == nil { + t.Fatal("expected release") + } + if release.TagName != "v1.2.3" || len(release.Assets) != 1 { + t.Fatalf("unexpected release: %#v", release) + } + if release.Assets[0].BrowserDownloadURL != "https://example.com/app.exe" { + t.Fatalf("url = %q", release.Assets[0].BrowserDownloadURL) + } + if !strings.HasPrefix(release.Assets[0].Digest, "sha256:") { + t.Fatalf("digest = %q", release.Assets[0].Digest) + } +} + +func TestDiskUpdateManifestRoundTrip(t *testing.T) { + root := t.TempDir() + t.Setenv("GONAVI_DATA_ROOT", root) + + payload := &updateReleaseManifest{ + SchemaVersion: 1, + Channel: "latest", + TagName: "v9.9.9", + Version: "9.9.9", + Name: "v9.9.9", + HTMLURL: "https://github.com/Syngnat/GoNavi/releases/tag/v9.9.9", + Assets: []updateManifestAsset{ + {Name: "app.bin", URL: "https://example.com/app.bin", SHA256: strings.Repeat("c", 64), Size: 1}, + }, + FetchedAt: time.Now().UTC(), + } + storeDiskUpdateManifest(updateChannelLatest, payload) + loaded, stale := loadDiskUpdateManifest(updateChannelLatest) + if loaded == nil || stale { + t.Fatalf("expected fresh disk cache, got %#v stale=%v", loaded, stale) + } + if loaded.Version != "9.9.9" { + t.Fatalf("version = %q", loaded.Version) + } + path := updateManifestCachePath(updateChannelLatest) + if _, err := os.Stat(path); err != nil { + t.Fatalf("cache file missing: %v", err) + } + if !strings.HasPrefix(path, root) { + t.Fatalf("cache path not under data root: %s", path) + } +} + +func TestFetchReleaseForChannelPreferringStaticUsesStaticFirst(t *testing.T) { + root := t.TempDir() + t.Setenv("GONAVI_DATA_ROOT", root) + updateReleaseCache = sync.Map{} + + staticCalled := false + apiCalled := false + restoreStatic := swapUpdateFetchStaticManifest(func(channel updateChannel) (*githubRelease, error) { + staticCalled = true + return &githubRelease{TagName: "v2.0.0", Name: "v2.0.0"}, nil + }) + defer restoreStatic() + restoreAPI := swapUpdateFetchLatestRelease(func() (*githubRelease, error) { + apiCalled = true + return nil, errors.New("api should not be called") + }) + defer restoreAPI() + + release, err := fetchReleaseForChannelPreferringStatic(updateChannelLatest, true) + if err != nil { + t.Fatalf("err=%v", err) + } + if !staticCalled || apiCalled { + t.Fatalf("staticCalled=%v apiCalled=%v", staticCalled, apiCalled) + } + if release.TagName != "v2.0.0" { + t.Fatalf("tag=%q", release.TagName) + } +} + +func TestFetchReleaseForChannelPreferringStaticFallsBackAPIThenDisk(t *testing.T) { + root := t.TempDir() + t.Setenv("GONAVI_DATA_ROOT", root) + updateReleaseCache = sync.Map{} + + storeDiskUpdateManifest(updateChannelLatest, &updateReleaseManifest{ + SchemaVersion: 1, + TagName: "v1.0.0", + Version: "1.0.0", + Assets: []updateManifestAsset{{Name: "a", URL: "https://example.com/a"}}, + FetchedAt: time.Now().UTC(), + }) + + restoreStatic := swapUpdateFetchStaticManifest(func(channel updateChannel) (*githubRelease, error) { + return nil, errors.New("static 404") + }) + defer restoreStatic() + restoreAPI := swapUpdateFetchLatestRelease(func() (*githubRelease, error) { + return nil, errors.New("api rate limit") + }) + defer restoreAPI() + + release, err := fetchReleaseForChannelPreferringStatic(updateChannelLatest, true) + if err != nil { + t.Fatalf("expected disk fallback, err=%v", err) + } + if release.TagName != "v1.0.0" { + t.Fatalf("tag=%q", release.TagName) + } +} + +func TestSilentCheckThrottleReusesDisk(t *testing.T) { + root := t.TempDir() + t.Setenv("GONAVI_DATA_ROOT", root) + updateReleaseCache = sync.Map{} + storeDiskUpdateManifest(updateChannelLatest, &updateReleaseManifest{ + SchemaVersion: 1, + TagName: "v3.0.0", + Version: "3.0.0", + Assets: []updateManifestAsset{{Name: "a", URL: "u"}}, + FetchedAt: time.Now().UTC(), + }) + markUpdateNetworkCheck(updateChannelLatest) + + staticCalls := 0 + restoreStatic := swapUpdateFetchStaticManifest(func(channel updateChannel) (*githubRelease, error) { + staticCalls++ + return nil, errors.New("should not hit network") + }) + defer restoreStatic() + restoreAPI := swapUpdateFetchLatestRelease(func() (*githubRelease, error) { + t.Fatal("api should not be called during throttle") + return nil, nil + }) + defer restoreAPI() + + release, err := fetchReleaseForChannelPreferringStatic(updateChannelLatest, false) + if err != nil { + t.Fatalf("err=%v", err) + } + if release.TagName != "v3.0.0" { + t.Fatalf("tag=%q", release.TagName) + } + if staticCalls != 0 { + t.Fatalf("staticCalls=%d", staticCalls) + } +} + +func TestUpdateManifestFromGitHubRelease(t *testing.T) { + release := &githubRelease{ + TagName: "v1.0.1", + Name: "v1.0.1", + HTMLURL: "https://github.com/Syngnat/GoNavi/releases/tag/v1.0.1", + Assets: []githubAsset{ + {Name: "a.exe", BrowserDownloadURL: "https://x/a.exe", Digest: "sha256:" + strings.Repeat("d", 64), Size: 10}, + }, + } + m := updateManifestFromGitHubRelease(updateChannelLatest, release, nil) + if m == nil || m.Version == "" || len(m.Assets) != 1 { + t.Fatalf("manifest=%#v", m) + } + _ = json.Marshal +} diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index 8c78ae9f..1e1109f5 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -2938,7 +2938,7 @@ "app.update.backend.error.channel_invalid": "Ungültiger Update-Kanal: {{channel}}", "app.update.backend.error.check_failed": "Updateprüfung fehlgeschlagen: {{detail}}", "app.update.backend.error.check_http_forbidden": "Update-Prüfung fehlgeschlagen: GitHub meldete 403 Forbidden. Prüfen Sie den Zugriff auf api.github.com über Netzwerk/Proxy oder setzen Sie GONAVI_GITHUB_TOKEN. {{detail}}", - "app.update.backend.error.check_http_rate_limited": "Update-Prüfung fehlgeschlagen: GitHub-API-Ratenlimit erreicht (ohne Auth ca. 60 Anfragen/Stunde; gemeinsame Proxy-IPs sind schneller erschöpft). Später erneut versuchen oder GONAVI_GITHUB_TOKEN setzen. {{detail}}", + "app.update.backend.error.check_http_rate_limited": "Update-Prüfung fehlgeschlagen: Der Update-Dienst ist ausgelastet oder Ihr Netzausgang ist begrenzt. Bitte später erneut versuchen. Endnutzer müssen kein Token konfigurieren. {{detail}}", "app.update.backend.error.check_http_status": "Updateprüfung fehlgeschlagen: HTTP {{status}}", "app.update.backend.error.download_failed": "Updatedownload fehlgeschlagen: {{detail}}", "app.update.backend.error.install_target_not_writable": "Das aktuelle Installationsverzeichnis ist nicht beschreibbar: {{path}} ({{detail}})", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index df782df3..a957c1e5 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -2938,7 +2938,7 @@ "app.update.backend.error.channel_invalid": "Invalid update channel: {{channel}}", "app.update.backend.error.check_failed": "Check for updates failed: {{detail}}", "app.update.backend.error.check_http_forbidden": "Update check failed: GitHub returned 403 Forbidden. Ensure api.github.com is reachable via your network/proxy, or set GONAVI_GITHUB_TOKEN. {{detail}}", - "app.update.backend.error.check_http_rate_limited": "Update check failed: GitHub API rate limit exceeded (about 60 requests/hour unauthenticated; shared proxy IPs hit the limit faster). Retry later, or set GONAVI_GITHUB_TOKEN to raise the quota. {{detail}}", + "app.update.backend.error.check_http_rate_limited": "Update check failed: the update service is busy or your network egress is rate-limited. Please try again later. End users do not need to configure any token. {{detail}}", "app.update.backend.error.check_http_status": "Check for updates failed: HTTP {{status}}", "app.update.backend.error.download_failed": "Update download failed: {{detail}}", "app.update.backend.error.install_target_not_writable": "The current install directory is not writable: {{path}} ({{detail}})", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index 9c37673a..6279d8ae 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -2938,7 +2938,7 @@ "app.update.backend.error.channel_invalid": "無効な更新チャネルです: {{channel}}", "app.update.backend.error.check_failed": "更新確認に失敗しました: {{detail}}", "app.update.backend.error.check_http_forbidden": "更新確認に失敗しました: GitHub が 403 を返しました。api.github.com への通信/プロキシを確認するか、GONAVI_GITHUB_TOKEN を設定してください。{{detail}}", - "app.update.backend.error.check_http_rate_limited": "更新確認に失敗しました: GitHub API のレート制限に達しました(未認証は約 60 回/時。共有プロキシ IP では更に早く制限されます)。しばらくして再試行するか、GONAVI_GITHUB_TOKEN を設定してください。{{detail}}", + "app.update.backend.error.check_http_rate_limited": "更新確認に失敗しました: 更新サーバーが混雑しているか、ネットワーク出口が制限されています。しばらくしてから再試行してください。一般ユーザーは Token の設定は不要です。{{detail}}", "app.update.backend.error.check_http_status": "更新確認に失敗しました: HTTP {{status}}", "app.update.backend.error.download_failed": "更新のダウンロードに失敗しました: {{detail}}", "app.update.backend.error.install_target_not_writable": "現在のインストール先ディレクトリに書き込めません: {{path}} ({{detail}})", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index 4ff1104d..aabcbc44 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -2938,7 +2938,7 @@ "app.update.backend.error.channel_invalid": "Недопустимый канал обновления: {{channel}}", "app.update.backend.error.check_failed": "Не удалось проверить обновления: {{detail}}", "app.update.backend.error.check_http_forbidden": "Не удалось проверить обновления: GitHub вернул 403. Проверьте доступ к api.github.com через сеть/прокси или задайте GONAVI_GITHUB_TOKEN. {{detail}}", - "app.update.backend.error.check_http_rate_limited": "Не удалось проверить обновления: превышен лимит GitHub API (без токена ~60 запросов/час; общий IP прокси исчерпывается быстрее). Повторите позже или задайте GONAVI_GITHUB_TOKEN. {{detail}}", + "app.update.backend.error.check_http_rate_limited": "Не удалось проверить обновления: служба обновлений занята или сетевой выход ограничен. Повторите позже. Конечным пользователям не нужно настраивать токен. {{detail}}", "app.update.backend.error.check_http_status": "Не удалось проверить обновления: HTTP {{status}}", "app.update.backend.error.download_failed": "Не удалось скачать обновление: {{detail}}", "app.update.backend.error.install_target_not_writable": "Текущий каталог установки недоступен для записи: {{path}} ({{detail}})", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index c2a0dcf8..1c994cde 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -2938,7 +2938,7 @@ "app.update.backend.error.channel_invalid": "无效的更新通道:{{channel}}", "app.update.backend.error.check_failed": "检查更新失败:{{detail}}", "app.update.backend.error.check_http_forbidden": "检查更新失败:GitHub 返回 403(访问被拒绝)。请确认网络/代理可访问 api.github.com,或配置 GONAVI_GITHUB_TOKEN。{{detail}}", - "app.update.backend.error.check_http_rate_limited": "检查更新失败:GitHub API 请求过于频繁(未认证约 60 次/小时;若走共享代理,出口 IP 会更快被限流)。可稍后再试,或设置环境变量 GONAVI_GITHUB_TOKEN 提高配额。{{detail}}", + "app.update.backend.error.check_http_rate_limited": "检查更新失败:更新服务暂时繁忙或网络出口被限流。请稍后再试;普通用户无需配置任何 Token。{{detail}}", "app.update.backend.error.check_http_status": "检查更新失败:HTTP {{status}}", "app.update.backend.error.download_failed": "更新下载失败:{{detail}}", "app.update.backend.error.install_target_not_writable": "当前安装目录不可写:{{path}}({{detail}})", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index e9440090..e7d11b12 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -2938,7 +2938,7 @@ "app.update.backend.error.channel_invalid": "無效的更新通道:{{channel}}", "app.update.backend.error.check_failed": "檢查更新失敗:{{detail}}", "app.update.backend.error.check_http_forbidden": "檢查更新失敗:GitHub 回傳 403(存取被拒絕)。請確認網路/代理可存取 api.github.com,或設定 GONAVI_GITHUB_TOKEN。{{detail}}", - "app.update.backend.error.check_http_rate_limited": "檢查更新失敗:GitHub API 請求過於頻繁(未認證約 60 次/小時;若走共用代理,出口 IP 會更快被限流)。可稍後再試,或設定環境變數 GONAVI_GITHUB_TOKEN 提高配額。{{detail}}", + "app.update.backend.error.check_http_rate_limited": "檢查更新失敗:更新服務暫時繁忙或網路出口被限流。請稍後再試;一般使用者無需設定任何 Token。{{detail}}", "app.update.backend.error.check_http_status": "檢查更新失敗:HTTP {{status}}", "app.update.backend.error.download_failed": "更新下載失敗:{{detail}}", "app.update.backend.error.install_target_not_writable": "目前安裝目錄不可寫:{{path}}({{detail}})", diff --git a/tools/generate-update-latest-manifest.py b/tools/generate-update-latest-manifest.py new file mode 100755 index 00000000..ed9db5f7 --- /dev/null +++ b/tools/generate-update-latest-manifest.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Generate GoNavi static update manifest (latest.json / latest-dev.json). + +The client prefers this file over GitHub REST API so end users are not subject +to unauthenticated api.github.com rate limits (60/hour/IP). + +Usage: + python3 tools/generate-update-latest-manifest.py \\ + --assets-dir dist \\ + --version 1.2.3 \\ + --tag v1.2.3 \\ + --channel latest \\ + --output dist/latest.json + + # dev channel + python3 tools/generate-update-latest-manifest.py \\ + --assets-dir dist \\ + --version dev-a1b2c3d \\ + --tag dev-latest \\ + --channel dev \\ + --output dist/latest-dev.json +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from datetime import datetime, timezone +from pathlib import Path + +REPO = "Syngnat/GoNavi" +SCHEMA_VERSION = 1 +SKIP_NAMES = { + "SHA256SUMS", + "latest.json", + "latest-dev.json", + ".DS_Store", +} + + +def parse_sha256sums(path: Path) -> dict[str, str]: + if not path.is_file(): + return {} + result: dict[str, str] = {} + for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + line = line.strip() + if not line: + continue + # "hash filename" or "hash *filename" + m = re.match(r"^([0-9a-fA-F]{64})\s+\*?(.+)$", line) + if not m: + continue + digest, name = m.group(1).lower(), m.group(2).strip() + result[Path(name).name] = digest + return result + + +def normalize_version(version: str) -> str: + v = (version or "").strip() + if v.lower().startswith("v") and len(v) > 1 and v[1].isdigit(): + return v[1:] + return v + + +def browser_download_url(tag: str, asset_name: str) -> str: + tag = tag.strip() + name = asset_name.strip() + return f"https://github.com/{REPO}/releases/download/{tag}/{name}" + + +def html_url(tag: str) -> str: + return f"https://github.com/{REPO}/releases/tag/{tag.strip()}" + + +def collect_assets(assets_dir: Path, tag: str, hashes: dict[str, str]) -> list[dict]: + assets: list[dict] = [] + for path in sorted(assets_dir.iterdir()): + if not path.is_file(): + continue + name = path.name + if name in SKIP_NAMES: + continue + if name.startswith("."): + continue + item = { + "name": name, + "url": browser_download_url(tag, name), + "size": path.stat().st_size, + } + sha = hashes.get(name, "").strip().lower() + if sha: + item["sha256"] = sha + assets.append(item) + return assets + + +def build_manifest( + *, + channel: str, + version: str, + tag: str, + assets_dir: Path, + name: str | None, + published_at: str | None, +) -> dict: + hashes = parse_sha256sums(assets_dir / "SHA256SUMS") + tag = tag.strip() or f"v{normalize_version(version)}" + version = normalize_version(version) or normalize_version(tag) + assets = collect_assets(assets_dir, tag, hashes) + if not assets: + raise SystemExit(f"no release assets found under {assets_dir}") + + return { + "schemaVersion": SCHEMA_VERSION, + "channel": channel, + "tagName": tag, + "version": version, + "name": (name or tag).strip(), + "htmlUrl": html_url(tag), + "publishedAt": (published_at or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")), + "assets": assets, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Generate GoNavi latest.json update manifest") + parser.add_argument("--assets-dir", required=True, help="Directory containing release binaries + SHA256SUMS") + parser.add_argument("--version", required=True, help="Release version, e.g. 1.2.3 or dev-abc1234") + parser.add_argument("--tag", default="", help="Git tag, e.g. v1.2.3 (default: v{version})") + parser.add_argument( + "--channel", + choices=("latest", "dev"), + default="latest", + help="Update channel (default: latest)", + ) + parser.add_argument("--name", default="", help="Release display name") + parser.add_argument("--published-at", default="", help="ISO8601 published time") + parser.add_argument( + "--output", + default="", + help="Output path (default: /latest.json or latest-dev.json)", + ) + args = parser.parse_args() + + assets_dir = Path(args.assets_dir).resolve() + if not assets_dir.is_dir(): + print(f"assets dir not found: {assets_dir}", file=sys.stderr) + return 2 + + tag = args.tag.strip() + if not tag: + if args.channel == "dev": + tag = "dev-latest" + else: + ver = normalize_version(args.version) + tag = f"v{ver}" if ver else "" + + out_name = "latest-dev.json" if args.channel == "dev" else "latest.json" + output = Path(args.output).resolve() if args.output else assets_dir / out_name + + manifest = build_manifest( + channel=args.channel, + version=args.version, + tag=tag, + assets_dir=assets_dir, + name=args.name or None, + published_at=args.published_at or None, + ) + output.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(f"wrote {output} ({len(manifest['assets'])} assets, version={manifest['version']})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/generate-update-latest-manifest.test.py b/tools/generate-update-latest-manifest.test.py new file mode 100644 index 00000000..bf2f6e8b --- /dev/null +++ b/tools/generate-update-latest-manifest.test.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "tools" / "generate-update-latest-manifest.py" + + +class GenerateUpdateLatestManifestTest(unittest.TestCase): + def test_generates_manifest_with_sha256(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + assets = Path(tmp) + exe = assets / "GoNavi-1.2.3-Windows-Amd64.exe" + exe.write_bytes(b"fake-binary") + (assets / "SHA256SUMS").write_text( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa GoNavi-1.2.3-Windows-Amd64.exe\n", + encoding="utf-8", + ) + out = assets / "latest.json" + subprocess.check_call( + [ + sys.executable, + str(SCRIPT), + "--assets-dir", + str(assets), + "--version", + "1.2.3", + "--tag", + "v1.2.3", + "--channel", + "latest", + "--output", + str(out), + ], + cwd=str(ROOT), + ) + data = json.loads(out.read_text(encoding="utf-8")) + self.assertEqual(data["schemaVersion"], 1) + self.assertEqual(data["channel"], "latest") + self.assertEqual(data["version"], "1.2.3") + self.assertEqual(data["tagName"], "v1.2.3") + self.assertEqual(len(data["assets"]), 1) + asset = data["assets"][0] + self.assertEqual(asset["name"], "GoNavi-1.2.3-Windows-Amd64.exe") + self.assertEqual( + asset["url"], + "https://github.com/Syngnat/GoNavi/releases/download/v1.2.3/GoNavi-1.2.3-Windows-Amd64.exe", + ) + self.assertEqual(asset["sha256"], "a" * 64) + self.assertNotIn("SHA256SUMS", [a["name"] for a in data["assets"]]) + + +if __name__ == "__main__": + unittest.main()