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:
parent
79e9269673
commit
450d38c6da
60 changed files with 673 additions and 1984 deletions
62
CLAUDE.md
62
CLAUDE.md
|
|
@ -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
|
||||
|
||||
All commands take `SITE=quintodrome` (default) or `SITE=hyper`.
|
||||
|
||||
```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
|
||||
make dev # start Go server + Tailwind watcher for SITE (Ctrl-C stops both)
|
||||
make dev SITE=hyper # same, for the hyper site
|
||||
make start # Go server only
|
||||
make test # go test -shuffle on ./... (covers every site)
|
||||
make css # one-shot Tailwind build (shared across sites)
|
||||
make lint # golangci-lint
|
||||
make build # Docker image for SITE (linux/amd64)
|
||||
make push # build + push to forge.quinefoundation.com
|
||||
```
|
||||
|
||||
## 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`.
|
||||
|
||||
```
|
||||
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
|
||||
internal/site/ shared server framework: Server, routes, release cache, update-check protocol
|
||||
internal/component/ shared gomponents chrome: page/nav/footer, download page, FeatureCard, DeviceWindow
|
||||
sites/quintodrome/ Quintodrome's Config, classifyAsset, landing-page content, cmd/main.go
|
||||
sites/hyper/ Quine Hyper's Config, classifyAsset, landing-page content, cmd/main.go
|
||||
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
|
||||
|
||||
**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
|
||||
|
||||
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`
|
||||
- `http/updatecheck_test.go` — `semverCmp`, `newerCanary`, `normalizePlatformForAssets`
|
||||
- `http/server_test.go` — integration test that starts the real server
|
||||
- `sites/<site>/assets_test.go` — per-site `classifyAsset` cases
|
||||
- `internal/site/releases_test.go` — shared `parseTag`
|
||||
- `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`
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ WORKDIR /app
|
|||
RUN set -x && apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y curl
|
||||
|
||||
COPY html ./html/
|
||||
COPY internal ./internal/
|
||||
COPY sites ./sites/
|
||||
|
||||
FROM golang AS gobuilder
|
||||
WORKDIR /app
|
||||
|
|
@ -15,7 +16,8 @@ RUN go mod download
|
|||
COPY . ./
|
||||
|
||||
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
|
||||
WORKDIR /app
|
||||
|
|
|
|||
11
Makefile
11
Makefile
|
|
@ -1,14 +1,17 @@
|
|||
# SITE selects which site under sites/ to build/run: quintodrome or hyper.
|
||||
SITE ?= quintodrome
|
||||
|
||||
.PHONY: benchmark
|
||||
benchmark:
|
||||
go test -bench=.
|
||||
|
||||
.PHONY: 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
|
||||
push: build
|
||||
docker push forge.quinefoundation.com/ironmagma/quintodrome-quineglobal-com
|
||||
docker push forge.quinefoundation.com/ironmagma/$(SITE)-quineglobal-com
|
||||
|
||||
.PHONY: cover
|
||||
cover:
|
||||
|
|
@ -24,7 +27,7 @@ css-watch:
|
|||
|
||||
.PHONY: 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
|
||||
install:
|
||||
|
|
@ -36,7 +39,7 @@ lint:
|
|||
|
||||
.PHONY: start
|
||||
start:
|
||||
go run ./cmd/app
|
||||
go run ./sites/$(SITE)/cmd
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
package html
|
||||
package component
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
|
@ -47,6 +47,40 @@ type DownloadProps struct {
|
|||
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 {
|
||||
OS 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.
|
||||
func DownloadPage(props PageProps, dl DownloadProps) Node {
|
||||
props.Title = "Download — Quintodrome"
|
||||
props.Description = "Download Quintodrome for macOS, Windows, or Linux."
|
||||
func DownloadPage(b Branding, dc DownloadConfig, props PageProps, dl DownloadProps) Node {
|
||||
props.Title = "Download — " + b.Name
|
||||
props.Description = "Download " + b.Name + " for macOS, Windows, or Linux."
|
||||
|
||||
// Split into stable and canary.
|
||||
var stable, canary []Release
|
||||
|
|
@ -112,41 +146,41 @@ func DownloadPage(props PageProps, dl DownloadProps) Node {
|
|||
|
||||
content := []Node{
|
||||
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"),
|
||||
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)),
|
||||
Group(platformButtons(dc, dl)),
|
||||
),
|
||||
If(strings.HasPrefix(dl.SelectedOS, "linux"),
|
||||
linuxFormatToggle(dl),
|
||||
linuxFormatToggle(dc, dl),
|
||||
),
|
||||
),
|
||||
}
|
||||
if len(stable) > 0 {
|
||||
content = append(content, stableSection(stable, dl))
|
||||
content = append(content, stableSection(b, stable, dl))
|
||||
}
|
||||
if len(canary) > 0 {
|
||||
content = append(content, canarySection(canary, dl))
|
||||
content = append(content, canarySection(b, canary, dl))
|
||||
}
|
||||
content = append(content,
|
||||
Div(Class("mt-6 text-center"),
|
||||
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"),
|
||||
Text("All releases on GitHub ↗"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
return page(props,
|
||||
return Page(b, props,
|
||||
Div(Class("mx-auto max-w-xl py-16"),
|
||||
Group(content),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func stableSection(stable []Release, dl DownloadProps) Node {
|
||||
func stableSection(b Branding, stable []Release, dl DownloadProps) Node {
|
||||
latest := stable[0]
|
||||
older := stable[1:]
|
||||
url := latest.DownloadURL(dl.SelectedOS, dl.SelectedArch)
|
||||
|
|
@ -165,7 +199,7 @@ func stableSection(stable []Release, dl DownloadProps) Node {
|
|||
Text("Latest Stable"),
|
||||
),
|
||||
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"),
|
||||
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]
|
||||
older := canary[1:]
|
||||
url := latest.DownloadURL(dl.SelectedOS, dl.SelectedArch)
|
||||
|
|
@ -208,7 +242,7 @@ func canarySection(canary []Release, dl DownloadProps) Node {
|
|||
Text("Latest Canary"),
|
||||
),
|
||||
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"),
|
||||
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
|
||||
}
|
||||
|
||||
func platformButtons(dl DownloadProps) []Node {
|
||||
func platformButtons(dc DownloadConfig, dl DownloadProps) []Node {
|
||||
nodes := make([]Node, len(platforms))
|
||||
// Treat any linux-* variant as linux for platform button highlight/detection purposes.
|
||||
effectiveSelectedOS := dl.SelectedOS
|
||||
|
|
@ -291,9 +325,10 @@ func platformButtons(dl DownloadProps) []Node {
|
|||
isSelected := p.OS == effectiveSelectedOS && p.Arch == dl.SelectedArch
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -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"
|
||||
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 {
|
||||
|
|
@ -360,13 +395,12 @@ func linuxFormatToggle(dl DownloadProps) Node {
|
|||
return fmt.Sprintf("/download?os=%s&arch=%s", targetOS, dl.SelectedArch)
|
||||
}
|
||||
|
||||
buttons := []Node{
|
||||
A(Href(href("linux-flatpak")), Class(cls("linux-flatpak")), Text("Flatpak")),
|
||||
A(Href(href("linux-snap")), Class(cls("linux-snap")), Text("Snap")),
|
||||
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")),
|
||||
var buttons []Node
|
||||
for _, f := range dc.Formats {
|
||||
if !containsString(f.Archs, dl.SelectedArch) {
|
||||
continue
|
||||
}
|
||||
buttons = append(buttons, A(Href(href(f.OS)), Class(cls(f.OS)), Text(f.Label)))
|
||||
}
|
||||
|
||||
return Div(Class("mt-4"),
|
||||
17
internal/component/featurecard.go
Normal file
17
internal/component/featurecard.go
Normal 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)),
|
||||
),
|
||||
)
|
||||
}
|
||||
22
internal/component/mockup.go
Normal file
22
internal/component/mockup.go
Normal 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,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
// Package HTML holds all the common HTML components and utilities.
|
||||
package html
|
||||
// Package component holds all the common HTML components and utilities
|
||||
// shared across sites. Site-specific branding is threaded through via
|
||||
// [Branding] and [DownloadConfig] rather than hardcoded.
|
||||
package component
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
|
|
@ -17,14 +19,26 @@ import (
|
|||
var hashOnce sync.Once
|
||||
var appCSSPath string
|
||||
|
||||
// PageProps are properties for the [page] component.
|
||||
// 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 {
|
||||
// Branding holds the per-site identity used by the shared page chrome.
|
||||
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() {
|
||||
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")),
|
||||
},
|
||||
Body: Group{
|
||||
siteNav(),
|
||||
siteNav(b),
|
||||
Main(Class("mx-auto max-w-5xl px-6 py-12"),
|
||||
Group(children),
|
||||
),
|
||||
siteFooter(),
|
||||
siteFooter(b),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func siteNav() Node {
|
||||
func siteNav(b Branding) 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/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"),
|
||||
Text("Quine Hyper"),
|
||||
Text(b.Name),
|
||||
),
|
||||
),
|
||||
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"),
|
||||
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"),
|
||||
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://hyper.is"), Class("text-zinc-400 hover:text-white underline"), Text("Hyper")),
|
||||
Text(" by Vercel."),
|
||||
Text("© 2026 Quine Global. "),
|
||||
b.FooterNote,
|
||||
),
|
||||
),
|
||||
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/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")),
|
||||
),
|
||||
),
|
||||
31
internal/site/config.go
Normal file
31
internal/site/config.go
Normal 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
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package http
|
||||
package site
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -10,20 +10,20 @@ import (
|
|||
. "maragu.dev/gomponents"
|
||||
ghttp "maragu.dev/gomponents/http"
|
||||
|
||||
"app/sites/quintodrome/html"
|
||||
"app/internal/component"
|
||||
)
|
||||
|
||||
// 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
|
||||
return s.config.HomeContent(s.config, component.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"))
|
||||
detectedOS, detectedArch := s.detectPlatform(r.Header.Get("User-Agent"))
|
||||
|
||||
selectedOS := detectedOS
|
||||
selectedArch := detectedArch
|
||||
|
|
@ -37,7 +37,7 @@ func (s *Server) Home(r chi.Router) {
|
|||
archExplicit = true
|
||||
}
|
||||
|
||||
return html.DownloadPage(html.PageProps{}, html.DownloadProps{
|
||||
return component.DownloadPage(s.config.Branding, s.config.DownloadConfig, component.PageProps{}, component.DownloadProps{
|
||||
DetectedOS: detectedOS,
|
||||
DetectedArch: detectedArch,
|
||||
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)
|
||||
switch {
|
||||
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"):
|
||||
return "mac", "arm64" // default Apple Silicon; user can override
|
||||
case strings.Contains(u, "linux"):
|
||||
// Quintodrome currently ships Linux builds as Flatpak/Snap only (no AppImage).
|
||||
return "linux-flatpak", "x64"
|
||||
return s.config.DefaultLinuxOS, "x64"
|
||||
default:
|
||||
return "mac", "arm64"
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package http
|
||||
package site
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
package http
|
||||
package site
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
|
@ -10,26 +11,29 @@ import (
|
|||
"sync"
|
||||
"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.
|
||||
type ReleaseCache struct {
|
||||
log *slog.Logger
|
||||
config Config
|
||||
mu sync.RWMutex
|
||||
releases []html.Release
|
||||
releases []component.Release
|
||||
lastFetched time.Time
|
||||
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.
|
||||
func (c *ReleaseCache) Get() []html.Release {
|
||||
func (c *ReleaseCache) Get() []component.Release {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
if len(c.releases) == 0 {
|
||||
return []html.Release{}
|
||||
return []component.Release{}
|
||||
}
|
||||
return c.releases
|
||||
}
|
||||
|
|
@ -40,7 +44,7 @@ func (c *ReleaseCache) ForceRefresh(ctx context.Context) error {
|
|||
c.fetchMu.Lock()
|
||||
defer c.fetchMu.Unlock()
|
||||
|
||||
releases, err := fetchReleases(ctx)
|
||||
releases, err := c.fetchReleases(ctx)
|
||||
if err != nil {
|
||||
c.log.Warn("Force refresh failed", "error", err)
|
||||
return err
|
||||
|
|
@ -74,7 +78,7 @@ func (c *ReleaseCache) TryRefresh(ctx context.Context, minAge time.Duration) {
|
|||
return
|
||||
}
|
||||
|
||||
releases, err := fetchReleases(ctx)
|
||||
releases, err := c.fetchReleases(ctx)
|
||||
if err != nil {
|
||||
c.log.Warn("Failed to fetch releases from GitHub", "error", err)
|
||||
return
|
||||
|
|
@ -104,8 +108,8 @@ type ghAsset struct {
|
|||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
}
|
||||
|
||||
func fetchReleases(ctx context.Context) ([]html.Release, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, releasesAPI, nil)
|
||||
func (c *ReleaseCache) fetchReleases(ctx context.Context) ([]component.Release, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.releasesAPI(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -123,13 +127,13 @@ func fetchReleases(ctx context.Context) ([]html.Release, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
releases := make([]html.Release, 0, len(raw))
|
||||
releases := make([]component.Release, 0, len(raw))
|
||||
for _, gr := range raw {
|
||||
if gr.Draft {
|
||||
continue
|
||||
}
|
||||
version, canaryBuild := parseTag(strings.TrimPrefix(gr.TagName, "v"))
|
||||
r := html.Release{
|
||||
r := component.Release{
|
||||
TagName: gr.TagName,
|
||||
Version: version,
|
||||
CanaryBuild: canaryBuild,
|
||||
|
|
@ -139,7 +143,7 @@ func fetchReleases(ctx context.Context) ([]html.Release, error) {
|
|||
Assets: make(map[string]map[string]string),
|
||||
}
|
||||
for _, a := range gr.Assets {
|
||||
os, arch, ok := classifyAsset(a.Name)
|
||||
os, arch, ok := c.config.ClassifyAsset(a.Name)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
|
@ -171,56 +175,3 @@ func parseTag(tag string) (version string, canaryBuild int) {
|
|||
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)
|
||||
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
|
||||
}
|
||||
}
|
||||
30
internal/site/releases_test.go
Normal file
30
internal/site/releases_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package http
|
||||
package site
|
||||
|
||||
import (
|
||||
"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
|
||||
// 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())
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
// Package http has the [Server] and HTTP handlers.
|
||||
package http
|
||||
// Package site is the shared HTTP server framework: the [Server] type,
|
||||
// routes, release cache, and update-check protocol. Site-specific identity
|
||||
// and content are injected via [Config].
|
||||
package site
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -15,13 +17,15 @@ import (
|
|||
// Server holds dependencies for the HTTP server as well as the HTTP server itself.
|
||||
type Server struct {
|
||||
log *slog.Logger
|
||||
config Config
|
||||
mux chi.Router
|
||||
server *http.Server
|
||||
releases *ReleaseCache
|
||||
}
|
||||
|
||||
type NewServerOptions struct {
|
||||
Log *slog.Logger
|
||||
Log *slog.Logger
|
||||
Config Config
|
||||
}
|
||||
|
||||
func NewServer(opts NewServerOptions) *Server {
|
||||
|
|
@ -33,8 +37,9 @@ func NewServer(opts NewServerOptions) *Server {
|
|||
|
||||
return &Server{
|
||||
log: opts.Log,
|
||||
config: opts.Config,
|
||||
mux: mux,
|
||||
releases: &ReleaseCache{log: opts.Log},
|
||||
releases: &ReleaseCache{log: opts.Log, config: opts.Config},
|
||||
server: &http.Server{
|
||||
Addr: ":8081",
|
||||
Handler: mux,
|
||||
58
internal/site/server_test.go
Normal file
58
internal/site/server_test.go
Normal 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>"))
|
||||
})
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package http
|
||||
package site
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package http
|
||||
package site
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
|
@ -10,7 +10,7 @@ import (
|
|||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"app/sites/quintodrome/html"
|
||||
"app/internal/component"
|
||||
)
|
||||
|
||||
// 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,
|
||||
// on the correct track (stable vs canary), and preferably has assets
|
||||
// for the requested platform/arch.
|
||||
var candidate *html.Release
|
||||
var candidate *component.Release
|
||||
for _, rel := range releases {
|
||||
if rel.IsCanary {
|
||||
if clientIsCanary && newerCanary(rel, clientBase, clientBuild, true) {
|
||||
|
|
@ -96,7 +96,7 @@ func (s *Server) updateCheckHandler() http.HandlerFunc {
|
|||
// Build the Hazel-compatible response.
|
||||
resp := hazelUpdate{
|
||||
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),
|
||||
URL: candidate.DownloadURL(internalOS, arch),
|
||||
}
|
||||
|
|
@ -107,7 +107,7 @@ func (s *Server) updateCheckHandler() http.HandlerFunc {
|
|||
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/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")
|
||||
|
|
@ -152,7 +152,7 @@ func normalizePlatformForAssets(p string) string {
|
|||
// 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 {
|
||||
func newerCanary(rel component.Release, clientBase string, clientBuild int, clientIsCanary bool) bool {
|
||||
cmp := semverCmp(rel.Version, clientBase)
|
||||
if cmp > 0 {
|
||||
return true
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
package http
|
||||
package site
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"maragu.dev/is"
|
||||
|
||||
"app/html"
|
||||
"app/internal/component"
|
||||
)
|
||||
|
||||
func TestSemverCmp(t *testing.T) {
|
||||
|
|
@ -28,8 +28,8 @@ func TestSemverCmp(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestNewerCanary(t *testing.T) {
|
||||
rel := func(base string, build int) html.Release {
|
||||
return html.Release{Version: base, CanaryBuild: build, IsCanary: true}
|
||||
rel := func(base string, build int) component.Release {
|
||||
return component.Release{Version: base, CanaryBuild: build, IsCanary: true}
|
||||
}
|
||||
|
||||
t.Run("higher base is newer for canary client", func(t *testing.T) {
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "quintodrome-quineglobal-com",
|
||||
"name": "quineglobal-sites",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"tailwindcss": "^3.4.0"
|
||||
|
|
|
|||
|
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
3
sites/hyper/.gitignore
vendored
3
sites/hyper/.gitignore
vendored
|
|
@ -1,3 +0,0 @@
|
|||
/cover.out
|
||||
/node_modules/
|
||||
/package-lock.json
|
||||
|
|
@ -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.
|
||||
|
||||
## Commands
|
||||
|
||||
```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
|
||||
Quine Hyper's site-specific config. See the root `CLAUDE.md` for the shared
|
||||
server/component architecture — this directory only holds `Config`
|
||||
(branding, GitHub repo, Linux download formats), `classifyAsset`, and the
|
||||
landing-page content (`home.go`, `mockup.go`).
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 ./...
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
# gomponents-starter-kit
|
||||
|
||||
<img src="logo.png" alt="Logo" width="300" align="right">
|
||||
|
||||
[](https://pkg.go.dev/github.com/maragudk/gomponents-starter-kit)
|
||||
[](https://github.com/maragudk/gomponents-starter-kit/actions/workflows/ci.yml)
|
||||
[](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
52
sites/hyper/assets.go
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package http
|
||||
package hyper
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
|
@ -8,10 +8,10 @@ import (
|
|||
|
||||
func TestClassifyAsset(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
wantOS string
|
||||
wantArch string
|
||||
wantOK bool
|
||||
name string
|
||||
wantOS string
|
||||
wantArch string
|
||||
wantOK bool
|
||||
}{
|
||||
// Windows
|
||||
{"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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,8 @@ import (
|
|||
"golang.org/x/sync/errgroup"
|
||||
"maragu.dev/env"
|
||||
|
||||
"app/http"
|
||||
"app/internal/site"
|
||||
"app/sites/hyper"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
|
@ -35,8 +36,9 @@ func start(log *slog.Logger) error {
|
|||
defer stop()
|
||||
|
||||
// Set up the HTTP server, injecting the database and logger
|
||||
s := http.NewServer(http.NewServerOptions{
|
||||
Log: log,
|
||||
s := site.NewServer(site.NewServerOptions{
|
||||
Log: log,
|
||||
Config: hyper.Config,
|
||||
})
|
||||
|
||||
// Use an errgroup to wait for separate goroutines which can error
|
||||
|
|
@ -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
|
||||
|
|
@ -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=
|
||||
|
|
@ -1,16 +1,19 @@
|
|||
package html
|
||||
package hyper
|
||||
|
||||
import (
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
|
||||
"app/internal/component"
|
||||
"app/internal/site"
|
||||
)
|
||||
|
||||
// HomePage is the Quine Hyper landing page.
|
||||
func HomePage(props PageProps) Node {
|
||||
// HomeContent is the Quine Hyper landing page.
|
||||
func HomeContent(cfg site.Config, props component.PageProps) Node {
|
||||
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."
|
||||
|
||||
return page(props,
|
||||
return component.Page(cfg.Branding, props,
|
||||
// Hero
|
||||
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"),
|
||||
|
|
@ -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"),
|
||||
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"),
|
||||
Text("View on GitHub"),
|
||||
),
|
||||
|
|
@ -36,32 +39,7 @@ func HomePage(props PageProps) Node {
|
|||
),
|
||||
|
||||
// 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"),
|
||||
// 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("█")),
|
||||
),
|
||||
),
|
||||
),
|
||||
terminalMockup(),
|
||||
|
||||
// Features
|
||||
Div(Class("pb-20"),
|
||||
|
|
@ -69,7 +47,7 @@ func HomePage(props PageProps) Node {
|
|||
Text("Why Quine Hyper"),
|
||||
),
|
||||
Div(Class("mt-12 grid grid-cols-1 gap-6 md:grid-cols-2"),
|
||||
featureCard("Plugin Ecosystem",
|
||||
component.FeatureCard("Plugin Ecosystem",
|
||||
Group{
|
||||
Text("Install plugins and themes from npm. Browse "),
|
||||
A(Href("https://hyper.is/plugins"),
|
||||
|
|
@ -85,15 +63,15 @@ func HomePage(props PageProps) Node {
|
|||
},
|
||||
"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."),
|
||||
"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."),
|
||||
"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."),
|
||||
"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."),
|
||||
),
|
||||
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"),
|
||||
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)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>"))
|
||||
})
|
||||
}
|
||||
|
|
@ -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
41
sites/hyper/mockup.go
Normal 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))
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
44
sites/hyper/site.go
Normal 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,
|
||||
}
|
||||
|
|
@ -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: [],
|
||||
}
|
||||
56
sites/quintodrome/assets.go
Normal file
56
sites/quintodrome/assets.go
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package http
|
||||
package quintodrome
|
||||
|
||||
import (
|
||||
"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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,8 @@ import (
|
|||
"golang.org/x/sync/errgroup"
|
||||
"maragu.dev/env"
|
||||
|
||||
"app/sites/quintodrome/http"
|
||||
"app/internal/site"
|
||||
"app/sites/quintodrome"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
|
@ -35,8 +36,9 @@ func start(log *slog.Logger) error {
|
|||
defer stop()
|
||||
|
||||
// Set up the HTTP server, injecting the database and logger
|
||||
s := http.NewServer(http.NewServerOptions{
|
||||
Log: log,
|
||||
s := site.NewServer(site.NewServerOptions{
|
||||
Log: log,
|
||||
Config: quintodrome.Config,
|
||||
})
|
||||
|
||||
// Use an errgroup to wait for separate goroutines which can error
|
||||
|
|
@ -1,16 +1,19 @@
|
|||
package html
|
||||
package quintodrome
|
||||
|
||||
import (
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
|
||||
"app/internal/component"
|
||||
"app/internal/site"
|
||||
)
|
||||
|
||||
// HomePage is the Quintodrome landing page.
|
||||
func HomePage(props PageProps) Node {
|
||||
// HomeContent is the Quintodrome landing page.
|
||||
func HomeContent(cfg site.Config, props component.PageProps) Node {
|
||||
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."
|
||||
|
||||
return page(props,
|
||||
return component.Page(cfg.Branding, props,
|
||||
// Hero
|
||||
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"),
|
||||
|
|
@ -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"),
|
||||
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"),
|
||||
Text("View on GitHub"),
|
||||
),
|
||||
|
|
@ -36,39 +39,7 @@ func HomePage(props PageProps) Node {
|
|||
),
|
||||
|
||||
// Player mockup
|
||||
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("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("⏭")),
|
||||
),
|
||||
),
|
||||
),
|
||||
musicPlayerMockup(),
|
||||
|
||||
// Features
|
||||
Div(Class("pb-20"),
|
||||
|
|
@ -76,15 +47,15 @@ func HomePage(props PageProps) Node {
|
|||
Text("Why Quintodrome"),
|
||||
),
|
||||
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."),
|
||||
"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."),
|
||||
"Works everywhere you do.",
|
||||
),
|
||||
featureCard("Your Library, Your Rules",
|
||||
component.FeatureCard("Your Library, Your Rules",
|
||||
Group{
|
||||
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"),
|
||||
|
|
@ -95,7 +66,7 @@ func HomePage(props PageProps) Node {
|
|||
},
|
||||
"~/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."),
|
||||
"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."),
|
||||
),
|
||||
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"),
|
||||
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)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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")
|
||||
})
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>"))
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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("", ""))
|
||||
}
|
||||
38
sites/quintodrome/mockup.go
Normal file
38
sites/quintodrome/mockup.go
Normal 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
44
sites/quintodrome/site.go
Normal 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,
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["./html/**/*.go", "./http/**/*.go"],
|
||||
content: ["./internal/**/*.go", "./sites/**/*.go"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue