Extract shared site framework into internal/site and internal/component

Reduces sites/quintodrome and sites/hyper to their genuinely site-specific
parts: a Config (branding, GitHub repo, Linux download-format list),
classifyAsset, and landing-page content/mockup. Everything else — the HTTP
server, routes, release cache, update-check protocol, and page chrome —
now lives once in internal/site and internal/component.

The Linux format toggle's button list, order, and per-format arch
availability is now driven entirely by Config.LinuxFormats, reproducing
each site's current behavior as data (quintodrome shows Snap on both
arches; hyper hides it on arm64) rather than as forked logic. The one
deliberate behavior change: platformLabel's "linux-*" normalization
(previously only applied to linux-rpm on hyper's side) now applies to all
linux-* formats, fixing a cosmetic label bug on hyper's deb/pacman/flatpak
download rows ("linux-deb · x64.deb" -> "Linux · x64.deb").

Also unifies top-level tooling: a single SITE-parameterized Makefile and
Dockerfile, a single tailwind.config.js scanning internal/** and sites/**,
and a shared public/ with both product logos.

Verified: go build/vet/test pass for both sites, and `make dev
SITE=quintodrome` / `SITE=hyper` were run and curl-checked against /,
/download (including each site's Linux format toggle and arch-filtering),
and /api/update-check, matching pre-refactor behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Philip Peterson 2026-09-26 03:55:36 -08:00
parent 79e9269673
commit 450d38c6da
60 changed files with 673 additions and 1984 deletions

View file

@ -1,17 +1,26 @@
# quintodrome-quineglobal-com # quineglobal-sites
Website and auto-update feed server for [Quintodrome](https://github.com/quine-global/quintodrome), a native desktop app that wraps [Navidrome](https://www.navidrome.org) (a self-hosted music server) for macOS, Windows, and Linux. Monorepo for Quine Global's website + auto-update feed servers: currently
[Quintodrome](https://github.com/quine-global/quintodrome) (a native desktop
app wrapping [Navidrome](https://www.navidrome.org)) and
[Quine Hyper](https://github.com/quine-global/hyper) (a fork of the Hyper
terminal). Both are the same server shape — release feed, download page,
Hazel/Squirrel-style auto-update API — with only branding, asset-naming
rules, and landing-page content differing per site.
## Commands ## Commands
All commands take `SITE=quintodrome` (default) or `SITE=hyper`.
```bash ```bash
make dev # start Go server + Tailwind watcher (Ctrl-C stops both) make dev # start Go server + Tailwind watcher for SITE (Ctrl-C stops both)
make start # Go server only make dev SITE=hyper # same, for the hyper site
make test # go test -shuffle on ./... make start # Go server only
make css # one-shot Tailwind build make test # go test -shuffle on ./... (covers every site)
make lint # golangci-lint make css # one-shot Tailwind build (shared across sites)
make build # Docker image (linux/amd64) make lint # golangci-lint
make push # build + push to forge.quinefoundation.com make build # Docker image for SITE (linux/amd64)
make push # build + push to forge.quinefoundation.com
``` ```
## Architecture ## Architecture
@ -19,27 +28,36 @@ make push # build + push to forge.quinefoundation.com
Pure Go HTTP server (`go-chi/chi`). HTML is rendered server-side with `maragu.dev/gomponents` (no templates). CSS is Tailwind, compiled to `public/styles/app.css`. Pure Go HTTP server (`go-chi/chi`). HTML is rendered server-side with `maragu.dev/gomponents` (no templates). CSS is Tailwind, compiled to `public/styles/app.css`.
``` ```
cmd/app/ entry point internal/site/ shared server framework: Server, routes, release cache, update-check protocol
http/ route handlers, server, release cache, update-check logic internal/component/ shared gomponents chrome: page/nav/footer, download page, FeatureCard, DeviceWindow
html/ gomponents page renderers and shared types (Release, DownloadProps, etc.) sites/quintodrome/ Quintodrome's Config, classifyAsset, landing-page content, cmd/main.go
public/ static assets (images, compiled CSS) sites/hyper/ Quine Hyper's Config, classifyAsset, landing-page content, cmd/main.go
scripts/ release refresh helper script public/ static assets shared by all sites (images, compiled CSS)
scripts/ release refresh helper script (shared, identical across sites)
``` ```
A site is defined entirely by `sites/<site>/site.go`'s `Config` value
(`internal/site.Config`, embedding `component.Branding` and
`component.DownloadConfig`) plus that package's `classifyAsset` and
`HomeContent`. Everything else — HTTP routing, the release cache, the
update-check handler, the download page's mechanics — is shared code in
`internal/site` and `internal/component`.
## Key concepts ## Key concepts
**Release cache** (`http/releases.go`): fetches GitHub releases from the `quine-global/quintodrome` repo via the GitHub API, caches them in memory. `TryRefresh` is debounced (called per-request, no-ops if cache is fresh or a fetch is already running). `ForceRefresh` is used by the internal `/internal/refresh` endpoint. **Release cache** (`internal/site/releases.go`): fetches GitHub releases from `Config.RepoOwner/Config.RepoName` via the GitHub API, caches them in memory per-server. `TryRefresh` is debounced (called per-request, no-ops if cache is fresh or a fetch is already running). `ForceRefresh` is used by the internal `/internal/refresh` endpoint.
**Asset classification** (`classifyAsset` in `http/releases.go`): maps GitHub asset filenames to `(os, arch)` keys used in `Release.Assets`. Supported platforms: `mac/{arm64,x64}` (`.dmg`), `windows/x64` (`.exe`), `linux/{arm64,x64}` (AppImage), `linux-rpm`, `linux-deb`, `linux-snap`, `linux-pacman`, `linux-flatpak` (each `{arm64,x64}`). Arch is detected from either `arm64` or `aarch64` in the filename. armv7l and other unrecognised ARM variants are explicitly excluded (return `ok=false`) so they don't get misclassified as x64. Quintodrome's actual releases currently ship `.dmg`/`.exe`/`.flatpak`/`.snap` only (no Linux AppImage/deb/rpm/pacman yet); `.msi` and `.app.zip` variants are intentionally left unclassified in favor of `.exe`/`.dmg`. The upstream repo also publishes `navidrome_*` server-binary assets (tar.gz/deb/rpm for the bundled Navidrome server itself) — those are unrelated to the desktop app and are not classified here. **Asset classification** (`sites/<site>/assets.go`): each site's own `classifyAsset` maps GitHub release asset filenames to `(os, arch)` keys used in `Release.Assets`, since real release artifact naming conventions differ per product (this is intentionally *not* shared logic). Supported OS keys: `mac`, `windows`, `linux` (AppImage), `linux-rpm`, `linux-deb`, `linux-snap`, `linux-pacman`, `linux-flatpak`. armv7l and other unrecognised ARM variants are explicitly excluded (return `ok=false`) rather than misclassified as x64.
**Auto-update feed** (`http/updatecheck.go`): implements a Hazel/Squirrel-style update protocol. Responds to `GET /api/update-check` and `GET /update` with 204 (no update) or 200 JSON `{name, notes, pub_date, url}`. Handles stable and canary tracks; platform strings from the client (`darwin`, `win32`, `deb`, etc.) are normalised via `normalizePlatformForAssets`. **Auto-update feed** (`internal/site/updatecheck.go`): implements a Hazel/Squirrel-style update protocol, shared by all sites. Responds to `GET /api/update-check` and `GET /update` with 204 (no update) or 200 JSON `{name, notes, pub_date, url}`. Handles stable and canary tracks; platform strings from the client (`darwin`, `win32`, `deb`, etc.) are normalised via `normalizePlatformForAssets`.
**Download page** (`http/home.go`, `html/download.go`): detects platform from `User-Agent`, supports manual override via `?os=` and `?arch=` query params. Linux defaults to the Flatpak format (`?os=linux-flatpak`) since that's what's actually published today; the format toggle also offers Snap, AppImage, DEB, RPM, and Pacman for forward compatibility. **Download page** (`internal/site/home.go`, `internal/component/download.go`): detects platform from `User-Agent` (Linux default comes from `Config.DefaultLinuxOS`), supports manual override via `?os=` and `?arch=` query params. The Linux format toggle's button list, order, and per-format architecture availability are entirely driven by `Config.LinuxFormats` — e.g. Quintodrome offers Flatpak first and ships Snap for both arm64/x64, while Hyper offers AppImage first and only ships Snap for x64 (hidden on arm64 automatically since the config declares it x64-only).
## Testing ## Testing
Tests live alongside source in `package http` (internal) or `package http_test` (integration). Run with `make test`. Notable test files: Tests live alongside source: `package site`/`site_test` in `internal/site`, `package quintodrome`/`hyper` in each site dir. Run with `make test`. Notable test files:
- `http/releases_test.go` — `classifyAsset` and `parseTag` - `sites/<site>/assets_test.go` — per-site `classifyAsset` cases
- `http/updatecheck_test.go` — `semverCmp`, `newerCanary`, `normalizePlatformForAssets` - `internal/site/releases_test.go` — shared `parseTag`
- `http/server_test.go` — integration test that starts the real server - `internal/site/updatecheck_test.go` — shared `semverCmp`, `newerCanary`, `normalizePlatformForAssets`
- `internal/site/server_test.go` — integration test that starts a real server against a minimal test `Config`

View file

@ -4,7 +4,8 @@ WORKDIR /app
RUN set -x && apt-get update && \ RUN set -x && apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y curl DEBIAN_FRONTEND=noninteractive apt-get install -y curl
COPY html ./html/ COPY internal ./internal/
COPY sites ./sites/
FROM golang AS gobuilder FROM golang AS gobuilder
WORKDIR /app WORKDIR /app
@ -15,7 +16,8 @@ RUN go mod download
COPY . ./ COPY . ./
ARG TARGETARCH ARG TARGETARCH
RUN GOOS=linux GOARCH=${TARGETARCH} go build -buildvcs=false -ldflags="-s -w" -o ./app ./cmd/app ARG SITE=quintodrome
RUN GOOS=linux GOARCH=${TARGETARCH} go build -buildvcs=false -ldflags="-s -w" -o ./app ./sites/${SITE}/cmd
FROM debian:stable-slim AS runner FROM debian:stable-slim AS runner
WORKDIR /app WORKDIR /app

View file

@ -1,14 +1,17 @@
# SITE selects which site under sites/ to build/run: quintodrome or hyper.
SITE ?= quintodrome
.PHONY: benchmark .PHONY: benchmark
benchmark: benchmark:
go test -bench=. go test -bench=.
.PHONY: build .PHONY: build
build: build:
docker build --platform linux/amd64 . -t forge.quinefoundation.com/ironmagma/quintodrome-quineglobal-com docker build --platform linux/amd64 --build-arg SITE=$(SITE) . -t forge.quinefoundation.com/ironmagma/$(SITE)-quineglobal-com
.PHONY: push .PHONY: push
push: build push: build
docker push forge.quinefoundation.com/ironmagma/quintodrome-quineglobal-com docker push forge.quinefoundation.com/ironmagma/$(SITE)-quineglobal-com
.PHONY: cover .PHONY: cover
cover: cover:
@ -24,7 +27,7 @@ css-watch:
.PHONY: dev .PHONY: dev
dev: dev:
@trap 'kill 0' SIGINT SIGTERM; npm run css:watch & go run ./cmd/app; wait @trap 'kill 0' SIGINT SIGTERM; npm run css:watch & go run ./sites/$(SITE)/cmd; wait
.PHONY: install .PHONY: install
install: install:
@ -36,7 +39,7 @@ lint:
.PHONY: start .PHONY: start
start: start:
go run ./cmd/app go run ./sites/$(SITE)/cmd
.PHONY: test .PHONY: test
test: test:

View file

@ -1,4 +1,4 @@
package html package component
import ( import (
"fmt" "fmt"
@ -47,6 +47,40 @@ type DownloadProps struct {
Releases []Release Releases []Release
} }
// LinuxFormat is one Linux packaging format a site offers on its download
// page (e.g. Flatpak, Snap, AppImage), and which architectures it ships for.
type LinuxFormat struct {
OS string // asset-classification key, e.g. "linux-flatpak"
Label string // button label, e.g. "Flatpak"
Archs []string // architectures this format is available for, e.g. []string{"x64"}
}
// DownloadConfig is the per-site knobs for the download page: which Linux
// format is assumed by default, and the ordered list of formats offered.
type DownloadConfig struct {
DefaultLinuxOS string // detectPlatform()'s Linux default, e.g. "linux-flatpak"
Formats []LinuxFormat
}
func (dc DownloadConfig) formatSupportsArch(os, arch string) bool {
for _, f := range dc.Formats {
if f.OS == os {
return containsString(f.Archs, arch)
}
}
// Not a tracked Linux format (e.g. plain "linux") — no arch restriction.
return true
}
func containsString(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
type platformOption struct { type platformOption struct {
OS string OS string
Arch string Arch string
@ -96,9 +130,9 @@ func platformLabel(os, arch string) string {
} }
// DownloadPage renders the download page with OS/arch selection and version list. // DownloadPage renders the download page with OS/arch selection and version list.
func DownloadPage(props PageProps, dl DownloadProps) Node { func DownloadPage(b Branding, dc DownloadConfig, props PageProps, dl DownloadProps) Node {
props.Title = "Download — Quintodrome" props.Title = "Download — " + b.Name
props.Description = "Download Quintodrome for macOS, Windows, or Linux." props.Description = "Download " + b.Name + " for macOS, Windows, or Linux."
// Split into stable and canary. // Split into stable and canary.
var stable, canary []Release var stable, canary []Release
@ -112,41 +146,41 @@ func DownloadPage(props PageProps, dl DownloadProps) Node {
content := []Node{ content := []Node{
If(dl.SelectedOS == "mac" && !dl.ArchExplicit, macArchDetectScript()), If(dl.SelectedOS == "mac" && !dl.ArchExplicit, macArchDetectScript()),
H1(Class("font-mono text-3xl font-bold text-white"), Text("Download Quintodrome")), H1(Class("font-mono text-3xl font-bold text-white"), Text("Download "+b.Name)),
Div(Class("mt-8"), Div(Class("mt-8"),
P(Class("font-mono text-xs uppercase tracking-widest text-zinc-500 mb-3"), Text("Platform")), P(Class("font-mono text-xs uppercase tracking-widest text-zinc-500 mb-3"), Text("Platform")),
Div(Class("flex flex-wrap gap-2"), Div(Class("flex flex-wrap gap-2"),
Group(platformButtons(dl)), Group(platformButtons(dc, dl)),
), ),
If(strings.HasPrefix(dl.SelectedOS, "linux"), If(strings.HasPrefix(dl.SelectedOS, "linux"),
linuxFormatToggle(dl), linuxFormatToggle(dc, dl),
), ),
), ),
} }
if len(stable) > 0 { if len(stable) > 0 {
content = append(content, stableSection(stable, dl)) content = append(content, stableSection(b, stable, dl))
} }
if len(canary) > 0 { if len(canary) > 0 {
content = append(content, canarySection(canary, dl)) content = append(content, canarySection(b, canary, dl))
} }
content = append(content, content = append(content,
Div(Class("mt-6 text-center"), Div(Class("mt-6 text-center"),
A( A(
Href("https://github.com/quine-global/quintodrome/releases"), Href(b.GitHubURL+"/releases"),
Class("font-mono text-xs text-zinc-500 hover:text-white transition-colors underline underline-offset-4"), Class("font-mono text-xs text-zinc-500 hover:text-white transition-colors underline underline-offset-4"),
Text("All releases on GitHub ↗"), Text("All releases on GitHub ↗"),
), ),
), ),
) )
return page(props, return Page(b, props,
Div(Class("mx-auto max-w-xl py-16"), Div(Class("mx-auto max-w-xl py-16"),
Group(content), Group(content),
), ),
) )
} }
func stableSection(stable []Release, dl DownloadProps) Node { func stableSection(b Branding, stable []Release, dl DownloadProps) Node {
latest := stable[0] latest := stable[0]
older := stable[1:] older := stable[1:]
url := latest.DownloadURL(dl.SelectedOS, dl.SelectedArch) url := latest.DownloadURL(dl.SelectedOS, dl.SelectedArch)
@ -165,7 +199,7 @@ func stableSection(stable []Release, dl DownloadProps) Node {
Text("Latest Stable"), Text("Latest Stable"),
), ),
H2(Class("mt-1 font-mono text-xl font-bold text-white"), H2(Class("mt-1 font-mono text-xl font-bold text-white"),
Text("Quintodrome v"+latest.DisplayVersion()), Text(b.Name+" v"+latest.DisplayVersion()),
), ),
P(Class("mt-1 font-mono text-xs text-zinc-500"), P(Class("mt-1 font-mono text-xs text-zinc-500"),
Text(latest.Date+" · "+platformLabel(dl.SelectedOS, dl.SelectedArch)+fileExt(dl.SelectedOS)), Text(latest.Date+" · "+platformLabel(dl.SelectedOS, dl.SelectedArch)+fileExt(dl.SelectedOS)),
@ -189,7 +223,7 @@ func stableSection(stable []Release, dl DownloadProps) Node {
} }
} }
func canarySection(canary []Release, dl DownloadProps) Node { func canarySection(b Branding, canary []Release, dl DownloadProps) Node {
latest := canary[0] latest := canary[0]
older := canary[1:] older := canary[1:]
url := latest.DownloadURL(dl.SelectedOS, dl.SelectedArch) url := latest.DownloadURL(dl.SelectedOS, dl.SelectedArch)
@ -208,7 +242,7 @@ func canarySection(canary []Release, dl DownloadProps) Node {
Text("Latest Canary"), Text("Latest Canary"),
), ),
H2(Class("mt-1 font-mono text-base font-bold text-white"), H2(Class("mt-1 font-mono text-base font-bold text-white"),
Text("Quintodrome v"+latest.DisplayVersion()), Text(b.Name+" v"+latest.DisplayVersion()),
), ),
P(Class("mt-1 font-mono text-xs text-zinc-500"), P(Class("mt-1 font-mono text-xs text-zinc-500"),
Text(latest.Date+" · "+platformLabel(dl.SelectedOS, dl.SelectedArch)+fileExt(dl.SelectedOS)), Text(latest.Date+" · "+platformLabel(dl.SelectedOS, dl.SelectedArch)+fileExt(dl.SelectedOS)),
@ -276,7 +310,7 @@ func releaseRows(releases []Release, dl DownloadProps, linkCls string) []Node {
return nodes return nodes
} }
func platformButtons(dl DownloadProps) []Node { func platformButtons(dc DownloadConfig, dl DownloadProps) []Node {
nodes := make([]Node, len(platforms)) nodes := make([]Node, len(platforms))
// Treat any linux-* variant as linux for platform button highlight/detection purposes. // Treat any linux-* variant as linux for platform button highlight/detection purposes.
effectiveSelectedOS := dl.SelectedOS effectiveSelectedOS := dl.SelectedOS
@ -291,9 +325,10 @@ func platformButtons(dl DownloadProps) []Node {
isSelected := p.OS == effectiveSelectedOS && p.Arch == dl.SelectedArch isSelected := p.OS == effectiveSelectedOS && p.Arch == dl.SelectedArch
isDetected := p.OS == effectiveDetectedOS && p.Arch == dl.DetectedArch isDetected := p.OS == effectiveDetectedOS && p.Arch == dl.DetectedArch
// Preserve the linux format when switching arch. // Preserve the linux format when switching arch, unless that format
// doesn't ship for the target arch (e.g. an x64-only Snap build).
targetOS := p.OS targetOS := p.OS
if p.OS == "linux" && strings.HasPrefix(dl.SelectedOS, "linux-") { if p.OS == "linux" && strings.HasPrefix(dl.SelectedOS, "linux-") && dc.formatSupportsArch(dl.SelectedOS, p.Arch) {
targetOS = dl.SelectedOS targetOS = dl.SelectedOS
} }
@ -347,7 +382,7 @@ func macArchDetectScript() Node {
})();`)) })();`))
} }
func linuxFormatToggle(dl DownloadProps) Node { func linuxFormatToggle(dc DownloadConfig, 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" 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" 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 { cls := func(targetOS string) string {
@ -360,13 +395,12 @@ func linuxFormatToggle(dl DownloadProps) Node {
return fmt.Sprintf("/download?os=%s&arch=%s", targetOS, dl.SelectedArch) return fmt.Sprintf("/download?os=%s&arch=%s", targetOS, dl.SelectedArch)
} }
buttons := []Node{ var buttons []Node
A(Href(href("linux-flatpak")), Class(cls("linux-flatpak")), Text("Flatpak")), for _, f := range dc.Formats {
A(Href(href("linux-snap")), Class(cls("linux-snap")), Text("Snap")), if !containsString(f.Archs, dl.SelectedArch) {
A(Href(href("linux")), Class(cls("linux")), Text("AppImage")), continue
A(Href(href("linux-deb")), Class(cls("linux-deb")), Text("DEB")), }
A(Href(href("linux-rpm")), Class(cls("linux-rpm")), Text("RPM")), buttons = append(buttons, A(Href(href(f.OS)), Class(cls(f.OS)), Text(f.Label)))
A(Href(href("linux-pacman")), Class(cls("linux-pacman")), Text("Pacman")),
} }
return Div(Class("mt-4"), return Div(Class("mt-4"),

View file

@ -0,0 +1,17 @@
package component
import (
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
// FeatureCard is one card in the "Why <product>" features grid.
func FeatureCard(title string, body Node, code string) Node {
return Div(Class("rounded-xl border border-zinc-800 bg-zinc-900 p-8"),
H3(Class("font-mono text-base font-semibold text-white"), Text(title)),
P(Class("mt-3 font-mono text-sm text-zinc-400 leading-relaxed"), body),
Div(Class("mt-5 inline-block rounded-md bg-zinc-800 px-3 py-1.5"),
Span(Class("font-mono text-xs text-primary-400"), Text(code)),
),
)
}

View file

@ -0,0 +1,22 @@
package component
import (
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
// DeviceWindow is the shared "device window" chrome (rounded border, shadow,
// traffic-light title bar) used by each site's home-page mockup. Sites
// supply their own body content (a music player, a terminal, etc.).
func DeviceWindow(titleBarLabel string, body Node) Node {
return Div(Class("mx-auto mb-20 max-w-3xl overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-2xl"),
// Title bar
Div(Class("flex items-center gap-2 border-b border-zinc-800 bg-zinc-900 px-4 py-3"),
Div(Class("h-3 w-3 rounded-full bg-red-500 opacity-80")),
Div(Class("h-3 w-3 rounded-full bg-yellow-500 opacity-80")),
Div(Class("h-3 w-3 rounded-full bg-primary-500 opacity-80")),
Span(Class("ml-4 font-mono text-xs text-zinc-500"), Text(titleBarLabel)),
),
body,
)
}

View file

@ -1,5 +1,7 @@
// Package HTML holds all the common HTML components and utilities. // Package component holds all the common HTML components and utilities
package html // shared across sites. Site-specific branding is threaded through via
// [Branding] and [DownloadConfig] rather than hardcoded.
package component
import ( import (
"crypto/sha256" "crypto/sha256"
@ -17,14 +19,26 @@ import (
var hashOnce sync.Once var hashOnce sync.Once
var appCSSPath string var appCSSPath string
// PageProps are properties for the [page] component. // PageProps are properties for the [Page] component.
type PageProps struct { type PageProps struct {
Title string Title string
Description string Description string
} }
// page layout with nav, main content, and footer. // Branding holds the per-site identity used by the shared page chrome.
func page(props PageProps, children ...Node) Node { type Branding struct {
Name string // e.g. "Quintodrome"
LogoPath string // e.g. "/images/quintodrome-logo.png"
GitHubURL string // e.g. "https://github.com/quine-global/quintodrome"
// FooterNote is the "Built on <link>." fragment rendered after the
// shared "© 2026 Quine Global. " copyright line. Each site supplies
// its own link and trailing text (e.g. "Navidrome." vs "Hyper by Vercel.").
FooterNote Node
}
// Page layout with nav, main content, and footer.
func Page(b Branding, props PageProps, children ...Node) Node {
hashOnce.Do(func() { hashOnce.Do(func() {
appCSSPath = getHashedPath("public/styles/app.css") appCSSPath = getHashedPath("public/styles/app.css")
}) })
@ -41,26 +55,26 @@ func page(props PageProps, children ...Node) Node {
Meta(Name("viewport"), Content("width=device-width, initial-scale=1")), Meta(Name("viewport"), Content("width=device-width, initial-scale=1")),
}, },
Body: Group{ Body: Group{
siteNav(), siteNav(b),
Main(Class("mx-auto max-w-5xl px-6 py-12"), Main(Class("mx-auto max-w-5xl px-6 py-12"),
Group(children), Group(children),
), ),
siteFooter(), siteFooter(b),
}, },
}) })
} }
func siteNav() Node { func siteNav(b Branding) Node {
return Header(Class("border-b border-zinc-800 bg-zinc-950"), return Header(Class("border-b border-zinc-800 bg-zinc-950"),
Div(Class("mx-auto flex max-w-5xl items-center justify-between px-6 py-4"), Div(Class("mx-auto flex max-w-5xl items-center justify-between px-6 py-4"),
A(Href("/"), Class("flex items-center gap-3 no-underline"), A(Href("/"), Class("flex items-center gap-3 no-underline"),
Img(Src("/images/hyper-logo.png"), Alt("Quine Hyper"), Class("h-8 w-8 rounded")), Img(Src(b.LogoPath), Alt(b.Name), Class("h-8 w-8 rounded")),
Span(Class("font-mono text-lg font-bold tracking-tight text-white"), Span(Class("font-mono text-lg font-bold tracking-tight text-white"),
Text("Quine Hyper"), Text(b.Name),
), ),
), ),
Div(Class("flex items-center gap-4"), Div(Class("flex items-center gap-4"),
A(Href("https://github.com/quine-global/hyper"), A(Href(b.GitHubURL),
Class("font-mono text-sm text-zinc-400 hover:text-white transition-colors"), Class("font-mono text-sm text-zinc-400 hover:text-white transition-colors"),
Text("GitHub"), Text("GitHub"),
), ),
@ -73,20 +87,19 @@ func siteNav() Node {
) )
} }
func siteFooter() Node { func siteFooter(b Branding) Node {
return Footer(Class("mt-24 border-t border-zinc-800 py-8"), return Footer(Class("mt-24 border-t border-zinc-800 py-8"),
Div(Class("mx-auto flex max-w-5xl flex-col items-center justify-between gap-4 px-6 sm:flex-row"), Div(Class("mx-auto flex max-w-5xl flex-col items-center justify-between gap-4 px-6 sm:flex-row"),
Div(Class("flex items-center gap-3"), Div(Class("flex items-center gap-3"),
Img(Src("/images/quineglobal-logo.png"), Alt("Quine Global"), Class("h-6 w-6 rounded-full")), Img(Src("/images/quineglobal-logo.png"), Alt("Quine Global"), Class("h-6 w-6 rounded-full")),
Span(Class("font-mono text-sm text-zinc-500"), Span(Class("font-mono text-sm text-zinc-500"),
Text("© 2026 Quine Global. Built on "), Text("© 2026 Quine Global. "),
A(Href("https://hyper.is"), Class("text-zinc-400 hover:text-white underline"), Text("Hyper")), b.FooterNote,
Text(" by Vercel."),
), ),
), ),
Div(Class("flex gap-6 font-mono text-sm"), Div(Class("flex gap-6 font-mono text-sm"),
A(Href("/"), Class("text-zinc-500 hover:text-white transition-colors"), Text("Home")), A(Href("/"), Class("text-zinc-500 hover:text-white transition-colors"), Text("Home")),
A(Href("https://github.com/quine-global/hyper"), Class("text-zinc-500 hover:text-white transition-colors"), Text("GitHub")), A(Href(b.GitHubURL), Class("text-zinc-500 hover:text-white transition-colors"), Text("GitHub")),
A(Href("/download"), Class("text-zinc-500 hover:text-white transition-colors"), Text("Download")), A(Href("/download"), Class("text-zinc-500 hover:text-white transition-colors"), Text("Download")),
), ),
), ),

31
internal/site/config.go Normal file
View file

@ -0,0 +1,31 @@
package site
import (
. "maragu.dev/gomponents"
"app/internal/component"
)
// Config is the full set of per-site knobs the shared server and page
// chrome need. Each site under sites/ builds one Config value and passes
// it to [NewServer].
type Config struct {
component.Branding
component.DownloadConfig
// RepoOwner/RepoName identify the GitHub repo releases are fetched from
// and linked to, e.g. "quine-global" / "quintodrome".
RepoOwner string
RepoName string
// ClassifyAsset maps a GitHub release asset filename to an (os, arch)
// key used in Release.Assets. Each site's real release artifacts have
// their own naming quirks, so this is not shared logic.
ClassifyAsset func(name string) (os, arch string, ok bool)
// HomeContent renders the site's landing page (hero, mockup, feature
// cards, origin story). It's expected to call component.Page itself.
// It takes the full Config (rather than closing over the site's package-
// level Config var) to avoid a package initialization cycle.
HomeContent func(cfg Config, props component.PageProps) Node
}

View file

@ -1,4 +1,4 @@
package http package site
import ( import (
"context" "context"
@ -10,20 +10,20 @@ import (
. "maragu.dev/gomponents" . "maragu.dev/gomponents"
ghttp "maragu.dev/gomponents/http" ghttp "maragu.dev/gomponents/http"
"app/sites/quintodrome/html" "app/internal/component"
) )
// Home registers the index and download handlers. // Home registers the index and download handlers.
func (s *Server) Home(r chi.Router) { func (s *Server) Home(r chi.Router) {
r.Get("/", ghttp.Adapt(func(w http.ResponseWriter, r *http.Request) (Node, error) { r.Get("/", ghttp.Adapt(func(w http.ResponseWriter, r *http.Request) (Node, error) {
return html.HomePage(html.PageProps{}), nil return s.config.HomeContent(s.config, component.PageProps{}), nil
})) }))
r.Get("/download", ghttp.Adapt(func(w http.ResponseWriter, r *http.Request) (Node, error) { r.Get("/download", ghttp.Adapt(func(w http.ResponseWriter, r *http.Request) (Node, error) {
// On-demand refresh, debounced to once per 2 minutes across all requests. // On-demand refresh, debounced to once per 2 minutes across all requests.
go s.releases.TryRefresh(context.Background(), 2*time.Minute) go s.releases.TryRefresh(context.Background(), 2*time.Minute)
detectedOS, detectedArch := detectPlatform(r.Header.Get("User-Agent")) detectedOS, detectedArch := s.detectPlatform(r.Header.Get("User-Agent"))
selectedOS := detectedOS selectedOS := detectedOS
selectedArch := detectedArch selectedArch := detectedArch
@ -37,7 +37,7 @@ func (s *Server) Home(r chi.Router) {
archExplicit = true archExplicit = true
} }
return html.DownloadPage(html.PageProps{}, html.DownloadProps{ return component.DownloadPage(s.config.Branding, s.config.DownloadConfig, component.PageProps{}, component.DownloadProps{
DetectedOS: detectedOS, DetectedOS: detectedOS,
DetectedArch: detectedArch, DetectedArch: detectedArch,
SelectedOS: selectedOS, SelectedOS: selectedOS,
@ -48,7 +48,7 @@ func (s *Server) Home(r chi.Router) {
})) }))
} }
func detectPlatform(ua string) (os, arch string) { func (s *Server) detectPlatform(ua string) (os, arch string) {
u := strings.ToLower(ua) u := strings.ToLower(ua)
switch { switch {
case strings.Contains(u, "windows"): case strings.Contains(u, "windows"):
@ -56,8 +56,7 @@ func detectPlatform(ua string) (os, arch string) {
case strings.Contains(u, "macintosh"), strings.Contains(u, "mac os"): case strings.Contains(u, "macintosh"), strings.Contains(u, "mac os"):
return "mac", "arm64" // default Apple Silicon; user can override return "mac", "arm64" // default Apple Silicon; user can override
case strings.Contains(u, "linux"): case strings.Contains(u, "linux"):
// Quintodrome currently ships Linux builds as Flatpak/Snap only (no AppImage). return s.config.DefaultLinuxOS, "x64"
return "linux-flatpak", "x64"
default: default:
return "mac", "arm64" return "mac", "arm64"
} }

View file

@ -1,4 +1,4 @@
package http package site
import ( import (
"fmt" "fmt"

View file

@ -1,8 +1,9 @@
package http package site
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"strconv" "strconv"
@ -10,26 +11,29 @@ import (
"sync" "sync"
"time" "time"
"app/sites/quintodrome/html" "app/internal/component"
) )
const releasesAPI = "https://api.github.com/repos/quine-global/quintodrome/releases?per_page=30"
// ReleaseCache is a thread-safe cache of GitHub releases. // ReleaseCache is a thread-safe cache of GitHub releases.
type ReleaseCache struct { type ReleaseCache struct {
log *slog.Logger log *slog.Logger
config Config
mu sync.RWMutex mu sync.RWMutex
releases []html.Release releases []component.Release
lastFetched time.Time lastFetched time.Time
fetchMu sync.Mutex // TryLock prevents concurrent fetches fetchMu sync.Mutex // TryLock prevents concurrent fetches
} }
func (c *ReleaseCache) releasesAPI() string {
return fmt.Sprintf("https://api.github.com/repos/%s/%s/releases?per_page=30", c.config.RepoOwner, c.config.RepoName)
}
// Get returns cached releases, falling back to hardcoded data if cache is empty. // Get returns cached releases, falling back to hardcoded data if cache is empty.
func (c *ReleaseCache) Get() []html.Release { func (c *ReleaseCache) Get() []component.Release {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
if len(c.releases) == 0 { if len(c.releases) == 0 {
return []html.Release{} return []component.Release{}
} }
return c.releases return c.releases
} }
@ -40,7 +44,7 @@ func (c *ReleaseCache) ForceRefresh(ctx context.Context) error {
c.fetchMu.Lock() c.fetchMu.Lock()
defer c.fetchMu.Unlock() defer c.fetchMu.Unlock()
releases, err := fetchReleases(ctx) releases, err := c.fetchReleases(ctx)
if err != nil { if err != nil {
c.log.Warn("Force refresh failed", "error", err) c.log.Warn("Force refresh failed", "error", err)
return err return err
@ -74,7 +78,7 @@ func (c *ReleaseCache) TryRefresh(ctx context.Context, minAge time.Duration) {
return return
} }
releases, err := fetchReleases(ctx) releases, err := c.fetchReleases(ctx)
if err != nil { if err != nil {
c.log.Warn("Failed to fetch releases from GitHub", "error", err) c.log.Warn("Failed to fetch releases from GitHub", "error", err)
return return
@ -104,8 +108,8 @@ type ghAsset struct {
BrowserDownloadURL string `json:"browser_download_url"` BrowserDownloadURL string `json:"browser_download_url"`
} }
func fetchReleases(ctx context.Context) ([]html.Release, error) { func (c *ReleaseCache) fetchReleases(ctx context.Context) ([]component.Release, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, releasesAPI, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.releasesAPI(), nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -123,13 +127,13 @@ func fetchReleases(ctx context.Context) ([]html.Release, error) {
return nil, err return nil, err
} }
releases := make([]html.Release, 0, len(raw)) releases := make([]component.Release, 0, len(raw))
for _, gr := range raw { for _, gr := range raw {
if gr.Draft { if gr.Draft {
continue continue
} }
version, canaryBuild := parseTag(strings.TrimPrefix(gr.TagName, "v")) version, canaryBuild := parseTag(strings.TrimPrefix(gr.TagName, "v"))
r := html.Release{ r := component.Release{
TagName: gr.TagName, TagName: gr.TagName,
Version: version, Version: version,
CanaryBuild: canaryBuild, CanaryBuild: canaryBuild,
@ -139,7 +143,7 @@ func fetchReleases(ctx context.Context) ([]html.Release, error) {
Assets: make(map[string]map[string]string), Assets: make(map[string]map[string]string),
} }
for _, a := range gr.Assets { for _, a := range gr.Assets {
os, arch, ok := classifyAsset(a.Name) os, arch, ok := c.config.ClassifyAsset(a.Name)
if !ok { if !ok {
continue continue
} }
@ -171,56 +175,3 @@ func parseTag(tag string) (version string, canaryBuild int) {
tag = strings.TrimSuffix(tag, "q") tag = strings.TrimSuffix(tag, "q")
return tag, 0 return tag, 0
} }
// classifyAsset maps an asset filename to an OS and architecture.
func classifyAsset(name string) (os, arch string, ok bool) {
l := strings.ToLower(name)
isArm64 := strings.Contains(l, "arm64") || strings.Contains(l, "aarch64")
isArmv7l := strings.Contains(l, "armv7l")
switch {
case strings.HasSuffix(l, ".exe"):
return "windows", "x64", true
case isArm64 && strings.HasSuffix(l, ".dmg"):
return "mac", "arm64", true
case strings.HasSuffix(l, ".dmg"):
return "mac", "x64", true
case isArm64 && strings.HasSuffix(l, ".appimage"):
return "linux", "arm64", true
case isArmv7l && strings.HasSuffix(l, ".appimage"):
return "", "", false
case strings.HasSuffix(l, ".appimage"):
return "linux", "x64", true
case isArm64 && strings.HasSuffix(l, ".rpm"):
return "linux-rpm", "arm64", true
case isArmv7l && strings.HasSuffix(l, ".rpm"):
return "", "", false
case strings.HasSuffix(l, ".rpm"):
return "linux-rpm", "x64", true
case isArm64 && strings.HasSuffix(l, ".deb"):
return "linux-deb", "arm64", true
case isArmv7l && strings.HasSuffix(l, ".deb"):
return "", "", false
case strings.HasSuffix(l, ".deb"):
return "linux-deb", "x64", true
case isArmv7l && strings.HasSuffix(l, ".snap"):
return "", "", false
case isArm64 && strings.HasSuffix(l, ".snap"):
return "linux-snap", "arm64", true
case strings.HasSuffix(l, ".snap"):
return "linux-snap", "x64", true
case isArm64 && strings.HasSuffix(l, ".pacman"):
return "linux-pacman", "arm64", true
case isArmv7l && strings.HasSuffix(l, ".pacman"):
return "", "", false
case strings.HasSuffix(l, ".pacman"):
return "linux-pacman", "x64", true
case isArm64 && strings.HasSuffix(l, ".flatpak"):
return "linux-flatpak", "arm64", true
case isArmv7l && strings.HasSuffix(l, ".flatpak"):
return "", "", false
case strings.HasSuffix(l, ".flatpak"):
return "linux-flatpak", "x64", true
default:
return "", "", false
}
}

View file

@ -0,0 +1,30 @@
package site
import (
"testing"
"maragu.dev/is"
)
func TestParseTag(t *testing.T) {
cases := []struct {
tag string
wantVer string
wantBuild int
}{
{"4.0.0", "4.0.0", 0},
{"4.0.0-q", "4.0.0", 0},
{"4.0.0q", "4.0.0", 0},
{"4.0.0-q-canary.12", "4.0.0", 12},
{"4.0.0-q-canary.1", "4.0.0", 1},
{"1.0.0-alpha", "1.0.0-alpha", 0},
}
for _, c := range cases {
t.Run(c.tag, func(t *testing.T) {
gotVer, gotBuild := parseTag(c.tag)
is.Equal(t, c.wantVer, gotVer)
is.Equal(t, c.wantBuild, gotBuild)
})
}
}

View file

@ -1,4 +1,4 @@
package http package site
import ( import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@ -25,6 +25,6 @@ func (s *Server) setupRoutes() {
}) })
// Also expose the update feed at the short /update path for convenient // Also expose the update feed at the short /update path for convenient
// feed URLs (e.g. https://hyper.quineglobal.com/update?platform=...&version=...) // feed URLs (e.g. https://<site>.quineglobal.com/update?platform=...&version=...)
s.mux.Get("/update", s.updateCheckHandler()) s.mux.Get("/update", s.updateCheckHandler())
} }

View file

@ -1,5 +1,7 @@
// Package http has the [Server] and HTTP handlers. // Package site is the shared HTTP server framework: the [Server] type,
package http // routes, release cache, and update-check protocol. Site-specific identity
// and content are injected via [Config].
package site
import ( import (
"context" "context"
@ -15,13 +17,15 @@ import (
// Server holds dependencies for the HTTP server as well as the HTTP server itself. // Server holds dependencies for the HTTP server as well as the HTTP server itself.
type Server struct { type Server struct {
log *slog.Logger log *slog.Logger
config Config
mux chi.Router mux chi.Router
server *http.Server server *http.Server
releases *ReleaseCache releases *ReleaseCache
} }
type NewServerOptions struct { type NewServerOptions struct {
Log *slog.Logger Log *slog.Logger
Config Config
} }
func NewServer(opts NewServerOptions) *Server { func NewServer(opts NewServerOptions) *Server {
@ -33,8 +37,9 @@ func NewServer(opts NewServerOptions) *Server {
return &Server{ return &Server{
log: opts.Log, log: opts.Log,
config: opts.Config,
mux: mux, mux: mux,
releases: &ReleaseCache{log: opts.Log}, releases: &ReleaseCache{log: opts.Log, config: opts.Config},
server: &http.Server{ server: &http.Server{
Addr: ":8081", Addr: ":8081",
Handler: mux, Handler: mux,

View file

@ -0,0 +1,58 @@
package site_test
import (
"io"
http2 "net/http"
"strings"
"testing"
"time"
"maragu.dev/is"
. "maragu.dev/gomponents"
"app/internal/component"
"app/internal/site"
)
func testConfig() site.Config {
return site.Config{
Branding: component.Branding{
Name: "Test",
LogoPath: "/images/test-logo.png",
GitHubURL: "https://github.com/quine-global/test",
},
DownloadConfig: component.DownloadConfig{
DefaultLinuxOS: "linux",
},
RepoOwner: "quine-global",
RepoName: "test",
ClassifyAsset: func(name string) (string, string, bool) { return "", "", false },
HomeContent: func(cfg site.Config, props component.PageProps) Node {
return component.Page(cfg.Branding, props)
},
}
}
func TestServer_Start(t *testing.T) {
t.Run("can start and stop server", func(t *testing.T) {
s := site.NewServer(site.NewServerOptions{Config: testConfig()})
go func() {
is.NotError(t, s.Start())
}()
defer func() {
is.NotError(t, s.Stop())
}()
// I know we could check that the server is running here, but it's easier to just wait a bit
time.Sleep(10 * time.Millisecond)
res, err := http2.Get("http://localhost:8081/")
is.NotError(t, err)
is.Equal(t, http2.StatusOK, res.StatusCode)
body, err := io.ReadAll(res.Body)
is.NotError(t, err)
is.True(t, strings.HasPrefix(string(body), "<!doctype html>"))
})
}

View file

@ -1,4 +1,4 @@
package http package site
import ( import (
"net/http" "net/http"

View file

@ -1,4 +1,4 @@
package http package site
import ( import (
"encoding/json" "encoding/json"
@ -10,7 +10,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"app/sites/quintodrome/html" "app/internal/component"
) )
// hazelUpdate is the response format expected by desktop auto-updaters // hazelUpdate is the response format expected by desktop auto-updaters
@ -66,7 +66,7 @@ func (s *Server) updateCheckHandler() http.HandlerFunc {
// Find the newest release that is newer than what the client has, // Find the newest release that is newer than what the client has,
// on the correct track (stable vs canary), and preferably has assets // on the correct track (stable vs canary), and preferably has assets
// for the requested platform/arch. // for the requested platform/arch.
var candidate *html.Release var candidate *component.Release
for _, rel := range releases { for _, rel := range releases {
if rel.IsCanary { if rel.IsCanary {
if clientIsCanary && newerCanary(rel, clientBase, clientBuild, true) { if clientIsCanary && newerCanary(rel, clientBase, clientBuild, true) {
@ -96,7 +96,7 @@ func (s *Server) updateCheckHandler() http.HandlerFunc {
// Build the Hazel-compatible response. // Build the Hazel-compatible response.
resp := hazelUpdate{ resp := hazelUpdate{
Name: candidate.TagName, Name: candidate.TagName,
Notes: fmt.Sprintf("Quintodrome %s", candidate.TagName), Notes: fmt.Sprintf("%s %s", s.config.Name, candidate.TagName),
PubDate: candidate.PublishedAt.UTC().Format(time.RFC3339), PubDate: candidate.PublishedAt.UTC().Format(time.RFC3339),
URL: candidate.DownloadURL(internalOS, arch), URL: candidate.DownloadURL(internalOS, arch),
} }
@ -107,7 +107,7 @@ func (s *Server) updateCheckHandler() http.HandlerFunc {
if resp.URL == "" { if resp.URL == "" {
// Provide a generic GitHub releases URL as a fallback. // Provide a generic GitHub releases URL as a fallback.
// The client already falls back to constructing a tag URL. // The client already falls back to constructing a tag URL.
resp.URL = fmt.Sprintf("https://github.com/quine-global/quintodrome/releases/tag/%s", candidate.TagName) resp.URL = fmt.Sprintf("https://github.com/%s/%s/releases/tag/%s", s.config.RepoOwner, s.config.RepoName, candidate.TagName)
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@ -152,7 +152,7 @@ func normalizePlatformForAssets(p string) string {
// newerCanary reports whether rel is newer than the client's current canary position. // 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 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. // 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 { func newerCanary(rel component.Release, clientBase string, clientBuild int, clientIsCanary bool) bool {
cmp := semverCmp(rel.Version, clientBase) cmp := semverCmp(rel.Version, clientBase)
if cmp > 0 { if cmp > 0 {
return true return true

View file

@ -1,11 +1,11 @@
package http package site
import ( import (
"testing" "testing"
"maragu.dev/is" "maragu.dev/is"
"app/html" "app/internal/component"
) )
func TestSemverCmp(t *testing.T) { func TestSemverCmp(t *testing.T) {
@ -28,8 +28,8 @@ func TestSemverCmp(t *testing.T) {
} }
func TestNewerCanary(t *testing.T) { func TestNewerCanary(t *testing.T) {
rel := func(base string, build int) html.Release { rel := func(base string, build int) component.Release {
return html.Release{Version: base, CanaryBuild: build, IsCanary: true} return component.Release{Version: base, CanaryBuild: build, IsCanary: true}
} }
t.Run("higher base is newer for canary client", func(t *testing.T) { t.Run("higher base is newer for canary client", func(t *testing.T) {

View file

@ -1,5 +1,5 @@
{ {
"name": "quintodrome-quineglobal-com", "name": "quineglobal-sites",
"private": true, "private": true,
"devDependencies": { "devDependencies": {
"tailwindcss": "^3.4.0" "tailwindcss": "^3.4.0"

View file

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

File diff suppressed because one or more lines are too long

View file

@ -1,3 +0,0 @@
/cover.out
/node_modules/
/package-lock.json

View file

@ -1,45 +1,6 @@
# hyper-quineglobal-com # sites/hyper
Website and auto-update feed server for [Quine Hyper](https://github.com/quine-global/hyper), a fork of the Hyper terminal. Quine Hyper's site-specific config. See the root `CLAUDE.md` for the shared
server/component architecture — this directory only holds `Config`
## Commands (branding, GitHub repo, Linux download formats), `classifyAsset`, and the
landing-page content (`home.go`, `mockup.go`).
```bash
make dev # start Go server + Tailwind watcher (Ctrl-C stops both)
make start # Go server only
make test # go test -shuffle on ./...
make css # one-shot Tailwind build
make lint # golangci-lint
make build # Docker image (linux/amd64)
make push # build + push to forge.quinefoundation.com
```
## Architecture
Pure Go HTTP server (`go-chi/chi`). HTML is rendered server-side with `maragu.dev/gomponents` (no templates). CSS is Tailwind, compiled to `public/styles/app.css`.
```
cmd/app/ entry point
http/ route handlers, server, release cache, update-check logic
html/ gomponents page renderers and shared types (Release, DownloadProps, etc.)
public/ static assets (images, compiled CSS)
scripts/ release refresh helper script
```
## Key concepts
**Release cache** (`http/releases.go`): fetches GitHub releases from the `quine-global/hyper` repo via the GitHub API, caches them in memory. `TryRefresh` is debounced (called per-request, no-ops if cache is fresh or a fetch is already running). `ForceRefresh` is used by the internal `/internal/refresh` endpoint.
**Asset classification** (`classifyAsset` in `http/releases.go`): maps GitHub asset filenames to `(os, arch)` keys used in `Release.Assets`. Supported platforms: `mac/{arm64,x64}`, `windows/x64`, `linux/{arm64,x64}` (AppImage), `linux-rpm/{arm64,x64}`. armv7l and other unrecognised ARM variants are explicitly excluded (return `ok=false`) so they don't get misclassified as x64.
**Auto-update feed** (`http/updatecheck.go`): implements the Hazel/Squirrel update protocol originally served at `releases.hyper.is`. Responds to `GET /api/update-check` and `GET /update` with 204 (no update) or 200 JSON `{name, notes, pub_date, url}`. Handles stable and canary tracks; platform strings from the Electron client (`darwin`, `win32`, `deb`, etc.) are normalised via `normalizePlatformForAssets`.
**Download page** (`http/home.go`, `html/download.go`): detects platform from `User-Agent`, supports manual override via `?os=` and `?arch=` query params. Linux format toggle (AppImage vs RPM) is a separate `?os=linux-rpm` variant.
## Testing
Tests live alongside source in `package http` (internal) or `package http_test` (integration). Run with `make test`. Notable test files:
- `http/releases_test.go` — `classifyAsset` and `parseTag`
- `http/updatecheck_test.go` — `semverCmp`, `newerCanary`, `normalizePlatformForAssets`
- `http/server_test.go` — integration test that starts the real server

View file

@ -1,32 +0,0 @@
FROM debian:stable AS cssbuilder
WORKDIR /app
RUN set -x && apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y curl
COPY html ./html/
FROM golang AS gobuilder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . ./
ARG TARGETARCH
RUN GOOS=linux GOARCH=${TARGETARCH} go build -buildvcs=false -ldflags="-s -w" -o ./app ./cmd/app
FROM debian:stable-slim AS runner
WORKDIR /app
RUN set -x && apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates && \
rm -rf /var/lib/apt/lists/*
COPY public ./public/
COPY --from=gobuilder /app/app ./
EXPOSE 8080
CMD ["./app"]

View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) Maragu ApS
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,43 +0,0 @@
.PHONY: benchmark
benchmark:
go test -bench=.
.PHONY: build
build:
docker build --platform linux/amd64 . -t forge.quinefoundation.com/ironmagma/hyper-quineglobal-com
.PHONY: push
push: build
docker push forge.quinefoundation.com/ironmagma/hyper-quineglobal-com
.PHONY: cover
cover:
go tool cover -html=cover.out
.PHONY: css
css:
npm run css
.PHONY: css-watch
css-watch:
npm run css:watch
.PHONY: dev
dev:
@trap 'kill 0' SIGINT SIGTERM; npm run css:watch & go run ./cmd/app; wait
.PHONY: install
install:
npm install
.PHONY: lint
lint:
golangci-lint run
.PHONY: start
start:
go run ./cmd/app
.PHONY: test
test:
go test -coverprofile=cover.out -shuffle on ./...

View file

@ -1,97 +0,0 @@
# gomponents-starter-kit
<img src="logo.png" alt="Logo" width="300" align="right">
[![GoDoc](https://pkg.go.dev/badge/github.com/maragudk/gomponents-starter-kit)](https://pkg.go.dev/github.com/maragudk/gomponents-starter-kit)
[![Go](https://github.com/maragudk/gomponents-starter-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/maragudk/gomponents-starter-kit/actions/workflows/ci.yml)
[![Go](https://github.com/maragudk/gomponents-starter-kit/actions/workflows/cd.yml/badge.svg)](https://github.com/maragudk/gomponents-starter-kit/actions/workflows/cd.yml)
A starter kit for building a web app with gomponents, HTMX, and TailwindCSS in Go.
Made with ✨sparkles✨ by [maragu](https://www.maragu.dev/).
Does your company depend on this project? [Contact me at markus@maragu.dk](mailto:markus@maragu.dk?Subject=Supporting%20your%20project) to discuss options for a one-time or recurring invoice to ensure its continued thriving.
## Getting started
The easiest way to get started is to [Use this template](https://github.com/new?template_name=gomponents-starter-kit&template_owner=maragudk) to create a new repository. Or you could clone this repository the traditional way:
```shell
git clone git@github.com:maragudk/gomponents-starter-kit.git your-app-name
```
After that, you can start the app with:
```shell
make start
```
If you make style changes, watch the CSS with:
```shell
make watch-css
```
You can run tests and linting with:
```shell
make test lint
```
### Enabling TailwindCSS auto-complete in your IDE
[TailwindCSS has auto-complete of classnames (and more) through IDE plugins](https://tailwindcss.com/docs/editor-setup).
After you've installed the TailwindCSS plugin for your IDE, it needs some configuration to work with gomponents. Here's the config for VS Code and JetBrains IDEs:
<details>
<summary>VSCode</summary>
Edit `vscode-settings.json` and add the following:
```json
{
"tailwindCSS.includeLanguages": {
"go": "html",
},
"tailwindCSS.experimental.classRegex": [
["Class(?:es)?[({]([^)}]*)[)}]", "[\"`]([^\"`]*)[\"`]"]
],
}
```
[See the official plugin page for more info](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss)
</details>
<details>
<summary>JetBrains/GoLand</summary>
Go to `Settings` -> `Languages & Frameworks` -> `Style Sheets` -> `Tailwind CSS` and add the following (don't delete the other config):
```json
{
"includeLanguages": {
"go": "html"
},
"experimental": {
"classRegex": [
["Class(?:es)?[({]([^)}]*)[)}]", "[\"`]([^\"`]*)[\"`]"]
]
}
}
```
[See the official plugin page for more info](https://plugins.jetbrains.com/plugin/15321-tailwind-css)
</details>
## Deploying
The [CD workflow](.github/workflows/cd.yml) automatically builds a multi-platform Docker image and pushes it to the Github container registry GHCR.io, tagged with the commit hash as well as `latest`.
You can try building the image locally with:
```shell
make build-docker
```
Note that [you need the containerd image store enabled](https://docs.docker.com/desktop/containerd/#enable-the-containerd-image-store) for this to work.

52
sites/hyper/assets.go Normal file
View file

@ -0,0 +1,52 @@
package hyper
import "strings"
// classifyAsset maps an asset filename to an OS and architecture.
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
}
}

View file

@ -1,4 +1,4 @@
package http package hyper
import ( import (
"testing" "testing"
@ -8,10 +8,10 @@ import (
func TestClassifyAsset(t *testing.T) { func TestClassifyAsset(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
wantOS string wantOS string
wantArch string wantArch string
wantOK bool wantOK bool
}{ }{
// Windows // Windows
{"QuineHyper.Setup.4.0.0.exe", "windows", "x64", true}, {"QuineHyper.Setup.4.0.0.exe", "windows", "x64", true},
@ -87,25 +87,3 @@ func TestClassifyAsset(t *testing.T) {
}) })
} }
} }
func TestParseTag(t *testing.T) {
cases := []struct {
tag string
wantVer string
wantBuild int
}{
{"4.0.0", "4.0.0", 0},
{"4.0.0-q", "4.0.0", 0},
{"4.0.0q", "4.0.0", 0},
{"4.0.0-q-canary.12", "4.0.0", 12},
{"4.0.0-q-canary.1", "4.0.0", 1},
}
for _, c := range cases {
t.Run(c.tag, func(t *testing.T) {
gotVer, gotBuild := parseTag(c.tag)
is.Equal(t, c.wantVer, gotVer)
is.Equal(t, c.wantBuild, gotBuild)
})
}
}

View file

@ -10,7 +10,8 @@ import (
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"maragu.dev/env" "maragu.dev/env"
"app/http" "app/internal/site"
"app/sites/hyper"
) )
func main() { func main() {
@ -35,8 +36,9 @@ func start(log *slog.Logger) error {
defer stop() defer stop()
// Set up the HTTP server, injecting the database and logger // Set up the HTTP server, injecting the database and logger
s := http.NewServer(http.NewServerOptions{ s := site.NewServer(site.NewServerOptions{
Log: log, Log: log,
Config: hyper.Config,
}) })
// Use an errgroup to wait for separate goroutines which can error // Use an errgroup to wait for separate goroutines which can error

View file

@ -1,14 +0,0 @@
module app
go 1.23
require (
github.com/go-chi/chi/v5 v5.1.0
golang.org/x/sync v0.10.0
maragu.dev/env v0.2.0
maragu.dev/gomponents v1.0.0
maragu.dev/httph v0.3.3
maragu.dev/is v0.2.0
)
require github.com/mitchellh/mapstructure v1.5.0 // indirect

View file

@ -1,16 +0,0 @@
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/maragudk/is v0.1.0 h1:obq9anZNmOYcaNbeT0LMyjIexdNeYTw/TLAPD/BnZHA=
github.com/maragudk/is v0.1.0/go.mod h1:W/r6+TpnISu+a88OLXQy5JQGCOhXQXXLD2e5b4xMn5c=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
maragu.dev/env v0.2.0 h1:nQKitDEB65ArZsh6E7vxzodOqY9bxEVFdBg+tskS1ys=
maragu.dev/env v0.2.0/go.mod h1:t5CCbaEnjCM5mewiAVVzTS4N+oXTus2+SRnzKQbQVME=
maragu.dev/gomponents v1.0.0 h1:eeLScjq4PqP1l+r5z/GC+xXZhLHXa6RWUWGW7gSfLh4=
maragu.dev/gomponents v1.0.0/go.mod h1:oEDahza2gZoXDoDHhw8jBNgH+3UR5ni7Ur648HORydM=
maragu.dev/httph v0.3.3 h1:YfOKkqwmAJykOENmxeoCmYY+firwPEpv4aIY7gDCTsc=
maragu.dev/httph v0.3.3/go.mod h1:EaoadbqczoICEGrtKrtlb1KD6gj6m65en3Wt2OchlJ0=
maragu.dev/is v0.2.0 h1:poeuVEA5GG3vrDpGmzo2KjWtIMZmqUyvGnOB0/pemig=
maragu.dev/is v0.2.0/go.mod h1:bviaM5S0fBshCw7wuumFGTju/izopZ/Yvq4g7Klc7y8=

View file

@ -1,16 +1,19 @@
package html package hyper
import ( import (
. "maragu.dev/gomponents" . "maragu.dev/gomponents"
. "maragu.dev/gomponents/html" . "maragu.dev/gomponents/html"
"app/internal/component"
"app/internal/site"
) )
// HomePage is the Quine Hyper landing page. // HomeContent is the Quine Hyper landing page.
func HomePage(props PageProps) Node { func HomeContent(cfg site.Config, props component.PageProps) Node {
props.Title = "Quine Hyper — The thoughtful terminal" props.Title = "Quine Hyper — The thoughtful terminal"
props.Description = "Quine Hyper is a beautiful, extensible terminal emulator forked from Vercel's Hyper. Built on web technology with a thriving plugin ecosystem." props.Description = "Quine Hyper is a beautiful, extensible terminal emulator forked from Vercel's Hyper. Built on web technology with a thriving plugin ecosystem."
return page(props, return component.Page(cfg.Branding, props,
// Hero // Hero
Div(Class("py-20 text-center"), Div(Class("py-20 text-center"),
Div(Class("mb-6 inline-flex items-center gap-2 rounded-full border border-zinc-700 bg-zinc-900 px-4 py-1.5"), Div(Class("mb-6 inline-flex items-center gap-2 rounded-full border border-zinc-700 bg-zinc-900 px-4 py-1.5"),
@ -28,7 +31,7 @@ func HomePage(props PageProps) Node {
Class("inline-flex items-center gap-2 rounded-lg bg-primary-500 px-8 py-3 font-mono text-base font-semibold text-black hover:bg-primary-400 transition-colors"), Class("inline-flex items-center gap-2 rounded-lg bg-primary-500 px-8 py-3 font-mono text-base font-semibold text-black hover:bg-primary-400 transition-colors"),
Text("Download for free"), Text("Download for free"),
), ),
A(Href("https://github.com/quine-global/hyper"), A(Href(cfg.GitHubURL),
Class("inline-flex items-center gap-2 rounded-lg border border-zinc-700 bg-zinc-900 px-8 py-3 font-mono text-base font-semibold text-white hover:border-zinc-500 transition-colors"), Class("inline-flex items-center gap-2 rounded-lg border border-zinc-700 bg-zinc-900 px-8 py-3 font-mono text-base font-semibold text-white hover:border-zinc-500 transition-colors"),
Text("View on GitHub"), Text("View on GitHub"),
), ),
@ -36,32 +39,7 @@ func HomePage(props PageProps) Node {
), ),
// Terminal window mockup // Terminal window mockup
Div(Class("mx-auto mb-20 max-w-3xl overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-2xl"), terminalMockup(),
// Title bar
Div(Class("flex items-center gap-2 border-b border-zinc-800 bg-zinc-900 px-4 py-3"),
Div(Class("h-3 w-3 rounded-full bg-red-500 opacity-80")),
Div(Class("h-3 w-3 rounded-full bg-yellow-500 opacity-80")),
Div(Class("h-3 w-3 rounded-full bg-primary-500 opacity-80")),
Span(Class("ml-4 font-mono text-xs text-zinc-500"), Text("quine-hyper — bash — 80×24")),
),
// Terminal body
Div(Class("p-6 font-mono text-sm leading-relaxed"),
termPrompt("deep-thought --answer"),
termOutput("42"),
termPrompt("hyper install infinite-improbability"),
termOutput("✔ Engaging drive..."),
termOutput("✔ Probability: 2:1 against."),
termOutput("✔ Don't Panic."),
termPrompt("ls ~/projects"),
termOutput("drwxr-xr-x earth/"),
termOutput("drwxr-xr-x magrathea/"),
termOutput("drwxr-xr-x slartibartfast/"),
Div(Class("flex items-center gap-0 text-primary-400"),
Span(Class("text-zinc-500"), Text("❯ ")),
Span(Class("animate-pulse"), Text("█")),
),
),
),
// Features // Features
Div(Class("pb-20"), Div(Class("pb-20"),
@ -69,7 +47,7 @@ func HomePage(props PageProps) Node {
Text("Why Quine Hyper"), Text("Why Quine Hyper"),
), ),
Div(Class("mt-12 grid grid-cols-1 gap-6 md:grid-cols-2"), Div(Class("mt-12 grid grid-cols-1 gap-6 md:grid-cols-2"),
featureCard("Plugin Ecosystem", component.FeatureCard("Plugin Ecosystem",
Group{ Group{
Text("Install plugins and themes from npm. Browse "), Text("Install plugins and themes from npm. Browse "),
A(Href("https://hyper.is/plugins"), A(Href("https://hyper.is/plugins"),
@ -85,15 +63,15 @@ func HomePage(props PageProps) Node {
}, },
"npm install hyper-snazzy", "npm install hyper-snazzy",
), ),
featureCard("Cross-Platform", component.FeatureCard("Cross-Platform",
Text("Runs on macOS, Windows, and Linux. One consistent experience across every machine you use."), Text("Runs on macOS, Windows, and Linux. One consistent experience across every machine you use."),
"Works everywhere you do.", "Works everywhere you do.",
), ),
featureCard("Web-Native", component.FeatureCard("Web-Native",
Text("Built on Electron, React, and xterm.js. Hack on the terminal itself using the same tools you use every day."), Text("Built on Electron, React, and xterm.js. Hack on the terminal itself using the same tools you use every day."),
"HTML + CSS + JS = your terminal", "HTML + CSS + JS = your terminal",
), ),
featureCard("Fully Customizable", component.FeatureCard("Fully Customizable",
Text("Tweak every detail with CSS. Change fonts, colors, animations, and layouts — no config file limits."), Text("Tweak every detail with CSS. Change fonts, colors, animations, and layouts — no config file limits."),
"hyper.json → your rules", "hyper.json → your rules",
), ),
@ -110,7 +88,7 @@ func HomePage(props PageProps) Node {
Text(", the open-source terminal by Vercel. We're building on that foundation with new capabilities and active development."), Text(", the open-source terminal by Vercel. We're building on that foundation with new capabilities and active development."),
), ),
Div(Class("mt-8"), Div(Class("mt-8"),
A(Href("https://github.com/quine-global/hyper"), A(Href(cfg.GitHubURL),
Class("inline-block rounded-lg border border-zinc-700 bg-zinc-800 px-6 py-3 font-mono text-sm font-semibold text-white hover:border-zinc-500 transition-colors"), Class("inline-block rounded-lg border border-zinc-700 bg-zinc-800 px-6 py-3 font-mono text-sm font-semibold text-white hover:border-zinc-500 transition-colors"),
Text("Star us on GitHub"), Text("Star us on GitHub"),
), ),
@ -118,24 +96,3 @@ func HomePage(props PageProps) Node {
), ),
) )
} }
func termPrompt(cmd string) Node {
return Div(Class("flex items-center gap-2"),
Span(Class("text-primary-400"), Text("❯")),
Span(Class("text-white"), Text(cmd)),
)
}
func termOutput(line string) Node {
return Div(Class("pl-5 text-zinc-400"), Text(line))
}
func featureCard(title string, body Node, code string) Node {
return Div(Class("rounded-xl border border-zinc-800 bg-zinc-900 p-8"),
H3(Class("font-mono text-base font-semibold text-white"), Text(title)),
P(Class("mt-3 font-mono text-sm text-zinc-400 leading-relaxed"), body),
Div(Class("mt-5 inline-block rounded-md bg-zinc-800 px-3 py-1.5"),
Span(Class("font-mono text-xs text-primary-400"), Text(code)),
),
)
}

View file

@ -1,417 +0,0 @@
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),
),
)
}

View file

@ -1,63 +0,0 @@
package http
import (
"context"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
. "maragu.dev/gomponents"
ghttp "maragu.dev/gomponents/http"
"app/html"
)
// Home registers the index and download handlers.
func (s *Server) Home(r chi.Router) {
r.Get("/", ghttp.Adapt(func(w http.ResponseWriter, r *http.Request) (Node, error) {
return html.HomePage(html.PageProps{}), nil
}))
r.Get("/download", ghttp.Adapt(func(w http.ResponseWriter, r *http.Request) (Node, error) {
// On-demand refresh, debounced to once per 2 minutes across all requests.
go s.releases.TryRefresh(context.Background(), 2*time.Minute)
detectedOS, detectedArch := detectPlatform(r.Header.Get("User-Agent"))
selectedOS := detectedOS
selectedArch := detectedArch
if p := r.URL.Query().Get("os"); p == "mac" || p == "windows" || p == "linux" || p == "linux-rpm" || p == "linux-deb" || p == "linux-snap" || p == "linux-pacman" || p == "linux-flatpak" {
selectedOS = p
}
archExplicit := false
if a := r.URL.Query().Get("arch"); a == "arm64" || a == "x64" {
selectedArch = a
archExplicit = true
}
return html.DownloadPage(html.PageProps{}, html.DownloadProps{
DetectedOS: detectedOS,
DetectedArch: detectedArch,
SelectedOS: selectedOS,
SelectedArch: selectedArch,
ArchExplicit: archExplicit,
Releases: s.releases.Get(),
}), nil
}))
}
func detectPlatform(ua string) (os, arch string) {
u := strings.ToLower(ua)
switch {
case strings.Contains(u, "windows"):
return "windows", "x64"
case strings.Contains(u, "macintosh"), strings.Contains(u, "mac os"):
return "mac", "arm64" // default Apple Silicon; user can override
case strings.Contains(u, "linux"):
return "linux", "x64"
default:
return "mac", "arm64"
}
}

View file

@ -1,222 +0,0 @@
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"
// ReleaseCache is a thread-safe cache of GitHub releases.
type ReleaseCache struct {
log *slog.Logger
mu sync.RWMutex
releases []html.Release
lastFetched time.Time
fetchMu sync.Mutex // TryLock prevents concurrent fetches
}
// Get returns cached releases, falling back to hardcoded data if cache is empty.
func (c *ReleaseCache) Get() []html.Release {
c.mu.RLock()
defer c.mu.RUnlock()
if len(c.releases) == 0 {
return []html.Release{}
}
return c.releases
}
// ForceRefresh does a blocking, unconditional fetch regardless of cache age.
// It waits for any in-progress fetch to finish before starting its own.
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
}
// TryRefresh fetches fresh releases if the cache is older than minAge.
// If a fetch is already in progress it returns immediately (non-blocking).
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 // keep existing cache rather than replacing with empty
}
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)
}
// Keep first match per os/arch (most specific asset listed first by GitHub)
if _, exists := r.Assets[os][arch]; !exists {
r.Assets[os][arch] = a.BrowserDownloadURL
}
}
releases = append(releases, r)
}
return releases, nil
}
// parseTag splits a tag like "4.0.0-q-canary.12" into base version "4.0.0" and build number 12.
// Returns build 0 for stable tags.
func parseTag(tag string) (version string, canaryBuild int) {
// Canary: "4.0.0-q-canary.12" → ("4.0.0", 12)
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
}
}
// Stable Quine builds: "4.0.0-q" or "4.0.0q" → "4.0.0"
tag = strings.TrimSuffix(tag, "-q")
tag = strings.TrimSuffix(tag, "q")
return tag, 0
}
// classifyAsset maps an asset filename to an OS and architecture.
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
}
}

View file

@ -1,36 +0,0 @@
package http_test
import (
"io"
http2 "net/http"
"strings"
"testing"
"time"
"maragu.dev/is"
"app/http"
)
func TestServer_Start(t *testing.T) {
t.Run("can start and stop server", func(t *testing.T) {
s := http.NewServer(http.NewServerOptions{})
go func() {
is.NotError(t, s.Start())
}()
defer func() {
is.NotError(t, s.Stop())
}()
// I know we could check that the server is running here, but it's easier to just wait a bit
time.Sleep(10 * time.Millisecond)
res, err := http2.Get("http://localhost:8081/")
is.NotError(t, err)
is.Equal(t, http2.StatusOK, res.StatusCode)
body, err := io.ReadAll(res.Body)
is.NotError(t, err)
is.True(t, strings.HasPrefix(string(body), "<!doctype html>"))
})
}

View file

@ -1,191 +0,0 @@
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
}

41
sites/hyper/mockup.go Normal file
View file

@ -0,0 +1,41 @@
package hyper
import (
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
"app/internal/component"
)
// terminalMockup is the terminal device-window body shown on the landing page.
func terminalMockup() Node {
return component.DeviceWindow("quine-hyper — bash — 80×24",
Div(Class("p-6 font-mono text-sm leading-relaxed"),
termPrompt("deep-thought --answer"),
termOutput("42"),
termPrompt("hyper install infinite-improbability"),
termOutput("✔ Engaging drive..."),
termOutput("✔ Probability: 2:1 against."),
termOutput("✔ Don't Panic."),
termPrompt("ls ~/projects"),
termOutput("drwxr-xr-x earth/"),
termOutput("drwxr-xr-x magrathea/"),
termOutput("drwxr-xr-x slartibartfast/"),
Div(Class("flex items-center gap-0 text-primary-400"),
Span(Class("text-zinc-500"), Text("❯ ")),
Span(Class("animate-pulse"), Text("█")),
),
),
)
}
func termPrompt(cmd string) Node {
return Div(Class("flex items-center gap-2"),
Span(Class("text-primary-400"), Text("❯")),
Span(Class("text-white"), Text(cmd)),
)
}
func termOutput(line string) Node {
return Div(Class("pl-5 text-zinc-400"), Text(line))
}

View file

@ -1,11 +0,0 @@
{
"name": "hyper-quineglobal-com",
"private": true,
"devDependencies": {
"tailwindcss": "^3.4.0"
},
"scripts": {
"css": "tailwindcss -i public/styles/input.css -o public/styles/app.css --minify",
"css:watch": "tailwindcss -i public/styles/input.css -o public/styles/app.css --watch"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

File diff suppressed because one or more lines are too long

View file

@ -1,10 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-zinc-950 text-zinc-100;
font-family: 'JetBrains Mono', ui-monospace, monospace;
}
}

View file

@ -1,19 +0,0 @@
#!/bin/sh
# Force an immediate re-fetch of GitHub release assets.
# Usage (from host): ./scripts/refresh-releases.sh [http://host:8081]
# Usage (in container): ./scripts/refresh-releases.sh
set -e
url="${1:-http://localhost:8081}/internal/refresh"
if command -v curl >/dev/null 2>&1; then
curl -fsS -X POST "$url"
elif command -v wget >/dev/null 2>&1; then
wget -q -O- --post-data='' "$url"
else
echo "error: curl or wget required" >&2
exit 1
fi
echo
echo "Releases refreshed."

44
sites/hyper/site.go Normal file
View file

@ -0,0 +1,44 @@
// Package hyper holds the Quine Hyper site's identity: branding, asset
// classification, download-format list, and landing-page content.
// Everything else (server, routes, release cache, update-check protocol,
// page chrome) lives in the shared internal/site and internal/component
// packages.
package hyper
import (
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
"app/internal/component"
"app/internal/site"
)
// Config is the Quine Hyper site configuration, passed to site.NewServer.
var Config = site.Config{
Branding: component.Branding{
Name: "Quine Hyper",
LogoPath: "/images/hyper-logo.png",
GitHubURL: "https://github.com/quine-global/hyper",
FooterNote: Group{
Text("Built on "),
A(Href("https://hyper.is"), Class("text-zinc-400 hover:text-white underline"), Text("Hyper")),
Text(" by Vercel."),
},
},
DownloadConfig: component.DownloadConfig{
DefaultLinuxOS: "linux",
Formats: []component.LinuxFormat{
{OS: "linux", Label: "AppImage", Archs: []string{"arm64", "x64"}},
{OS: "linux-deb", Label: "DEB", Archs: []string{"arm64", "x64"}},
{OS: "linux-rpm", Label: "RPM", Archs: []string{"arm64", "x64"}},
{OS: "linux-pacman", Label: "Pacman", Archs: []string{"arm64", "x64"}},
{OS: "linux-flatpak", Label: "Flatpak", Archs: []string{"arm64", "x64"}},
// Hyper only ships Snap for x64.
{OS: "linux-snap", Label: "Snap", Archs: []string{"x64"}},
},
},
RepoOwner: "quine-global",
RepoName: "hyper",
ClassifyAsset: classifyAsset,
HomeContent: HomeContent,
}

View file

@ -1,25 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./html/**/*.go", "./http/**/*.go"],
theme: {
extend: {
colors: {
primary: {
300: '#c4b5fd',
400: '#a78bfa',
500: '#8b5cf6',
},
seafoam: {
400: '#50d9a8',
500: '#3db88a',
},
canary: {
300: '#fde047',
400: '#facc15',
500: '#eab308',
},
},
},
},
plugins: [],
}

