| package updatecheck |
|
|
| import ( |
| "context" |
| "encoding/json" |
| "errors" |
| "fmt" |
| "io" |
| "net/http" |
| "net/url" |
| "strconv" |
| "strings" |
| "sync" |
| "time" |
|
|
| "golang.org/x/sync/singleflight" |
| ) |
|
|
| const ( |
| latestReleaseAPI = "https://api.github.com/repos/chenyme/grok2api/releases/latest" |
| maxReleaseBytes = 1 << 20 |
| maxNotesRunes = 4096 |
| ) |
|
|
| type Status string |
|
|
| const ( |
| StatusUnchecked Status = "unchecked" |
| StatusUpToDate Status = "up_to_date" |
| StatusUpdateAvailable Status = "update_available" |
| StatusCheckFailed Status = "check_failed" |
| ) |
|
|
| type Snapshot struct { |
| CurrentVersion string `json:"currentVersion"` |
| LatestVersion string `json:"latestVersion"` |
| UpdateAvailable bool `json:"updateAvailable"` |
| Status Status `json:"status"` |
| CheckedAt *time.Time `json:"checkedAt"` |
| ReleaseURL string `json:"releaseUrl"` |
| ReleaseNotes string `json:"releaseNotes"` |
| Error string `json:"error"` |
| } |
|
|
| type Service struct { |
| current string |
| client *http.Client |
| now func() time.Time |
|
|
| mu sync.RWMutex |
| snapshot Snapshot |
| checks singleflight.Group |
| } |
|
|
| func NewService(currentVersion string, client *http.Client) *Service { |
| currentVersion = strings.TrimSpace(currentVersion) |
| if currentVersion == "" { |
| currentVersion = "dev" |
| } |
| if client == nil { |
| client = &http.Client{Timeout: 10 * time.Second} |
| } |
| return &Service{ |
| current: currentVersion, |
| client: client, |
| now: time.Now, |
| snapshot: Snapshot{ |
| CurrentVersion: currentVersion, |
| Status: StatusUnchecked, |
| }, |
| } |
| } |
|
|
| func (s *Service) Snapshot() Snapshot { |
| s.mu.RLock() |
| defer s.mu.RUnlock() |
| return cloneSnapshot(s.snapshot) |
| } |
|
|
| func (s *Service) Check(ctx context.Context) Snapshot { |
| result, err, _ := s.checks.Do("latest", func() (any, error) { |
| return s.fetchLatest(ctx) |
| }) |
| s.mu.Lock() |
| defer s.mu.Unlock() |
| if err != nil { |
| s.snapshot.Status = StatusCheckFailed |
| s.snapshot.Error = err.Error() |
| return cloneSnapshot(s.snapshot) |
| } |
| release := result.(latestRelease) |
| checkedAt := s.now().UTC() |
| current, currentOK := parseSemanticVersion(s.current) |
| latest, latestOK := parseSemanticVersion(release.Tag) |
| if !currentOK || !latestOK { |
| s.snapshot.LatestVersion = release.Tag |
| s.snapshot.ReleaseURL = release.URL |
| s.snapshot.ReleaseNotes = release.Notes |
| s.snapshot.Status = StatusCheckFailed |
| s.snapshot.Error = "当前版本或最新版本不是有效的语义化版本,无法比较" |
| return cloneSnapshot(s.snapshot) |
| } |
| available := compareSemanticVersion(latest, current) > 0 |
| s.snapshot = Snapshot{ |
| CurrentVersion: s.current, LatestVersion: release.Tag, |
| UpdateAvailable: available, CheckedAt: &checkedAt, |
| ReleaseURL: release.URL, ReleaseNotes: release.Notes, |
| Status: StatusUpToDate, |
| } |
| if available { |
| s.snapshot.Status = StatusUpdateAvailable |
| } |
| return cloneSnapshot(s.snapshot) |
| } |
|
|
| type latestRelease struct { |
| Tag string |
| URL string |
| Notes string |
| } |
|
|
| func (s *Service) fetchLatest(ctx context.Context) (latestRelease, error) { |
| request, err := http.NewRequestWithContext(ctx, http.MethodGet, latestReleaseAPI, nil) |
| if err != nil { |
| return latestRelease{}, err |
| } |
| request.Header.Set("Accept", "application/vnd.github+json") |
| request.Header.Set("User-Agent", "grok2api/"+s.current) |
| request.Header.Set("X-GitHub-Api-Version", "2022-11-28") |
| response, err := s.client.Do(request) |
| if err != nil { |
| return latestRelease{}, fmt.Errorf("检查 GitHub Release 失败: %w", err) |
| } |
| defer response.Body.Close() |
| if response.StatusCode != http.StatusOK { |
| return latestRelease{}, fmt.Errorf("GitHub Release 检查失败(HTTP %d)", response.StatusCode) |
| } |
| data, err := io.ReadAll(io.LimitReader(response.Body, maxReleaseBytes+1)) |
| if err != nil { |
| return latestRelease{}, fmt.Errorf("读取 GitHub Release 响应: %w", err) |
| } |
| if len(data) > maxReleaseBytes { |
| return latestRelease{}, errors.New("GitHub Release 响应超过安全上限") |
| } |
| var payload struct { |
| Tag string `json:"tag_name"` |
| Body string `json:"body"` |
| } |
| if err := json.Unmarshal(data, &payload); err != nil { |
| return latestRelease{}, fmt.Errorf("解析 GitHub Release 响应: %w", err) |
| } |
| payload.Tag = strings.TrimSpace(payload.Tag) |
| if payload.Tag == "" { |
| return latestRelease{}, errors.New("GitHub Release 未返回版本号") |
| } |
| return latestRelease{ |
| Tag: payload.Tag, |
| URL: "https://github.com/chenyme/grok2api/releases/tag/" + url.PathEscape(payload.Tag), |
| Notes: truncateRunes(strings.TrimSpace(payload.Body), maxNotesRunes), |
| }, nil |
| } |
|
|
| type semanticVersion struct { |
| major, minor, patch uint64 |
| prerelease string |
| } |
|
|
| func parseSemanticVersion(value string) (semanticVersion, bool) { |
| value = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(value), "v")) |
| if before, _, ok := strings.Cut(value, "+"); ok { |
| value = before |
| } |
| prerelease := "" |
| if before, after, ok := strings.Cut(value, "-"); ok { |
| value, prerelease = before, after |
| } |
| parts := strings.Split(value, ".") |
| if len(parts) != 3 { |
| return semanticVersion{}, false |
| } |
| numbers := make([]uint64, 3) |
| for index, part := range parts { |
| if part == "" || (len(part) > 1 && part[0] == '0') { |
| return semanticVersion{}, false |
| } |
| value, err := strconv.ParseUint(part, 10, 64) |
| if err != nil { |
| return semanticVersion{}, false |
| } |
| numbers[index] = value |
| } |
| return semanticVersion{major: numbers[0], minor: numbers[1], patch: numbers[2], prerelease: prerelease}, true |
| } |
|
|
| func compareSemanticVersion(left, right semanticVersion) int { |
| for _, pair := range [][2]uint64{{left.major, right.major}, {left.minor, right.minor}, {left.patch, right.patch}} { |
| if pair[0] < pair[1] { |
| return -1 |
| } |
| if pair[0] > pair[1] { |
| return 1 |
| } |
| } |
| if left.prerelease == right.prerelease { |
| return 0 |
| } |
| leftHotfix, leftHotfixNumber := projectHotfix(left.prerelease) |
| rightHotfix, rightHotfixNumber := projectHotfix(right.prerelease) |
| if leftHotfix && rightHotfix { |
| if leftHotfixNumber < rightHotfixNumber { |
| return -1 |
| } |
| if leftHotfixNumber > rightHotfixNumber { |
| return 1 |
| } |
| return 0 |
| } |
| if leftHotfix { |
| return 1 |
| } |
| if rightHotfix { |
| return -1 |
| } |
| if left.prerelease == "" { |
| return 1 |
| } |
| if right.prerelease == "" { |
| return -1 |
| } |
| return strings.Compare(left.prerelease, right.prerelease) |
| } |
|
|
| func projectHotfix(value string) (bool, uint64) { |
| const prefix = "hotfix." |
| if !strings.HasPrefix(value, prefix) { |
| return false, 0 |
| } |
| part := strings.TrimPrefix(value, prefix) |
| if part == "" || (len(part) > 1 && part[0] == '0') { |
| return false, 0 |
| } |
| number, err := strconv.ParseUint(part, 10, 64) |
| if err != nil { |
| return false, 0 |
| } |
| return true, number |
| } |
|
|
| func truncateRunes(value string, limit int) string { |
| runes := []rune(value) |
| if len(runes) <= limit { |
| return value |
| } |
| return string(runes[:limit]) |
| } |
|
|
| func cloneSnapshot(value Snapshot) Snapshot { |
| if value.CheckedAt != nil { |
| checkedAt := *value.CheckedAt |
| value.CheckedAt = &checkedAt |
| } |
| return value |
| } |
|
|