package version import ( "context" "encoding/json" "fmt" "io" "mime" "net/http" "strings" "time" "gitgud.io/mike/mpv-manager/pkg/installer" "gitgud.io/mike/mpv-manager/pkg/log" "gitgud.io/mike/mpv-manager/pkg/releasemanifest" ) // FetchReleaseInfo fetches the release manifest from ReleasesURL in a single // attempt. Prefer FetchReleaseInfoWithRetry at startup, where a transient // network or gateway failure is likely. func FetchReleaseInfo() (*installer.ReleaseInfo, error) { return FetchReleaseInfoContext(context.Background()) } // FetchReleaseInfoContext retrieves authenticated release metadata with caller cancellation. func FetchReleaseInfoContext(ctx context.Context) (*installer.ReleaseInfo, error) { return fetchReleaseInfoFrom(ctx, releaseFeedURL()) } // FetchReleaseInfoWithRetry fetches the release manifest, retrying failures // with a linear backoff (baseDelay * attempt), and returns the last error // once all attempts are exhausted. func FetchReleaseInfoWithRetry(attempts int, baseDelay time.Duration) (*installer.ReleaseInfo, error) { return FetchReleaseInfoWithRetryContext(context.Background(), attempts, baseDelay) } // FetchReleaseInfoWithRetryContext applies caller cancellation to every // manifest request and retry delay. func FetchReleaseInfoWithRetryContext(ctx context.Context, attempts int, baseDelay time.Duration) (*installer.ReleaseInfo, error) { return fetchReleaseInfoWithRetryFromContext(ctx, releaseFeedURL(), attempts, baseDelay) } // fetchReleaseInfoFrom fetches and parses the release manifest from url. // A body that parses but carries no version (e.g. a "{}" gateway error page) // is an error: an empty ReleaseInfo makes every later download fail with a // cryptic "unsupported protocol scheme" error. func fetchReleaseInfoFrom(ctx context.Context, url string) (*installer.ReleaseInfo, error) { client := &http.Client{Timeout: UpdateCheckTimeout} request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } resp, err := client.Do(request) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("HTTP %d", resp.StatusCode) } if contentType := strings.TrimSpace(resp.Header.Get("Content-Type")); contentType != "" { mediaType, _, err := mime.ParseMediaType(contentType) if err != nil || (mediaType != "application/json" && !strings.HasSuffix(mediaType, "+json")) { return nil, fmt.Errorf("unexpected release manifest content type %q", contentType) } } if resp.ContentLength > releasemanifest.MaxManifestBytes { return nil, fmt.Errorf("release manifest exceeds %d-byte limit", releasemanifest.MaxManifestBytes) } body, err := io.ReadAll(io.LimitReader(resp.Body, releasemanifest.MaxManifestBytes+1)) if err != nil { return nil, err } if len(body) > releasemanifest.MaxManifestBytes { return nil, fmt.Errorf("release manifest exceeds %d-byte limit", releasemanifest.MaxManifestBytes) } return decodeReleaseManifest(body) } func decodeReleaseManifest(body []byte) (*installer.ReleaseInfo, error) { var info installer.ReleaseInfo if err := json.Unmarshal(body, &info); err != nil { return nil, fmt.Errorf("decode release manifest: %w", err) } if err := verifyReleaseManifest(&info); err != nil { return nil, err } return &info, nil } func verifyReleaseManifest(info *installer.ReleaseInfo) error { if info == nil { return fmt.Errorf("release information is unavailable") } if info.Version == "" { return fmt.Errorf("release manifest is empty or invalid") } trustedKeys, err := releasemanifest.ParseTrustedKeys(ManifestPublicKeys) if err != nil { return fmt.Errorf("configure release manifest trust: %w", err) } if len(trustedKeys) == 0 { return fmt.Errorf("no trusted release manifest keys are configured") } if err := info.Verify(trustedKeys); err != nil { return fmt.Errorf("verify release manifest: %w", err) } if err := info.Validate(); err != nil { return err } if expected := releasemanifest.ChannelForVersion(CurrentVersion); info.Channel != expected { return fmt.Errorf("release manifest channel %q does not match this build channel %q", info.Channel, expected) } if err := enforceReleaseTrustLifecycle(info); err != nil { return fmt.Errorf("enforce release manifest trust lifecycle: %w", err) } return nil } func fetchReleaseInfoWithRetryFromContext(ctx context.Context, url string, attempts int, baseDelay time.Duration) (*installer.ReleaseInfo, error) { if attempts < 1 { attempts = 1 } var lastErr error for attempt := 1; attempt <= attempts; attempt++ { if err := ctx.Err(); err != nil { return nil, err } info, err := fetchReleaseInfoFrom(ctx, url) if err == nil { return info, nil } lastErr = err log.Error(fmt.Sprintf("Release info fetch attempt %d/%d failed: %s", attempt, attempts, err.Error())) if attempt < attempts { if err := waitForRetry(ctx, time.Duration(attempt)*baseDelay); err != nil { return nil, err } } } return nil, fmt.Errorf("failed to fetch release info after %d attempts: %w", attempts, lastErr) } // releaseFeedURL keeps RC builds off the public stable/legacy feed. func releaseFeedURL() string { if releasemanifest.ChannelForVersion(CurrentVersion) == releasemanifest.ReleaseCandidateChannel { return ReleaseCandidatesURL } return ReleasesURL }