🏡 index : quine-global/hyper.quineglobal.com.git

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
}