View file

@ -0,0 +1,56 @@
package quintodrome
import "strings"
// classifyAsset maps an asset filename to an OS and architecture.
func classifyAsset(name string) (os, arch string, ok bool) {
l := strings.ToLower(name)
isArm64 := strings.Contains(l, "arm64") || strings.Contains(l, "aarch64")
isArmv7l := strings.Contains(l, "armv7l")
switch {
case strings.HasSuffix(l, ".exe"):
return "windows", "x64", true
case isArm64 && strings.HasSuffix(l, ".dmg"):
return "mac", "arm64", true
case strings.HasSuffix(l, ".dmg"):
return "mac", "x64", true
case isArm64 && strings.HasSuffix(l, ".appimage"):
return "linux", "arm64", true
case isArmv7l && strings.HasSuffix(l, ".appimage"):
return "", "", false
case strings.HasSuffix(l, ".appimage"):
return "linux", "x64", true
case isArm64 && strings.HasSuffix(l, ".rpm"):
return "linux-rpm", "arm64", true
case isArmv7l && strings.HasSuffix(l, ".rpm"):
return "", "", false
case strings.HasSuffix(l, ".rpm"):
return "linux-rpm", "x64", true
case isArm64 && strings.HasSuffix(l, ".deb"):
return "linux-deb", "arm64", true
case isArmv7l && strings.HasSuffix(l, ".deb"):
return "", "", false
case strings.HasSuffix(l, ".deb"):
return "linux-deb", "x64", true
case isArmv7l && strings.HasSuffix(l, ".snap"):
return "", "", false
case isArm64 && strings.HasSuffix(l, ".snap"):
return "linux-snap", "arm64", true
case strings.HasSuffix(l, ".snap"):
return "linux-snap", "x64", true
case isArm64 && strings.HasSuffix(l, ".pacman"):
return "linux-pacman", "arm64", true
case isArmv7l && strings.HasSuffix(l, ".pacman"):
return "", "", false
case strings.HasSuffix(l, ".pacman"):
return "linux-pacman", "x64", true
case isArm64 && strings.HasSuffix(l, ".flatpak"):
return "linux-flatpak", "arm64", true
case isArmv7l && strings.HasSuffix(l, ".flatpak"):
return "", "", false
case strings.HasSuffix(l, ".flatpak"):
return "linux-flatpak", "x64", true
default:
return "", "", false
}
}

