mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 20:52:51 +00:00
daef38dd3c
GitHub's release list is now ~9.6 MB because every 6.4.x release embeds
349 assets (~600 KB of JSON each). The update check buffered the whole
response behind a 1 MiB bound, so the primary path always failed and fell
through to the Atom feed. That feed holds ten entries and each release now
spends three of them, so stable-channel checks on main failed outright
("no suitable release found for channel stable") and 6.4.5 stable would
have shipped relying on it.
Decode the release list and the single-release notes response as a token
stream, keeping only tag, name, body, draft, prerelease, publish time and
the Pulse linux server archives. Memory now tracks what the updater keeps,
so the byte bound rises to 64 MiB while staying typed; the Atom feed stays
as the last resort for that over-limit condition only.
Verified against live GitHub: stable resolves v6.4.1 and rc resolves
v6.4.5-rc.3 through the primary path with no feed fallback.
125 lines
3.5 KiB
Go
125 lines
3.5 KiB
Go
package updates
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
|
|
)
|
|
|
|
// ErrReleaseNotFound indicates no published GitHub release exists for the
|
|
// requested version tag.
|
|
var ErrReleaseNotFound = errors.New("release not found")
|
|
|
|
const (
|
|
releaseNotesCacheDuration = 24 * time.Hour
|
|
releaseNotesMissCacheDuration = time.Hour
|
|
)
|
|
|
|
// ReleaseNotesInfo carries the release notes for a specific published version.
|
|
type ReleaseNotesInfo struct {
|
|
Version string `json:"version"`
|
|
ReleaseNotes string `json:"releaseNotes"`
|
|
ReleaseDate time.Time `json:"releaseDate"`
|
|
IsPrerelease bool `json:"isPrerelease"`
|
|
}
|
|
|
|
func resolveReleaseByTagURL(tag string) (*url.URL, error) {
|
|
baseURL, err := securityutil.NormalizeHTTPBaseURL(updateReleaseAPIBaseURL(), "https")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid update server base URL: %w", err)
|
|
}
|
|
|
|
target, err := securityutil.ResolveRelativeURL(baseURL, updateReleaseAPIPath()+"/tags/"+url.PathEscape(tag))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build release notes URL: %w", err)
|
|
}
|
|
|
|
return target, nil
|
|
}
|
|
|
|
// GetReleaseNotes fetches the GitHub release notes for a specific version tag.
|
|
// Results (including "no such release") are cached so repeated UI requests
|
|
// don't burn GitHub API quota.
|
|
func (m *Manager) GetReleaseNotes(ctx context.Context, version string) (*ReleaseNotesInfo, error) {
|
|
trimmed := strings.TrimSpace(version)
|
|
if trimmed == "" {
|
|
return nil, fmt.Errorf("version is required")
|
|
}
|
|
tag := "v" + strings.TrimPrefix(trimmed, "v")
|
|
|
|
m.statusMu.RLock()
|
|
if m.notesCacheTag == tag {
|
|
if m.notesCacheMiss && time.Since(m.notesCacheTime) < releaseNotesMissCacheDuration {
|
|
m.statusMu.RUnlock()
|
|
return nil, fmt.Errorf("%w: %s", ErrReleaseNotFound, tag)
|
|
}
|
|
if m.notesCache != nil && time.Since(m.notesCacheTime) < releaseNotesCacheDuration {
|
|
cached := m.notesCache
|
|
m.statusMu.RUnlock()
|
|
return cached, nil
|
|
}
|
|
}
|
|
m.statusMu.RUnlock()
|
|
|
|
target, err := resolveReleaseByTagURL(tag)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|
resp, err := m.getWithRetry(ctx, client, target, map[string]string{
|
|
"Accept": "application/vnd.github.v3+json",
|
|
"User-Agent": "Pulse-Update-Checker",
|
|
}, "fetch release notes")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch release notes: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
switch {
|
|
case resp.StatusCode == http.StatusNotFound:
|
|
m.cacheReleaseNotes(tag, nil)
|
|
return nil, fmt.Errorf("%w: %s", ErrReleaseNotFound, tag)
|
|
case resp.StatusCode == http.StatusForbidden:
|
|
return nil, fmt.Errorf("%w: fetching release notes for %s", errGitHubRateLimited, tag)
|
|
case resp.StatusCode != http.StatusOK:
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
detail := strings.TrimSpace(string(body))
|
|
if detail == "" {
|
|
detail = resp.Status
|
|
}
|
|
return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, detail)
|
|
}
|
|
|
|
release, err := decodeSingleRelease(resp)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode release notes: %w", err)
|
|
}
|
|
|
|
info := &ReleaseNotesInfo{
|
|
Version: strings.TrimPrefix(release.TagName, "v"),
|
|
ReleaseNotes: release.Body,
|
|
ReleaseDate: release.PublishedAt,
|
|
IsPrerelease: release.Prerelease,
|
|
}
|
|
m.cacheReleaseNotes(tag, info)
|
|
|
|
return info, nil
|
|
}
|
|
|
|
func (m *Manager) cacheReleaseNotes(tag string, info *ReleaseNotesInfo) {
|
|
m.statusMu.Lock()
|
|
m.notesCacheTag = tag
|
|
m.notesCache = info
|
|
m.notesCacheMiss = info == nil
|
|
m.notesCacheTime = time.Now()
|
|
m.statusMu.Unlock()
|
|
}
|