Some checks failed
Security Scan / backend-security (push) Failing after 3s
Security Scan / frontend-security (push) Failing after 5s
CI / test (push) Failing after 3s
CI / frontend (push) Failing after 3s
CI / golangci-lint (push) Failing after 3s
CI / windsurf-platform (macos-latest) (push) Has been cancelled
CI / windsurf-platform (windows-latest) (push) Has been cancelled
198 lines
5.4 KiB
Go
198 lines
5.4 KiB
Go
package antigravity
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"runtime"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"time"
|
||
)
|
||
|
||
// 上游 Antigravity auto-updater 服务,返回有序的版本数组(最新在前)。
|
||
//
|
||
// 真实响应格式(截取):
|
||
//
|
||
// [{"version":"1.23.2","execution_id":"..."},{"version":"1.22.2",...},...]
|
||
const antigravityReleasesURL = "https://antigravity-auto-updater-974169037036.us-central1.run.app/releases"
|
||
|
||
const (
|
||
// versionRefreshInterval 与 CLIProxyAPI 一致:3 小时刷新一次真实版本号。
|
||
versionRefreshInterval = 3 * time.Hour
|
||
// versionFetchTimeout 单次拉取超时;失败不影响请求路径,沿用旧版本号即可。
|
||
versionFetchTimeout = 5 * time.Second
|
||
)
|
||
|
||
// versionFetcher 负责异步刷新 Antigravity 真实最新版本号。
|
||
//
|
||
// 设计:
|
||
// - 启动时若有 cached 版本则立即生效,否则保持兜底版本(defaultUserAgentVersion)。
|
||
// - 后台 goroutine 每 versionRefreshInterval 拉取一次。
|
||
// - 拉取失败不传播错误:保持现值即可(永远不让 UA 变成空字符串)。
|
||
// - 用户通过 ANTIGRAVITY_USER_AGENT_VERSION 显式指定版本时,禁用自动刷新。
|
||
type versionFetcher struct {
|
||
httpClient *http.Client
|
||
endpoint string
|
||
|
||
mu sync.RWMutex
|
||
current atomic.Pointer[string]
|
||
once sync.Once
|
||
stopCh chan struct{}
|
||
overridden bool
|
||
}
|
||
|
||
var defaultVersionFetcher = newVersionFetcher()
|
||
|
||
func newVersionFetcher() *versionFetcher {
|
||
return &versionFetcher{
|
||
httpClient: &http.Client{Timeout: versionFetchTimeout},
|
||
endpoint: antigravityReleasesURL,
|
||
stopCh: make(chan struct{}),
|
||
}
|
||
}
|
||
|
||
// newVersionFetcherForTest 用于注入自定义 endpoint 进行单元测试。
|
||
func newVersionFetcherForTest(endpoint string) *versionFetcher {
|
||
return &versionFetcher{
|
||
httpClient: &http.Client{Timeout: versionFetchTimeout},
|
||
endpoint: endpoint,
|
||
stopCh: make(chan struct{}),
|
||
}
|
||
}
|
||
|
||
// Current 返回当前缓存的版本号,未拉取过时返回空串。
|
||
func (f *versionFetcher) Current() string {
|
||
if v := f.current.Load(); v != nil {
|
||
return *v
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// MarkOverridden 标记版本号被环境变量显式覆盖,避免后台刷新覆盖用户配置。
|
||
func (f *versionFetcher) MarkOverridden() {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
f.overridden = true
|
||
}
|
||
|
||
// Start 启动后台刷新循环,幂等。
|
||
func (f *versionFetcher) Start() {
|
||
f.once.Do(func() {
|
||
go f.loop()
|
||
})
|
||
}
|
||
|
||
// Stop 停止后台刷新循环(用于测试)。
|
||
func (f *versionFetcher) Stop() {
|
||
select {
|
||
case <-f.stopCh:
|
||
default:
|
||
close(f.stopCh)
|
||
}
|
||
}
|
||
|
||
func (f *versionFetcher) loop() {
|
||
// 启动后立即拉一次,确保 UA 在第一次请求前已是真实版本(最多等待 versionFetchTimeout)。
|
||
f.refreshOnce()
|
||
ticker := time.NewTicker(versionRefreshInterval)
|
||
defer ticker.Stop()
|
||
for {
|
||
select {
|
||
case <-f.stopCh:
|
||
return
|
||
case <-ticker.C:
|
||
f.refreshOnce()
|
||
}
|
||
}
|
||
}
|
||
|
||
func (f *versionFetcher) refreshOnce() {
|
||
f.mu.RLock()
|
||
overridden := f.overridden
|
||
f.mu.RUnlock()
|
||
if overridden {
|
||
return
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), versionFetchTimeout)
|
||
defer cancel()
|
||
|
||
endpoint := f.endpoint
|
||
if endpoint == "" {
|
||
endpoint = antigravityReleasesURL
|
||
}
|
||
version, err := fetchVersionFromURL(ctx, f.httpClient, endpoint)
|
||
if err != nil {
|
||
// 失败不传播:保持现值;下个 tick 再试。
|
||
return
|
||
}
|
||
f.current.Store(&version)
|
||
// 同步给 GetUserAgent 的兜底全局变量,使旧路径也能拿到新版本。
|
||
setDefaultUserAgentVersion(version)
|
||
}
|
||
|
||
// fetchVersionFromURL 从指定 URL 拉取最新版本号(数组首元素的 version 字段)。
|
||
// 抽离 endpoint 参数以便单元测试注入 httptest 服务器。
|
||
func fetchVersionFromURL(ctx context.Context, client *http.Client, endpoint string) (string, error) {
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||
if err != nil {
|
||
return "", fmt.Errorf("build releases request: %w", err)
|
||
}
|
||
// 用真实客户端的 UA 模式拉取,使流量看起来像一次正常的更新检查。
|
||
req.Header.Set("User-Agent", fmt.Sprintf("antigravity-updater/%s %s/%s", currentUserAgentVersion(), runtime.GOOS, runtime.GOARCH))
|
||
req.Header.Set("Accept", "application/json")
|
||
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return "", fmt.Errorf("fetch releases: %w", err)
|
||
}
|
||
defer func() { _ = resp.Body.Close() }()
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
return "", fmt.Errorf("releases responded with status %d", resp.StatusCode)
|
||
}
|
||
|
||
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||
if err != nil {
|
||
return "", fmt.Errorf("read releases body: %w", err)
|
||
}
|
||
|
||
var entries []struct {
|
||
Version string `json:"version"`
|
||
}
|
||
if err := json.Unmarshal(body, &entries); err != nil {
|
||
return "", fmt.Errorf("decode releases body: %w", err)
|
||
}
|
||
for _, e := range entries {
|
||
v := strings.TrimSpace(e.Version)
|
||
if v != "" && isPlausibleAntigravityVersion(v) {
|
||
return v, nil
|
||
}
|
||
}
|
||
return "", errors.New("no version entries in releases response")
|
||
}
|
||
|
||
// isPlausibleAntigravityVersion 防御性检查:避免错误响应把 UA 污染成无效字符串。
|
||
// 形如 1.23.2、1.21.9、1.20.6;接受 2-4 段数字。
|
||
func isPlausibleAntigravityVersion(v string) bool {
|
||
parts := strings.Split(v, ".")
|
||
if len(parts) < 2 || len(parts) > 4 {
|
||
return false
|
||
}
|
||
for _, p := range parts {
|
||
if p == "" || len(p) > 5 {
|
||
return false
|
||
}
|
||
if _, err := strconv.Atoi(p); err != nil {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|