View file

@ -1,4 +1,4 @@
package http package quintodrome
import ( import (
"testing" "testing"
@ -98,26 +98,3 @@ func TestClassifyAsset(t *testing.T) {
}) })
} }
} }
func TestParseTag(t *testing.T) {
cases := []struct {
tag string
wantVer string
wantBuild int
}{
{"4.0.0", "4.0.0", 0},
{"4.0.0-q", "4.0.0", 0},
{"4.0.0q", "4.0.0", 0},
{"4.0.0-q-canary.12", "4.0.0", 12},
{"4.0.0-q-canary.1", "4.0.0", 1},
{"1.0.0-alpha", "1.0.0-alpha", 0},
}
for _, c := range cases {
t.Run(c.tag, func(t *testing.T) {
gotVer, gotBuild := parseTag(c.tag)
is.Equal(t, c.wantVer, gotVer)
is.Equal(t, c.wantBuild, gotBuild)
})
}
}

View file

@ -10,7 +10,8 @@ import (
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"maragu.dev/env" "maragu.dev/env"
"app/sites/quintodrome/http" "app/internal/site"
"app/sites/quintodrome"
) )
func main() { func main() {
@ -35,8 +36,9 @@ func start(log *slog.Logger) error {
defer stop() defer stop()
// Set up the HTTP server, injecting the database and logger // Set up the HTTP server, injecting the database and logger
s := http.NewServer(http.NewServerOptions{ s := site.NewServer(site.NewServerOptions{
Log: log, Log: log,
Config: quintodrome.Config,
}) })
// Use an errgroup to wait for separate goroutines which can error // Use an errgroup to wait for separate goroutines which can error

View file

@ -1,16 +1,19 @@
package html package quintodrome
import ( import (
. "maragu.dev/gomponents" . "maragu.dev/gomponents"
. "maragu.dev/gomponents/html" . "maragu.dev/gomponents/html"
"app/internal/component"
"app/internal/site"
) )
// HomePage is the Quintodrome landing page. // HomeContent is the Quintodrome landing page.
func HomePage(props PageProps) Node { func HomeContent(cfg site.Config, props component.PageProps) Node {
props.Title = "Quintodrome — Your music, self-hosted" props.Title = "Quintodrome — Your music, self-hosted"
props.Description = "Quintodrome wraps Navidrome, your self-hosted music server, into a native desktop app for macOS, Windows, and Linux." props.Description = "Quintodrome wraps Navidrome, your self-hosted music server, into a native desktop app for macOS, Windows, and Linux."
return page(props, return component.Page(cfg.Branding, props,
// Hero // Hero
Div(Class("py-20 text-center"), Div(Class("py-20 text-center"),
Div(Class("mb-6 inline-flex items-center gap-2 rounded-full border border-zinc-700 bg-zinc-900 px-4 py-1.5"), Div(Class("mb-6 inline-flex items-center gap-2 rounded-full border border-zinc-700 bg-zinc-900 px-4 py-1.5"),
@ -28,7 +31,7 @@ func HomePage(props PageProps) Node {
Class("inline-flex items-center gap-2 rounded-lg bg-primary-500 px-8 py-3 font-mono text-base font-semibold text-black hover:bg-primary-400 transition-colors"), Class("inline-flex items-center gap-2 rounded-lg bg-primary-500 px-8 py-3 font-mono text-base font-semibold text-black hover:bg-primary-400 transition-colors"),
Text("Download for free"), Text("Download for free"),
), ),
A(Href("https://github.com/quine-global/quintodrome"), A(Href(cfg.GitHubURL),
Class("inline-flex items-center gap-2 rounded-lg border border-zinc-700 bg-zinc-900 px-8 py-3 font-mono text-base font-semibold text-white hover:border-zinc-500 transition-colors"), Class("inline-flex items-center gap-2 rounded-lg border border-zinc-700 bg-zinc-900 px-8 py-3 font-mono text-base font-semibold text-white hover:border-zinc-500 transition-colors"),
Text("View on GitHub"), Text("View on GitHub"),
), ),
@ -36,39 +39,7 @@ func HomePage(props PageProps) Node {
), ),
// Player mockup // Player mockup
Div(Class("mx-auto mb-20 max-w-3xl overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-2xl"), musicPlayerMockup(),
// Title bar
Div(Class("flex items-center gap-2 border-b border-zinc-800 bg-zinc-900 px-4 py-3"),
Div(Class("h-3 w-3 rounded-full bg-red-500 opacity-80")),
Div(Class("h-3 w-3 rounded-full bg-yellow-500 opacity-80")),
Div(Class("h-3 w-3 rounded-full bg-primary-500 opacity-80")),
Span(Class("ml-4 font-mono text-xs text-zinc-500"), Text("quintodrome — now playing")),
),
// Now-playing body
Div(Class("p-8"),
Div(Class("flex items-center gap-5"),
Div(Class("h-20 w-20 shrink-0 rounded-lg bg-gradient-to-br from-primary-400 to-primary-500")),
Div(Class("min-w-0 text-left"),
P(Class("truncate font-mono text-base font-semibold text-white"), Text("Brandenburg Concerto No. 3")),
P(Class("mt-1 truncate font-mono text-sm text-zinc-400"), Text("J.S. Bach — Library/Classical")),
),
),
Div(Class("mt-6"),
Div(Class("h-1.5 w-full overflow-hidden rounded-full bg-zinc-800"),
Div(Class("h-full w-1/3 rounded-full bg-primary-400")),
),
Div(Class("mt-2 flex justify-between font-mono text-xs text-zinc-500"),
Span(Text("1:42")),
Span(Text("5:07")),
),
),
Div(Class("mt-6 flex items-center justify-center gap-6 font-mono text-lg text-zinc-400"),
Span(Text("⏮")),
Span(Class("text-2xl text-white"), Text("⏸")),
Span(Text("⏭")),
),
),
),
// Features // Features
Div(Class("pb-20"), Div(Class("pb-20"),
@ -76,15 +47,15 @@ func HomePage(props PageProps) Node {
Text("Why Quintodrome"), Text("Why Quintodrome"),
), ),
Div(Class("mt-12 grid grid-cols-1 gap-6 md:grid-cols-2"), Div(Class("mt-12 grid grid-cols-1 gap-6 md:grid-cols-2"),
featureCard("Zero Setup", component.FeatureCard("Zero Setup",
Text("Launch the app and it provisions and starts its own music server for you — no admin account to create, no config file to write."), Text("Launch the app and it provisions and starts its own music server for you — no admin account to create, no config file to write."),
"double-click → listening", "double-click → listening",
), ),
featureCard("Cross-Platform", component.FeatureCard("Cross-Platform",
Text("Native apps for macOS, Windows, and Linux, all wrapping the same self-hosted server underneath."), Text("Native apps for macOS, Windows, and Linux, all wrapping the same self-hosted server underneath."),
"Works everywhere you do.", "Works everywhere you do.",
), ),
featureCard("Your Library, Your Rules", component.FeatureCard("Your Library, Your Rules",
Group{ Group{
Text("Point it at your music folder and it's yours: no subscription, no catalog gaps, no algorithm. Built on "), Text("Point it at your music folder and it's yours: no subscription, no catalog gaps, no algorithm. Built on "),
A(Href("https://www.navidrome.org"), A(Href("https://www.navidrome.org"),
@ -95,7 +66,7 @@ func HomePage(props PageProps) Node {
}, },
"~/Music → your library", "~/Music → your library",
), ),
featureCard("Subsonic Compatible", component.FeatureCard("Subsonic Compatible",
Text("Speaks the Subsonic/OpenSubsonic API, so your library also works with the mobile and desktop clients you already use."), Text("Speaks the Subsonic/OpenSubsonic API, so your library also works with the mobile and desktop clients you already use."),
"stream anywhere, from one server", "stream anywhere, from one server",
), ),
@ -112,7 +83,7 @@ func HomePage(props PageProps) Node {
Text(", the open-source self-hosted music server. We wrap it in a native desktop shell so there's nothing to install or configure separately."), Text(", the open-source self-hosted music server. We wrap it in a native desktop shell so there's nothing to install or configure separately."),
), ),
Div(Class("mt-8"), Div(Class("mt-8"),
A(Href("https://github.com/quine-global/quintodrome"), A(Href(cfg.GitHubURL),
Class("inline-block rounded-lg border border-zinc-700 bg-zinc-800 px-6 py-3 font-mono text-sm font-semibold text-white hover:border-zinc-500 transition-colors"), Class("inline-block rounded-lg border border-zinc-700 bg-zinc-800 px-6 py-3 font-mono text-sm font-semibold text-white hover:border-zinc-500 transition-colors"),
Text("Star us on GitHub"), Text("Star us on GitHub"),
), ),
@ -120,13 +91,3 @@ func HomePage(props PageProps) Node {
), ),
) )
} }
func featureCard(title string, body Node, code string) Node {
return Div(Class("rounded-xl border border-zinc-800 bg-zinc-900 p-8"),
H3(Class("font-mono text-base font-semibold text-white"), Text(title)),
P(Class("mt-3 font-mono text-sm text-zinc-400 leading-relaxed"), body),
Div(Class("mt-5 inline-block rounded-md bg-zinc-800 px-3 py-1.5"),
Span(Class("font-mono text-xs text-primary-400"), Text(code)),
),
)
}

View file

@ -1,109 +0,0 @@
// Package HTML holds all the common HTML components and utilities.
package html
import (
"crypto/sha256"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/components"
. "maragu.dev/gomponents/html"
)
var hashOnce sync.Once
var appCSSPath string
// PageProps are properties for the [page] component.
type PageProps struct {
Title string
Description string
}
// page layout with nav, main content, and footer.
func page(props PageProps, children ...Node) Node {
hashOnce.Do(func() {
appCSSPath = getHashedPath("public/styles/app.css")
})
return HTML5(HTML5Props{
Title: props.Title,
Description: props.Description,
Language: "en",
Head: []Node{
Link(Rel("stylesheet"), Href(appCSSPath)),
Link(Rel("preconnect"), Href("https://fonts.googleapis.com")),
Link(Rel("preconnect"), Href("https://fonts.gstatic.com"), Attr("crossorigin", "")),
Link(Rel("stylesheet"), Href("https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap")),
Meta(Name("viewport"), Content("width=device-width, initial-scale=1")),
},
Body: Group{
siteNav(),
Main(Class("mx-auto max-w-5xl px-6 py-12"),
Group(children),
),
siteFooter(),
},
})
}
func siteNav() Node {
return Header(Class("border-b border-zinc-800 bg-zinc-950"),
Div(Class("mx-auto flex max-w-5xl items-center justify-between px-6 py-4"),
A(Href("/"), Class("flex items-center gap-3 no-underline"),
Img(Src("/images/quintodrome-logo.png"), Alt("Quintodrome"), Class("h-8 w-8 rounded")),
Span(Class("font-mono text-lg font-bold tracking-tight text-white"),
Text("Quintodrome"),
),
),
Div(Class("flex items-center gap-4"),
A(Href("https://github.com/quine-global/quintodrome"),
Class("font-mono text-sm text-zinc-400 hover:text-white transition-colors"),
Text("GitHub"),
),
A(Href("/download"),
Class("rounded-md bg-primary-500 px-4 py-2 font-mono text-sm font-semibold text-black hover:bg-primary-400 transition-colors"),
Text("Download"),
),
),
),
)
}
func siteFooter() Node {
return Footer(Class("mt-24 border-t border-zinc-800 py-8"),
Div(Class("mx-auto flex max-w-5xl flex-col items-center justify-between gap-4 px-6 sm:flex-row"),
Div(Class("flex items-center gap-3"),
Img(Src("/images/quineglobal-logo.png"), Alt("Quine Global"), Class("h-6 w-6 rounded-full")),
Span(Class("font-mono text-sm text-zinc-500"),
Text("© 2026 Quine Global. Built on "),
A(Href("https://www.navidrome.org"), Class("text-zinc-400 hover:text-white underline"), Text("Navidrome")),
Text("."),
),
),
Div(Class("flex gap-6 font-mono text-sm"),
A(Href("/"), Class("text-zinc-500 hover:text-white transition-colors"), Text("Home")),
A(Href("https://github.com/quine-global/quintodrome"), Class("text-zinc-500 hover:text-white transition-colors"), Text("GitHub")),
A(Href("/download"), Class("text-zinc-500 hover:text-white transition-colors"), Text("Download")),
),
),
)
}
func getHashedPath(path string) string {
externalPath := strings.TrimPrefix(path, "public")
ext := filepath.Ext(path)
if ext == "" {
panic("no extension found")
}
data, err := os.ReadFile(path)
if err != nil {
return fmt.Sprintf("%v.x%v", strings.TrimSuffix(externalPath, ext), ext)
}
return fmt.Sprintf("%v.%x%v", strings.TrimSuffix(externalPath, ext), sha256.Sum256(data), ext)
}

View file

@ -1,20 +0,0 @@
package http
import (
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
)
// InternalRefresh registers POST /internal/refresh to force an immediate GitHub release cache refresh.
func (s *Server) InternalRefresh(r chi.Router) {
r.Post("/internal/refresh", func(w http.ResponseWriter, r *http.Request) {
if err := s.releases.ForceRefresh(r.Context()); err != nil {
http.Error(w, "refresh failed: "+err.Error(), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "text/plain")
fmt.Fprintln(w, "ok")
})
}

View file

@ -1,30 +0,0 @@
package http
import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"maragu.dev/httph"
)
// setupRoutes for the server.
func (s *Server) setupRoutes() {
s.InternalRefresh(s.mux)
s.UpdateCheck(s.mux)
s.mux.Group(func(r chi.Router) {
r.Use(middleware.Compress(5))
// Sets up a static file handler with cache busting middleware.
r.Group(func(r chi.Router) {
r.Use(httph.VersionedAssets)
Static(r)
})
s.Home(r)
})
// Also expose the update feed at the short /update path for convenient
// feed URLs (e.g. https://quintodrome.quineglobal.com/update?platform=...&version=...)
s.mux.Get("/update", s.updateCheckHandler())
}

View file

@ -1,83 +0,0 @@
// Package http has the [Server] and HTTP handlers.
package http
import (
"context"
"errors"
"io"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
)
// Server holds dependencies for the HTTP server as well as the HTTP server itself.
type Server struct {
log *slog.Logger
mux chi.Router
server *http.Server
releases *ReleaseCache
}
type NewServerOptions struct {
Log *slog.Logger
}
func NewServer(opts NewServerOptions) *Server {
if opts.Log == nil {
opts.Log = slog.New(slog.NewTextHandler(io.Discard, nil))
}
mux := chi.NewMux()
return &Server{
log: opts.Log,
mux: mux,
releases: &ReleaseCache{log: opts.Log},
server: &http.Server{
Addr: ":8081",
Handler: mux,
ReadTimeout: 5 * time.Second,
ReadHeaderTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
IdleTimeout: 5 * time.Second,
},
}
}
// Start the server and set up routes.
func (s *Server) Start() error {
s.log.Info("Starting http server", "address", "http://localhost:8081")
s.setupRoutes()
// Fetch releases immediately, then refresh every hour in the background.
go s.releases.TryRefresh(context.Background(), 0)
go func() {
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for range ticker.C {
s.releases.TryRefresh(context.Background(), time.Hour)
}
}()
if err := s.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
// Stop the server gracefully.
func (s *Server) Stop() error {
s.log.Info("Stopping http server")
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
if err := s.server.Shutdown(ctx); err != nil {
return err
}
s.log.Info("Stopped http server")
return nil
}

View file

@ -1,36 +0,0 @@
package http_test
import (
"io"
http2 "net/http"
"strings"
"testing"
"time"
"maragu.dev/is"
"app/sites/quintodrome/http"
)
func TestServer_Start(t *testing.T) {
t.Run("can start and stop server", func(t *testing.T) {
s := http.NewServer(http.NewServerOptions{})
go func() {
is.NotError(t, s.Start())
}()
defer func() {
is.NotError(t, s.Stop())
}()
// I know we could check that the server is running here, but it's easier to just wait a bit
time.Sleep(10 * time.Millisecond)
res, err := http2.Get("http://localhost:8081/")
is.NotError(t, err)
is.Equal(t, http2.StatusOK, res.StatusCode)
body, err := io.ReadAll(res.Body)
is.NotError(t, err)
is.True(t, strings.HasPrefix(string(body), "<!doctype html>"))
})
}

View file

@ -1,15 +0,0 @@
package http
import (
"net/http"
"github.com/go-chi/chi/v5"
)
// Static assets handler, which serves files from the root that have an extension, and everything from
// the images, scripts, and styles directories.
func Static(r chi.Router) {
staticHandler := http.FileServer(http.Dir("public"))
r.Get(`/{:[^.]+\.[^.]+}`, staticHandler.ServeHTTP)
r.Get(`/{:images|scripts|styles}/*`, staticHandler.ServeHTTP)
}

View file

@ -1,93 +0,0 @@
package http
import (
"testing"
"maragu.dev/is"
"app/sites/quintodrome/html"
)
func TestSemverCmp(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"4.0.0", "4.0.0", 0},
{"4.1.0", "4.0.0", 1},
{"4.0.0", "4.1.0", -1},
{"4.0.1", "4.0.0", 1},
{"5.0.0", "4.9.9", 1},
{"4.10.0", "4.9.0", 1},
}
for _, c := range cases {
t.Run(c.a+"_vs_"+c.b, func(t *testing.T) {
is.Equal(t, c.want, semverCmp(c.a, c.b))
})
}
}
func TestNewerCanary(t *testing.T) {
rel := func(base string, build int) html.Release {
return html.Release{Version: base, CanaryBuild: build, IsCanary: true}
}
t.Run("higher base is newer for canary client", func(t *testing.T) {
is.True(t, newerCanary(rel("4.1.0", 1), "4.0.0", 5, true))
})
t.Run("same base higher build is newer for canary client", func(t *testing.T) {
is.True(t, newerCanary(rel("4.0.0", 6), "4.0.0", 5, true))
})
t.Run("same base same build is not newer for canary client", func(t *testing.T) {
is.True(t, !newerCanary(rel("4.0.0", 5), "4.0.0", 5, true))
})
t.Run("lower base is not newer for canary client", func(t *testing.T) {
is.True(t, !newerCanary(rel("3.9.0", 99), "4.0.0", 1, true))
})
t.Run("same base canary is newer than stable client", func(t *testing.T) {
is.True(t, newerCanary(rel("4.0.0", 1), "4.0.0", 0, false))
})
t.Run("higher base canary is newer than stable client", func(t *testing.T) {
is.True(t, newerCanary(rel("4.1.0", 1), "4.0.0", 0, false))
})
t.Run("lower base canary is not newer than stable client", func(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"},
{"linux", "linux"},
{"appimage", "linux"},
{"deb", "linux-deb"},
{"linux-deb", "linux-deb"},
{"linux-rpm", "linux-rpm"},
{"rpm", "linux-rpm"},
{"linux-snap", "linux-snap"},
{"snap", "linux-snap"},
{"linux-pacman", "linux-pacman"},
{"pacman", "linux-pacman"},
{"linux-flatpak", "linux-flatpak"},
{"flatpak", "linux-flatpak"},
{"", ""},
}
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("", ""))
}

View file

@ -0,0 +1,38 @@
package quintodrome
import (
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
"app/internal/component"
)
// musicPlayerMockup is the "now playing" device-window body shown on the
// landing page.
func musicPlayerMockup() Node {
return component.DeviceWindow("quintodrome — now playing",
Div(Class("p-8"),
Div(Class("flex items-center gap-5"),
Div(Class("h-20 w-20 shrink-0 rounded-lg bg-gradient-to-br from-primary-400 to-primary-500")),
Div(Class("min-w-0 text-left"),
P(Class("truncate font-mono text-base font-semibold text-white"), Text("Brandenburg Concerto No. 3")),
P(Class("mt-1 truncate font-mono text-sm text-zinc-400"), Text("J.S. Bach — Library/Classical")),
),
),
Div(Class("mt-6"),
Div(Class("h-1.5 w-full overflow-hidden rounded-full bg-zinc-800"),
Div(Class("h-full w-1/3 rounded-full bg-primary-400")),
),
Div(Class("mt-2 flex justify-between font-mono text-xs text-zinc-500"),
Span(Text("1:42")),
Span(Text("5:07")),
),
),
Div(Class("mt-6 flex items-center justify-center gap-6 font-mono text-lg text-zinc-400"),
Span(Text("⏮")),
Span(Class("text-2xl text-white"), Text("⏸")),
Span(Text("⏭")),
),
),
)
}

44
sites/quintodrome/site.go Normal file
View file

@ -0,0 +1,44 @@
// Package quintodrome holds the Quintodrome site's identity: branding,
// asset classification, download-format list, and landing-page content.
// Everything else (server, routes, release cache, update-check protocol,
// page chrome) lives in the shared internal/site and internal/component
// packages.
package quintodrome
import (
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
"app/internal/component"
"app/internal/site"
)
// Config is the Quintodrome site configuration, passed to site.NewServer.
var Config = site.Config{
Branding: component.Branding{
Name: "Quintodrome",
LogoPath: "/images/quintodrome-logo.png",
GitHubURL: "https://github.com/quine-global/quintodrome",
FooterNote: Group{
Text("Built on "),
A(Href("https://www.navidrome.org"), Class("text-zinc-400 hover:text-white underline"), Text("Navidrome")),
Text("."),
},
},
DownloadConfig: component.DownloadConfig{
// Quintodrome currently ships Linux builds as Flatpak/Snap only (no AppImage).
DefaultLinuxOS: "linux-flatpak",
Formats: []component.LinuxFormat{
{OS: "linux-flatpak", Label: "Flatpak", Archs: []string{"arm64", "x64"}},
{OS: "linux-snap", Label: "Snap", Archs: []string{"arm64", "x64"}},
{OS: "linux", Label: "AppImage", Archs: []string{"arm64", "x64"}},
{OS: "linux-deb", Label: "DEB", Archs: []string{"arm64", "x64"}},
{OS: "linux-rpm", Label: "RPM", Archs: []string{"arm64", "x64"}},
{OS: "linux-pacman", Label: "Pacman", Archs: []string{"arm64", "x64"}},
},
},
RepoOwner: "quine-global",
RepoName: "quintodrome",
ClassifyAsset: classifyAsset,
HomeContent: HomeContent,
}

View file

@ -1,6 +1,6 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
content: ["./html/**/*.go", "./http/**/*.go"], content: ["./internal/**/*.go", "./sites/**/*.go"],
theme: { theme: {
extend: { extend: {
colors: { colors: {