win 9da079a5ee
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
x
2026-04-27 19:01:41 +08:00

198 lines
5.4 KiB
Go
Raw Permalink 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 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
}