quintodrome.quineglobal.com/sites/hyper/http/updatecheck.go
2026-09-26 03:28:18 -08:00

191 lines
5.5 KiB
Go

package http
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"app/html"
)
// hazelUpdate is the response format expected by Electron autoUpdater
// (the same protocol used by the original releases.hyper.is / Hazel).
type hazelUpdate struct {
Name string `json:"name"`
Notes string `json:"notes,omitempty"`
PubDate string `json:"pub_date,omitempty"`
URL string `json:"url,omitempty"`
}
// UpdateCheck registers the auto-update feed endpoints.
//
// - GET /api/update-check (primary, matches existing expectations)
// - GET /update (convenience short path)
//
// This implements the update protocol used by Hyper's Electron autoUpdater
// (the same one originally served from releases.hyper.is via Hazel).
//
// Query params:
//
// version — client's current version, e.g. "4.0.0-q-canary.12" or "v4.0.0"
// platform — darwin | win32 | linux | deb (or os= for compatibility)
// arch — x64 | arm64
// channel — "stable" | "canary" (explicit preference; takes precedence for track selection)
//
// Response:
// - 204 No Content → no update available
// - 200 JSON → update available (Hazel/Squirrel format: {name, notes, pub_date, url?})
func (s *Server) UpdateCheck(r chi.Router) {
r.Get("/api/update-check", s.updateCheckHandler())
}
func (s *Server) updateCheckHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
rawVersion := strings.TrimPrefix(q.Get("version"), "v")
platform := firstNonEmpty(q.Get("platform"), q.Get("os"))
arch := q.Get("arch")
channel := strings.ToLower(q.Get("channel"))
if rawVersion == "" {
http.Error(w, "version is required", http.StatusBadRequest)
return
}
clientBase, clientBuild := parseTag(rawVersion)
clientIsCanary := clientBuild > 0 || channel == "canary" || channel == "canaryupdates"
// Map incoming platform names from the Hyper client to our internal asset keys.
internalOS := normalizePlatformForAssets(platform)
releases := s.releases.Get()
// Find the newest release that is newer than what the client has,
// on the correct track (stable vs canary), and preferably has assets
// for the requested platform/arch.
var candidate *html.Release
for _, rel := range releases {
if rel.IsCanary {
if clientIsCanary && newerCanary(rel, clientBase, clientBuild, true) {
candidate = &rel
break
}
// Non-canary client can still be offered canary if they opted in,
// but by default we only auto-offer canary to canary clients
// (matching original behavior of separate canary/stable feeds).
if !clientIsCanary && newerCanary(rel, clientBase, clientBuild, false) {
candidate = &rel
break
}
} else {
if !clientIsCanary && semverCmp(rel.Version, clientBase) > 0 {
candidate = &rel
break
}
}
}
if candidate == nil {
w.WriteHeader(http.StatusNoContent)
return
}
// Build the Hazel-compatible response.
resp := hazelUpdate{
Name: candidate.TagName,
Notes: fmt.Sprintf("Quine Hyper %s", candidate.TagName),
PubDate: candidate.PublishedAt.UTC().Format(time.RFC3339),
URL: candidate.DownloadURL(internalOS, arch),
}
// If we have no precise asset URL for this platform/arch, still tell the
// client there is an update (especially important for Linux where the
// client only uses the feed to learn *that* a new version exists).
if resp.URL == "" {
// Provide a generic GitHub releases URL as a fallback.
// The client already falls back to constructing a tag URL.
resp.URL = fmt.Sprintf("https://github.com/quine-global/hyper/releases/tag/%s", candidate.TagName)
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
s.log.Warn("Failed to encode update response", "error", err)
}
}
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
// normalizePlatformForAssets converts values the Hyper client sends
// (darwin, win32, deb, etc.) into the keys used in Release.Assets.
func normalizePlatformForAssets(p string) string {
switch strings.ToLower(p) {
case "darwin", "mac", "osx":
return "mac"
case "win32", "win", "windows":
return "windows"
case "linux", "appimage":
return "linux"
case "linux-deb", "deb":
return "linux-deb"
case "linux-rpm", "rpm":
return "linux-rpm"
case "linux-snap", "snap":
return "linux-snap"
case "linux-pacman", "pacman":
return "linux-pacman"
case "linux-flatpak", "flatpak":
return "linux-flatpak"
default:
return p
}
}
// newerCanary reports whether rel is newer than the client's current canary position.
// For a canary client, the build number is the tie-breaker on the same base version.
// For a stable client opting into canary info, any canary on the same or higher base qualifies.
func newerCanary(rel html.Release, clientBase string, clientBuild int, clientIsCanary bool) bool {
cmp := semverCmp(rel.Version, clientBase)
if cmp > 0 {
return true
}
if clientIsCanary {
return cmp == 0 && rel.CanaryBuild > clientBuild
}
// Stable client: same-base canary is ahead of the stable release.
return cmp == 0
}
// semverCmp compares two "major.minor.patch" version strings.
// Returns -1, 0, or 1.
func semverCmp(a, b string) int {
pa := strings.SplitN(a, ".", 3)
pb := strings.SplitN(b, ".", 3)
for i := 0; i < 3; i++ {
var na, nb int
if i < len(pa) {
na, _ = strconv.Atoi(pa[i])
}
if i < len(pb) {
nb, _ = strconv.Atoi(pb[i])
}
if na != nb {
if na < nb {
return -1
}
return 1
}
}
return 0
}