feat: support Hyper auto-update feed protocol via query params (#1)
Implements the Hazel/Squirrel-style update endpoint expected by the
Hyper Electron app (and the custom Linux updater).
- Endpoint at /api/update-check and /update
- Accepts query params: version, platform (or os), arch, channel
- Returns 204 or the classic {name, notes, pub_date, url?} JSON
- Supports explicit channel=canary so stable-installed clients can opt in
- Updated Release model to carry TagName and PublishedAt for proper responses
This pairs with the corresponding client change in the hyper repo
(branch: update-endpoint-quine).
This commit is contained in:
parent
f5e53841b6
commit
fcedfa754d
5 changed files with 153 additions and 49 deletions
|
|
@ -2,6 +2,7 @@ package html
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
|
|
@ -9,9 +10,11 @@ import (
|
|||
|
||||
// Release is a single GitHub release with per-platform download URLs.
|
||||
type Release struct {
|
||||
Version string
|
||||
CanaryBuild int // >0 for canary, e.g. 12 from "v4.0.0-q-canary.12"
|
||||
Date string
|
||||
TagName string // Full GitHub tag, e.g. "v4.0.0-q-canary.12"
|
||||
Version string // Base semver, e.g. "4.0.0"
|
||||
CanaryBuild int // >0 for canary, e.g. 12 from "v4.0.0-q-canary.12"
|
||||
Date string // Human date for UI, e.g. "May 2026"
|
||||
PublishedAt time.Time // For update feed pub_date
|
||||
IsCanary bool
|
||||
Assets map[string]map[string]string // os -> arch -> URL
|
||||
}
|
||||
|
|
@ -48,7 +51,18 @@ func buildFallback(version, date string, canary bool) Release {
|
|||
assets[os][arch] = fallbackURL(version, os, arch)
|
||||
}
|
||||
}
|
||||
return Release{Version: version, Date: date, IsCanary: canary, Assets: assets}
|
||||
tag := "v" + version
|
||||
if canary {
|
||||
tag = "v" + version + "-q-canary.1" // placeholder build
|
||||
}
|
||||
return Release{
|
||||
TagName: tag,
|
||||
Version: version,
|
||||
Date: date,
|
||||
PublishedAt: time.Now().AddDate(0, -1, 0), // rough fallback
|
||||
IsCanary: canary,
|
||||
Assets: assets,
|
||||
}
|
||||
}
|
||||
|
||||
func fallbackURL(version, os, arch string) string {
|
||||
|
|
|
|||
|
|
@ -130,9 +130,11 @@ func fetchReleases(ctx context.Context) ([]html.Release, error) {
|
|||
}
|
||||
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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,4 +23,8 @@ func (s *Server) setupRoutes() {
|
|||
|
||||
s.Home(r)
|
||||
})
|
||||
|
||||
// Also expose the update feed at the short /update path for convenient
|
||||
// feed URLs (e.g. https://hyper.quineglobal.com/update?platform=...&version=...)
|
||||
s.mux.Get("/update", s.updateCheckHandler())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,90 +2,145 @@ package http
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"app/html"
|
||||
)
|
||||
|
||||
type updateCheckResponse struct {
|
||||
HasUpdate bool `json:"has_update"`
|
||||
Stable *releaseUpdate `json:"stable,omitempty"`
|
||||
Canary *releaseUpdate `json:"canary,omitempty"`
|
||||
}
|
||||
|
||||
type releaseUpdate struct {
|
||||
Version string `json:"version"`
|
||||
Build int `json:"build,omitempty"` // canary only
|
||||
Date string `json:"date"`
|
||||
// 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 GET /api/update-check.
|
||||
// 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 — current client version, e.g. "4.0.0" or "4.0.0-q-canary.12" (v-prefix stripped)
|
||||
// os — mac | windows | linux | linux-rpm
|
||||
// arch — x64 | arm64
|
||||
// canary — "1" to also include canary update info (implied when client is already on canary)
|
||||
// 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", func(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
rawVersion := strings.TrimPrefix(q.Get("version"), "v")
|
||||
os := q.Get("os")
|
||||
arch := q.Get("arch")
|
||||
r.Get("/api/update-check", s.updateCheckHandler())
|
||||
}
|
||||
|
||||
if rawVersion == "" || os == "" || arch == "" {
|
||||
http.Error(w, "version, os, and arch are required", http.StatusBadRequest)
|
||||
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
|
||||
wantCanary := clientIsCanary || q.Get("canary") == "1"
|
||||
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()
|
||||
|
||||
var stableUpdate, canaryUpdate *releaseUpdate
|
||||
// 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 {
|
||||
// Releases arrive newest-first from GitHub; take the first match on each track.
|
||||
if rel.IsCanary {
|
||||
if wantCanary && canaryUpdate == nil && newerCanary(rel, clientBase, clientBuild, clientIsCanary) {
|
||||
canaryUpdate = makeUpdate(rel, os, arch)
|
||||
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 stableUpdate == nil && semverCmp(rel.Version, clientBase) > 0 {
|
||||
stableUpdate = makeUpdate(rel, os, arch)
|
||||
if !clientIsCanary && semverCmp(rel.Version, clientBase) > 0 {
|
||||
candidate = &rel
|
||||
break
|
||||
}
|
||||
}
|
||||
if stableUpdate != nil && (!wantCanary || canaryUpdate != nil) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
resp := updateCheckResponse{
|
||||
HasUpdate: stableUpdate != nil || canaryUpdate != nil,
|
||||
Stable: stableUpdate,
|
||||
Canary: canaryUpdate,
|
||||
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-check response", "error", err)
|
||||
s.log.Warn("Failed to encode update response", "error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func makeUpdate(rel html.Release, os, arch string) *releaseUpdate {
|
||||
return &releaseUpdate{
|
||||
Version: rel.Version,
|
||||
Build: rel.CanaryBuild,
|
||||
Date: rel.Date,
|
||||
URL: rel.DownloadURL(os, arch),
|
||||
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", "deb", "appimage":
|
||||
return "linux"
|
||||
case "linux-rpm", "rpm":
|
||||
return "linux-rpm"
|
||||
default:
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -54,3 +54,32 @@ func TestNewerCanary(t *testing.T) {
|
|||
is.True(t, !newerCanary(rel("3.9.0", 99), "4.0.0", 0, false))
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizePlatformForAssets(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"darwin", "mac"},
|
||||
{"Darwin", "mac"},
|
||||
{"mac", "mac"},
|
||||
{"win32", "windows"},
|
||||
{"windows", "windows"},
|
||||
{"deb", "linux"},
|
||||
{"linux", "linux"},
|
||||
{"linux-rpm", "linux-rpm"},
|
||||
{"rpm", "linux-rpm"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.in, func(t *testing.T) {
|
||||
is.Equal(t, c.want, normalizePlatformForAssets(c.in))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstNonEmpty(t *testing.T) {
|
||||
is.Equal(t, "a", firstNonEmpty("a", "b"))
|
||||
is.Equal(t, "b", firstNonEmpty("", "b"))
|
||||
is.Equal(t, "", firstNonEmpty("", ""))
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue