mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-13 00:13:33 +08:00
✨ feat(update): 检查更新优先静态 latest.json,避免用户撞 GitHub API 限流
- 客户端优先下载 releases/latest/download/latest.json,再回退 REST API - 成功结果写入磁盘缓存,限流/断网时可 stale 回退 - 静默检查增加节流;限流文案改为用户无需配置 Token - CI 与 build-release 发版时生成 latest.json 清单 - 补充静态清单/缓存/节流单测与生成脚本测试
This commit is contained in:
18
.github/workflows/release.yml
vendored
18
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
349
internal/app/update_manifest.go
Normal file
349
internal/app/update_manifest.go
Normal file
@@ -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()
|
||||
}
|
||||
188
internal/app/update_manifest_test.go
Normal file
188
internal/app/update_manifest_test.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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}})",
|
||||
|
||||
@@ -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}})",
|
||||
|
||||
@@ -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}})",
|
||||
|
||||
@@ -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}})",
|
||||
|
||||
@@ -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}})",
|
||||
|
||||
@@ -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}})",
|
||||
|
||||
177
tools/generate-update-latest-manifest.py
Executable file
177
tools/generate-update-latest-manifest.py
Executable file
@@ -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: <assets-dir>/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())
|
||||
58
tools/generate-update-latest-manifest.test.py
Normal file
58
tools/generate-update-latest-manifest.test.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user