hyper.quineglobal.com/html/download.go
2026-09-22 00:13:54 -08:00

417 lines
13 KiB
Go

package html
import (
"fmt"
"strings"
"time"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
// Release is a single GitHub release with per-platform download URLs.
type Release struct {
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
}
// DownloadURL returns the asset URL for the given OS and arch, or "" if unavailable.
func (r Release) DownloadURL(os, arch string) string {
if m := r.Assets[os]; m != nil {
return m[arch]
}
return ""
}
// DisplayVersion returns a human-readable version string.
// Canary builds render as "4.0.0 Canary #12".
func (r Release) DisplayVersion() string {
if r.CanaryBuild > 0 {
return fmt.Sprintf("%s Canary #%d", r.Version, r.CanaryBuild)
}
return r.Version
}
func buildFallback(version, date string, published time.Time, canary bool) Release {
assets := make(map[string]map[string]string)
for _, os := range []string{"mac", "windows", "linux"} {
assets[os] = make(map[string]string)
for _, arch := range []string{"arm64", "x64"} {
assets[os][arch] = fallbackURL(version, os, arch)
}
}
tag := "v" + version
if canary {
tag = "v" + version + "-q-canary.1" // placeholder build
}
return Release{
TagName: tag,
Version: version,
Date: date,
PublishedAt: published,
IsCanary: canary,
Assets: assets,
}
}
func fallbackURL(version, os, arch string) string {
switch os {
case "windows":
return fmt.Sprintf("https://github.com/quine-global/hyper/releases/download/v%s/QuineHyper.Setup.%s.exe", version, version)
case "linux":
if arch == "arm64" {
return fmt.Sprintf("https://github.com/quine-global/hyper/releases/download/v%s/QuineHyper-%s-arm64.AppImage", version, version)
}
return fmt.Sprintf("https://github.com/quine-global/hyper/releases/download/v%s/QuineHyper-%s.AppImage", version, version)
default: // mac
if arch == "arm64" {
return fmt.Sprintf("https://github.com/quine-global/hyper/releases/download/v%s/QuineHyper-%s-arm64.dmg", version, version)
}
return fmt.Sprintf("https://github.com/quine-global/hyper/releases/download/v%s/QuineHyper-%s.dmg", version, version)
}
}
// DownloadProps carries platform selection and release list for the download page.
type DownloadProps struct {
DetectedOS string
DetectedArch string
SelectedOS string
SelectedArch string
ArchExplicit bool // true if arch came from an explicit ?arch= query param rather than auto-detection
Releases []Release
}
type platformOption struct {
OS string
Arch string
Label string
}
var platforms = []platformOption{
{"mac", "arm64", "macOS · Apple Silicon"},
{"mac", "x64", "macOS · Intel"},
{"windows", "x64", "Windows · x64"},
{"linux", "x64", "Linux · x64"},
{"linux", "arm64", "Linux · ARM64"},
}
func fileExt(os string) string {
switch os {
case "windows":
return ".exe"
case "linux":
return ".AppImage"
case "linux-deb":
return ".deb"
case "linux-rpm":
return ".rpm"
case "linux-snap":
return ".snap"
case "linux-pacman":
return ".pacman"
case "linux-flatpak":
return ".flatpak"
default:
return ".dmg"
}
}
func platformLabel(os, arch string) string {
effectiveOS := os
if os == "linux-rpm" {
effectiveOS = "linux"
}
for _, p := range platforms {
if p.OS == effectiveOS && p.Arch == arch {
return p.Label
}
}
return effectiveOS + " · " + arch
}
// DownloadPage renders the download page with OS/arch selection and version list.
func DownloadPage(props PageProps, dl DownloadProps) Node {
props.Title = "Download — Quine Hyper"
props.Description = "Download Quine Hyper for macOS, Windows, or Linux."
// Split into stable and canary.
var stable, canary []Release
for _, r := range dl.Releases {
if r.IsCanary {
canary = append(canary, r)
} else {
stable = append(stable, r)
}
}
content := []Node{
If(dl.SelectedOS == "mac" && !dl.ArchExplicit, macArchDetectScript()),
H1(Class("font-mono text-3xl font-bold text-white"), Text("Download Quine Hyper")),
Div(Class("mt-8"),
P(Class("font-mono text-xs uppercase tracking-widest text-zinc-500 mb-3"), Text("Platform")),
Div(Class("flex flex-wrap gap-2"),
Group(platformButtons(dl)),
),
If(strings.HasPrefix(dl.SelectedOS, "linux"),
linuxFormatToggle(dl),
),
),
}
if len(stable) > 0 {
content = append(content, stableSection(stable, dl))
}
if len(canary) > 0 {
content = append(content, canarySection(canary, dl))
}
content = append(content,
Div(Class("mt-6 text-center"),
A(
Href("https://github.com/quine-global/hyper/releases"),
Class("font-mono text-xs text-zinc-500 hover:text-white transition-colors underline underline-offset-4"),
Text("All releases on GitHub ↗"),
),
),
)
return page(props,
Div(Class("mx-auto max-w-xl py-16"),
Group(content),
),
)
}
func stableSection(stable []Release, dl DownloadProps) Node {
latest := stable[0]
older := stable[1:]
url := latest.DownloadURL(dl.SelectedOS, dl.SelectedArch)
return Group{
Div(Class("mt-10"),
Div(Class("mb-4 flex items-center gap-3"),
P(Class("font-mono text-xs uppercase tracking-widest text-seafoam-400"), Text("Stable releases")),
),
// Primary download card
Div(Class("min-h-[200px] flex items-center rounded-xl border border-seafoam-400/30 bg-zinc-900 p-6"),
Div(Class("flex w-full items-center justify-between gap-4 flex-wrap"),
Div(
Span(Class("font-mono text-xs font-semibold uppercase tracking-widest text-seafoam-400"),
Text("Latest Stable"),
),
H2(Class("mt-1 font-mono text-xl font-bold text-white"),
Text("Quine Hyper v"+latest.DisplayVersion()),
),
P(Class("mt-1 font-mono text-xs text-zinc-500"),
Text(latest.Date+" · "+platformLabel(dl.SelectedOS, dl.SelectedArch)+fileExt(dl.SelectedOS)),
),
),
Div(Class("flex-1")),
downloadButton(url, "bg-seafoam-400 hover:bg-seafoam-500 text-black"),
),
),
),
// Older stable releases
If(len(older) > 0,
Div(Class("mt-8"),
P(Class("font-mono text-xs uppercase tracking-widest text-zinc-500 mb-4"), Text("Older releases")),
Div(Class("divide-y divide-zinc-800 rounded-xl border border-zinc-800"),
Group(releaseRows(older, dl, "text-seafoam-400 hover:text-seafoam-500")),
),
),
),
}
}
func canarySection(canary []Release, dl DownloadProps) Node {
latest := canary[0]
older := canary[1:]
url := latest.DownloadURL(dl.SelectedOS, dl.SelectedArch)
return Div(Class("mt-10"),
Div(Class("mb-4 flex items-center gap-3"),
P(Class("font-mono text-xs uppercase tracking-widest text-canary-500"), Text("Canary releases")),
Span(Class("font-mono text-xs text-zinc-600"), Text("— unstable, may contain bugs")),
),
// Latest canary card
Div(Class("rounded-xl border border-canary-500/30 bg-zinc-900 p-6"),
Div(Class("flex items-center justify-between gap-4 flex-wrap"),
Div(
Span(Class("font-mono text-xs font-semibold uppercase tracking-widest text-canary-400"),
Text("Latest Canary"),
),
H2(Class("mt-1 font-mono text-base font-bold text-white"),
Text("Quine Hyper v"+latest.DisplayVersion()),
),
P(Class("mt-1 font-mono text-xs text-zinc-500"),
Text(latest.Date+" · "+platformLabel(dl.SelectedOS, dl.SelectedArch)+fileExt(dl.SelectedOS)),
),
),
downloadButton(url, "bg-canary-500 hover:bg-canary-400 text-black", "py-2 text-xs"),
),
),
// Older canary releases
If(len(older) > 0,
Div(Class("mt-4 divide-y divide-zinc-800 rounded-xl border border-zinc-800"),
Group(releaseRows(older, dl, "text-canary-400 hover:text-canary-300")),
),
),
)
}
func downloadButton(url, colorCls string, extraCls ...string) Node {
base := "inline-flex items-center justify-center gap-2 rounded-lg w-[135px] font-mono font-semibold transition-colors shrink-0 "
sizeCls := "px-6 py-3 text-sm"
if len(extraCls) > 0 && extraCls[0] != "" {
sizeCls = extraCls[0]
}
btnCls := base + sizeCls + " " + colorCls
unavailableCls := base + sizeCls + " border border-zinc-800 text-zinc-600"
if url == "" {
return Span(Class(unavailableCls), Text("Not available"))
}
return A(
Href(url),
Class(btnCls),
Text("↓ Download"),
)
}
func releaseRows(releases []Release, dl DownloadProps, linkCls string) []Node {
nodes := make([]Node, len(releases))
for i, r := range releases {
url := r.DownloadURL(dl.SelectedOS, dl.SelectedArch)
var link Node
if url != "" {
link = A(
Href(url),
Class("font-mono text-xs transition-colors "+linkCls),
Text("↓ "+platformLabel(dl.SelectedOS, dl.SelectedArch)+fileExt(dl.SelectedOS)),
)
} else {
link = Span(
Class("font-mono text-xs text-zinc-600"),
Text("Not available"),
)
}
nodes[i] = Div(Class("flex items-center justify-between px-5 py-4"),
Div(Class("flex items-center gap-4"),
Span(Class("font-mono text-sm font-semibold text-white"), Text("v"+r.DisplayVersion())),
Span(Class("font-mono text-xs text-zinc-500"), Text(r.Date)),
),
link,
)
}
return nodes
}
func platformButtons(dl DownloadProps) []Node {
nodes := make([]Node, len(platforms))
// Treat linux-rpm as linux for platform button highlight/detection purposes.
effectiveSelectedOS := dl.SelectedOS
if strings.HasPrefix(dl.SelectedOS, "linux") {
effectiveSelectedOS = "linux"
}
for i, p := range platforms {
isSelected := p.OS == effectiveSelectedOS && p.Arch == dl.SelectedArch
isDetected := p.OS == dl.DetectedOS && p.Arch == dl.DetectedArch
// Preserve the linux format when switching arch; snap is x64-only so don't carry it to arm64.
targetOS := p.OS
if p.OS == "linux" && strings.HasPrefix(dl.SelectedOS, "linux-") {
if dl.SelectedOS != "linux-snap" || p.Arch == "x64" {
targetOS = dl.SelectedOS
}
}
var cls string
if isSelected {
cls = "inline-flex items-center gap-1.5 rounded-md bg-primary-500 px-3 py-1.5 font-mono text-xs font-semibold text-black"
} else {
cls = "inline-flex items-center gap-1.5 rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 font-mono text-xs text-zinc-300 hover:border-zinc-500 hover:text-white transition-colors"
}
children := []Node{Text(p.Label)}
if isDetected && !isSelected {
children = append(children,
Span(Class("rounded bg-zinc-700 px-1 py-0.5 text-zinc-400 text-[10px]"), Text("auto")),
)
} else if isDetected && isSelected {
children = append(children,
Span(Class("rounded bg-black/20 px-1 py-0.5 text-[10px]"), Text("auto")),
)
}
nodes[i] = A(
Href(fmt.Sprintf("/download?os=%s&arch=%s", targetOS, p.Arch)),
Class(cls),
Group(children),
)
}
return nodes
}
// macArchDetectScript corrects the arm64 default guess on real Intel Macs.
// Browser User-Agent strings report "Intel Mac OS X" even on Apple Silicon, so
// the server can't tell arm64 from x64 from headers alone. The WebGL renderer
// string is the one client-side signal that actually differs: Apple Silicon
// reports something containing "Apple" (e.g. "Apple M2"); real Intel/AMD/Nvidia
// GPUs never do. If it doesn't look like Apple Silicon, reload with ?arch=x64.
func macArchDetectScript() Node {
return Script(Raw(`(function(){
try {
var c = document.createElement('canvas');
var gl = c.getContext('webgl') || c.getContext('experimental-webgl');
if (!gl) return;
var ext = gl.getExtension('WEBGL_debug_renderer_info');
if (!ext) return;
var renderer = String(gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) || '');
if (!renderer || /Apple/i.test(renderer)) return;
var url = new URL(window.location.href);
url.searchParams.set('arch', 'x64');
window.location.replace(url.toString());
} catch (e) {}
})();`))
}
func linuxFormatToggle(dl DownloadProps) Node {
selectedCls := "inline-flex items-center rounded-md bg-primary-500 px-3 py-1.5 font-mono text-xs font-semibold text-black"
unselectedCls := "inline-flex items-center rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 font-mono text-xs text-zinc-300 hover:border-zinc-500 hover:text-white transition-colors"
cls := func(targetOS string) string {
if dl.SelectedOS == targetOS {
return selectedCls
}
return unselectedCls
}
href := func(targetOS string) string {
return fmt.Sprintf("/download?os=%s&arch=%s", targetOS, dl.SelectedArch)
}
buttons := []Node{
A(Href(href("linux")), Class(cls("linux")), Text("AppImage")),
A(Href(href("linux-deb")), Class(cls("linux-deb")), Text("DEB")),
A(Href(href("linux-rpm")), Class(cls("linux-rpm")), Text("RPM")),
A(Href(href("linux-pacman")), Class(cls("linux-pacman")), Text("Pacman")),
A(Href(href("linux-flatpak")), Class(cls("linux-flatpak")), Text("Flatpak")),
}
if dl.SelectedArch == "x64" {
buttons = append(buttons, A(Href(href("linux-snap")), Class(cls("linux-snap")), Text("Snap")))
}
return Div(Class("mt-4"),
P(Class("font-mono text-xs uppercase tracking-widest text-zinc-500 mb-3"), Text("Format")),
Div(Class("flex items-center gap-2"),
Group(buttons),
),
)
}