package http
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"strconv"
"strings"
"sync"
"time"
"app/html"
)
const releasesAPI = "https://api.github.com/repos/quine-global/hyper/releases?per_page=30"
type ReleaseCache struct {
log *slog.Logger
mu sync.RWMutex
releases []html.Release
lastFetched time.Time
fetchMu sync.Mutex
}
func (c *ReleaseCache) Get() []html.Release {
c.mu.RLock()
defer c.mu.RUnlock()
if len(c.releases) == 0 {
return []html.Release{}
}
return c.releases
}
func (c *ReleaseCache) ForceRefresh(ctx context.Context) error {
c.fetchMu.Lock()
defer c.fetchMu.Unlock()
releases, err := fetchReleases(ctx)
if err != nil {
c.log.Warn("Force refresh failed", "error", err)
return err
}
if len(releases) == 0 {
return nil
}
c.mu.Lock()
c.releases = releases
c.lastFetched = time.Now()
c.mu.Unlock()
c.log.Info("Force-refreshed releases from GitHub", "count", len(releases))
return nil
}
func (c *ReleaseCache) TryRefresh(ctx context.Context, minAge time.Duration) {
if !c.fetchMu.TryLock() {
return
}
defer c.fetchMu.Unlock()
c.mu.RLock()
stale := time.Since(c.lastFetched) >= minAge
c.mu.RUnlock()
if !stale {
return
}
releases, err := fetchReleases(ctx)
if err != nil {
c.log.Warn("Failed to fetch releases from GitHub", "error", err)
return
}
if len(releases) == 0 {
return
}
c.mu.Lock()
c.releases = releases
c.lastFetched = time.Now()
c.mu.Unlock()
c.log.Info("Refreshed releases from GitHub", "count", len(releases))
}
type ghRelease struct {
TagName string `json:"tag_name"`
Prerelease bool `json:"prerelease"`
Draft bool `json:"draft"`
PublishedAt time.Time `json:"published_at"`
Assets []ghAsset `json:"assets"`
}
type ghAsset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
}
func fetchReleases(ctx context.Context) ([]html.Release, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, releasesAPI, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var raw []ghRelease
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return nil, err
}
releases := make([]html.Release, 0, len(raw))
for _, gr := range raw {
if gr.Draft {
continue
}
version, canaryBuild := parseTag(strings.TrimPrefix(gr.TagName, "v"))
r := html.Release{
TagName: gr.TagName,
Version: version,
CanaryBuild: canaryBuild,
Date: gr.PublishedAt.Format("Jan 2006"),
PublishedAt: gr.PublishedAt,
IsCanary: gr.Prerelease || canaryBuild > 0,
Assets: make(map[string]map[string]string),
}
for _, a := range gr.Assets {
os, arch, ok := classifyAsset(a.Name)
if !ok {
continue
}
if r.Assets[os] == nil {
r.Assets[os] = make(map[string]string)
}
if _, exists := r.Assets[os][arch]; !exists {
r.Assets[os][arch] = a.BrowserDownloadURL
}
}
releases = append(releases, r)
}
return releases, nil
}
func parseTag(tag string) (version string, canaryBuild int) {
const canaryMarker = "-q-canary."
if idx := strings.Index(tag, canaryMarker); idx != -1 {
if n, err := strconv.Atoi(tag[idx+len(canaryMarker):]); err == nil {
return tag[:idx], n
}
}
tag = strings.TrimSuffix(tag, "-q")
tag = strings.TrimSuffix(tag, "q")
return tag, 0
}
func classifyAsset(name string) (os, arch string, ok bool) {
l := strings.ToLower(name)
switch {
case strings.HasSuffix(l, ".exe"):
return "windows", "x64", true
case strings.Contains(l, "arm64") && strings.HasSuffix(l, ".dmg"):
return "mac", "arm64", true
case strings.HasSuffix(l, ".dmg"):
return "mac", "x64", true
case strings.Contains(l, "arm64") && strings.HasSuffix(l, ".appimage"):
return "linux", "arm64", true
case strings.Contains(l, "armv7l") && strings.HasSuffix(l, ".appimage"):
return "", "", false
case strings.HasSuffix(l, ".appimage"):
return "linux", "x64", true
case (strings.Contains(l, "arm64") || strings.Contains(l, "aarch64")) && strings.HasSuffix(l, ".rpm"):
return "linux-rpm", "arm64", true
case strings.Contains(l, "armv7l") && strings.HasSuffix(l, ".rpm"):
return "", "", false
case strings.HasSuffix(l, ".rpm"):
return "linux-rpm", "x64", true
case strings.Contains(l, "arm64") && strings.HasSuffix(l, ".deb"):
return "linux-deb", "arm64", true
case strings.Contains(l, "armv7l") && strings.HasSuffix(l, ".deb"):
return "", "", false
case strings.HasSuffix(l, ".deb"):
return "linux-deb", "x64", true
case strings.Contains(l, "armv7l") && strings.HasSuffix(l, ".snap"):
return "", "", false
case strings.HasSuffix(l, ".snap"):
return "linux-snap", "x64", true
case (strings.Contains(l, "arm64") || strings.Contains(l, "aarch64")) && strings.HasSuffix(l, ".pacman"):
return "linux-pacman", "arm64", true
case strings.Contains(l, "armv7l") && strings.HasSuffix(l, ".pacman"):
return "", "", false
case strings.HasSuffix(l, ".pacman"):
return "linux-pacman", "x64", true
case (strings.Contains(l, "arm64") || strings.Contains(l, "aarch64")) && strings.HasSuffix(l, ".flatpak"):
return "linux-flatpak", "arm64", true
case strings.Contains(l, "armv7l") && strings.HasSuffix(l, ".flatpak"):
return "", "", false
case strings.HasSuffix(l, ".flatpak"):
return "linux-flatpak", "x64", true
default:
return "", "", false
}
}