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
|
## Commands
|
||||||
|
|
||||||
|
All commands take `SITE=quintodrome` (default) or `SITE=hyper`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make dev # start Go server + Tailwind watcher (Ctrl-C stops both)
|
make dev # start Go server + Tailwind watcher for SITE (Ctrl-C stops both)
|
||||||
make start # Go server only
|
make dev SITE=hyper # same, for the hyper site
|
||||||
make test # go test -shuffle on ./...
|
make start # Go server only
|
||||||
make css # one-shot Tailwind build
|
make test # go test -shuffle on ./... (covers every site)
|
||||||
make lint # golangci-lint
|
make css # one-shot Tailwind build (shared across sites)
|
||||||
make build # Docker image (linux/amd64)
|
make lint # golangci-lint
|
||||||
make push # build + push to forge.quinefoundation.com
|
make build # Docker image for SITE (linux/amd64)
|
||||||
|
make push # build + push to forge.quinefoundation.com
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
@ -19,27 +28,36 @@ make push # build + push to forge.quinefoundation.com
|
||||||
Pure Go HTTP server (`go-chi/chi`). HTML is rendered server-side with `maragu.dev/gomponents` (no templates). CSS is Tailwind, compiled to `public/styles/app.css`.
|
Pure Go HTTP server (`go-chi/chi`). HTML is rendered server-side with `maragu.dev/gomponents` (no templates). CSS is Tailwind, compiled to `public/styles/app.css`.
|
||||||
|
|
||||||
```
|
```
|
||||||
cmd/app/ entry point
|
internal/site/ shared server framework: Server, routes, release cache, update-check protocol
|
||||||
http/ route handlers, server, release cache, update-check logic
|
internal/component/ shared gomponents chrome: page/nav/footer, download page, FeatureCard, DeviceWindow
|
||||||
html/ gomponents page renderers and shared types (Release, DownloadProps, etc.)
|
sites/quintodrome/ Quintodrome's Config, classifyAsset, landing-page content, cmd/main.go
|
||||||
public/ static assets (images, compiled CSS)
|
sites/hyper/ Quine Hyper's Config, classifyAsset, landing-page content, cmd/main.go
|
||||||
scripts/ release refresh helper script
|
public/ static assets shared by all sites (images, compiled CSS)
|
||||||
|
scripts/ release refresh helper script (shared, identical across sites)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
A site is defined entirely by `sites/<site>/site.go`'s `Config` value
|
||||||
|
(`internal/site.Config`, embedding `component.Branding` and
|
||||||
|
`component.DownloadConfig`) plus that package's `classifyAsset` and
|
||||||
|
`HomeContent`. Everything else — HTTP routing, the release cache, the
|
||||||
|
update-check handler, the download page's mechanics — is shared code in
|
||||||
|
`internal/site` and `internal/component`.
|
||||||
|
|
||||||
## Key concepts
|
## Key concepts
|
||||||
|
|
||||||
**Release cache** (`http/releases.go`): fetches GitHub releases from the `quine-global/quintodrome` repo via the GitHub API, caches them in memory. `TryRefresh` is debounced (called per-request, no-ops if cache is fresh or a fetch is already running). `ForceRefresh` is used by the internal `/internal/refresh` endpoint.
|
**Release cache** (`internal/site/releases.go`): fetches GitHub releases from `Config.RepoOwner/Config.RepoName` via the GitHub API, caches them in memory per-server. `TryRefresh` is debounced (called per-request, no-ops if cache is fresh or a fetch is already running). `ForceRefresh` is used by the internal `/internal/refresh` endpoint.
|
||||||
|
|
||||||
**Asset classification** (`classifyAsset` in `http/releases.go`): maps GitHub asset filenames to `(os, arch)` keys used in `Release.Assets`. Supported platforms: `mac/{arm64,x64}` (`.dmg`), `windows/x64` (`.exe`), `linux/{arm64,x64}` (AppImage), `linux-rpm`, `linux-deb`, `linux-snap`, `linux-pacman`, `linux-flatpak` (each `{arm64,x64}`). Arch is detected from either `arm64` or `aarch64` in the filename. armv7l and other unrecognised ARM variants are explicitly excluded (return `ok=false`) so they don't get misclassified as x64. Quintodrome's actual releases currently ship `.dmg`/`.exe`/`.flatpak`/`.snap` only (no Linux AppImage/deb/rpm/pacman yet); `.msi` and `.app.zip` variants are intentionally left unclassified in favor of `.exe`/`.dmg`. The upstream repo also publishes `navidrome_*` server-binary assets (tar.gz/deb/rpm for the bundled Navidrome server itself) — those are unrelated to the desktop app and are not classified here.
|
**Asset classification** (`sites/<site>/assets.go`): each site's own `classifyAsset` maps GitHub release asset filenames to `(os, arch)` keys used in `Release.Assets`, since real release artifact naming conventions differ per product (this is intentionally *not* shared logic). Supported OS keys: `mac`, `windows`, `linux` (AppImage), `linux-rpm`, `linux-deb`, `linux-snap`, `linux-pacman`, `linux-flatpak`. armv7l and other unrecognised ARM variants are explicitly excluded (return `ok=false`) rather than misclassified as x64.
|
||||||
|
|
||||||
**Auto-update feed** (`http/updatecheck.go`): implements a Hazel/Squirrel-style update protocol. Responds to `GET /api/update-check` and `GET /update` with 204 (no update) or 200 JSON `{name, notes, pub_date, url}`. Handles stable and canary tracks; platform strings from the client (`darwin`, `win32`, `deb`, etc.) are normalised via `normalizePlatformForAssets`.
|
**Auto-update feed** (`internal/site/updatecheck.go`): implements a Hazel/Squirrel-style update protocol, shared by all sites. Responds to `GET /api/update-check` and `GET /update` with 204 (no update) or 200 JSON `{name, notes, pub_date, url}`. Handles stable and canary tracks; platform strings from the client (`darwin`, `win32`, `deb`, etc.) are normalised via `normalizePlatformForAssets`.
|
||||||
|
|
||||||
**Download page** (`http/home.go`, `html/download.go`): detects platform from `User-Agent`, supports manual override via `?os=` and `?arch=` query params. Linux defaults to the Flatpak format (`?os=linux-flatpak`) since that's what's actually published today; the format toggle also offers Snap, AppImage, DEB, RPM, and Pacman for forward compatibility.
|
**Download page** (`internal/site/home.go`, `internal/component/download.go`): detects platform from `User-Agent` (Linux default comes from `Config.DefaultLinuxOS`), supports manual override via `?os=` and `?arch=` query params. The Linux format toggle's button list, order, and per-format architecture availability are entirely driven by `Config.LinuxFormats` — e.g. Quintodrome offers Flatpak first and ships Snap for both arm64/x64, while Hyper offers AppImage first and only ships Snap for x64 (hidden on arm64 automatically since the config declares it x64-only).
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Tests live alongside source in `package http` (internal) or `package http_test` (integration). Run with `make test`. Notable test files:
|
Tests live alongside source: `package site`/`site_test` in `internal/site`, `package quintodrome`/`hyper` in each site dir. Run with `make test`. Notable test files:
|
||||||
|
|
||||||
- `http/releases_test.go` — `classifyAsset` and `parseTag`
|
- `sites/<site>/assets_test.go` — per-site `classifyAsset` cases
|
||||||
- `http/updatecheck_test.go` — `semverCmp`, `newerCanary`, `normalizePlatformForAssets`
|
- `internal/site/releases_test.go` — shared `parseTag`
|
||||||
- `http/server_test.go` — integration test that starts the real server
|
- `internal/site/updatecheck_test.go` — shared `semverCmp`, `newerCanary`, `normalizePlatformForAssets`
|
||||||
|
- `internal/site/server_test.go` — integration test that starts a real server against a minimal test `Config`
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ WORKDIR /app
|
||||||
RUN set -x && apt-get update && \
|
RUN set -x && apt-get update && \
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y curl
|
DEBIAN_FRONTEND=noninteractive apt-get install -y curl
|
||||||
|
|
||||||
COPY html ./html/
|
COPY internal ./internal/
|
||||||
|
COPY sites ./sites/
|
||||||
|
|
||||||
FROM golang AS gobuilder
|
FROM golang AS gobuilder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
@ -15,7 +16,8 @@ RUN go mod download
|
||||||
COPY . ./
|
COPY . ./
|
||||||
|
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
RUN GOOS=linux GOARCH=${TARGETARCH} go build -buildvcs=false -ldflags="-s -w" -o ./app ./cmd/app
|
ARG SITE=quintodrome
|
||||||
|
RUN GOOS=linux GOARCH=${TARGETARCH} go build -buildvcs=false -ldflags="-s -w" -o ./app ./sites/${SITE}/cmd
|
||||||
|
|
||||||
FROM debian:stable-slim AS runner
|
FROM debian:stable-slim AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
|
||||||
11
Makefile
11
Makefile
|
|
@ -1,14 +1,17 @@
|
||||||
|
# SITE selects which site under sites/ to build/run: quintodrome or hyper.
|
||||||
|
SITE ?= quintodrome
|
||||||
|
|
||||||
.PHONY: benchmark
|
.PHONY: benchmark
|
||||||
benchmark:
|
benchmark:
|
||||||
go test -bench=.
|
go test -bench=.
|
||||||
|
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
build:
|
build:
|
||||||
docker build --platform linux/amd64 . -t forge.quinefoundation.com/ironmagma/quintodrome-quineglobal-com
|
docker build --platform linux/amd64 --build-arg SITE=$(SITE) . -t forge.quinefoundation.com/ironmagma/$(SITE)-quineglobal-com
|
||||||
|
|
||||||
.PHONY: push
|
.PHONY: push
|
||||||
push: build
|
push: build
|
||||||
docker push forge.quinefoundation.com/ironmagma/quintodrome-quineglobal-com
|
docker push forge.quinefoundation.com/ironmagma/$(SITE)-quineglobal-com
|
||||||
|
|
||||||
.PHONY: cover
|
.PHONY: cover
|
||||||
cover:
|
cover:
|
||||||
|
|
@ -24,7 +27,7 @@ css-watch:
|
||||||
|
|
||||||
.PHONY: dev
|
.PHONY: dev
|
||||||
dev:
|
dev:
|
||||||
@trap 'kill 0' SIGINT SIGTERM; npm run css:watch & go run ./cmd/app; wait
|
@trap 'kill 0' SIGINT SIGTERM; npm run css:watch & go run ./sites/$(SITE)/cmd; wait
|
||||||
|
|
||||||
.PHONY: install
|
.PHONY: install
|
||||||
install:
|
install:
|
||||||
|
|
@ -36,7 +39,7 @@ lint:
|
||||||
|
|
||||||
.PHONY: start
|
.PHONY: start
|
||||||
start:
|
start:
|
||||||
go run ./cmd/app
|
go run ./sites/$(SITE)/cmd
|
||||||
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
test:
|
test:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package html
|
package component
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
@ -47,6 +47,40 @@ type DownloadProps struct {
|
||||||
Releases []Release
|
Releases []Release
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LinuxFormat is one Linux packaging format a site offers on its download
|
||||||
|
// page (e.g. Flatpak, Snap, AppImage), and which architectures it ships for.
|
||||||
|
type LinuxFormat struct {
|
||||||
|
OS string // asset-classification key, e.g. "linux-flatpak"
|
||||||
|
Label string // button label, e.g. "Flatpak"
|
||||||
|
Archs []string // architectures this format is available for, e.g. []string{"x64"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadConfig is the per-site knobs for the download page: which Linux
|
||||||
|
// format is assumed by default, and the ordered list of formats offered.
|
||||||
|
type DownloadConfig struct {
|
||||||
|
DefaultLinuxOS string // detectPlatform()'s Linux default, e.g. "linux-flatpak"
|
||||||
|
Formats []LinuxFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dc DownloadConfig) formatSupportsArch(os, arch string) bool {
|
||||||
|
for _, f := range dc.Formats {
|
||||||
|
if f.OS == os {
|
||||||
|
return containsString(f.Archs, arch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Not a tracked Linux format (e.g. plain "linux") — no arch restriction.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsString(list []string, s string) bool {
|
||||||
|
for _, v := range list {
|
||||||
|
if v == s {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
type platformOption struct {
|
type platformOption struct {
|
||||||
OS string
|
OS string
|
||||||
Arch string
|
Arch string
|
||||||
|
|
@ -96,9 +130,9 @@ func platformLabel(os, arch string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadPage renders the download page with OS/arch selection and version list.
|
// DownloadPage renders the download page with OS/arch selection and version list.
|
||||||
func DownloadPage(props PageProps, dl DownloadProps) Node {
|
func DownloadPage(b Branding, dc DownloadConfig, props PageProps, dl DownloadProps) Node {
|
||||||
props.Title = "Download — Quintodrome"
|
props.Title = "Download — " + b.Name
|
||||||
props.Description = "Download Quintodrome for macOS, Windows, or Linux."
|
props.Description = "Download " + b.Name + " for macOS, Windows, or Linux."
|
||||||
|
|
||||||
// Split into stable and canary.
|
// Split into stable and canary.
|
||||||
var stable, canary []Release
|
var stable, canary []Release
|
||||||
|
|
@ -112,41 +146,41 @@ func DownloadPage(props PageProps, dl DownloadProps) Node {
|
||||||
|
|
||||||
content := []Node{
|
content := []Node{
|
||||||
If(dl.SelectedOS == "mac" && !dl.ArchExplicit, macArchDetectScript()),
|
If(dl.SelectedOS == "mac" && !dl.ArchExplicit, macArchDetectScript()),
|
||||||
H1(Class("font-mono text-3xl font-bold text-white"), Text("Download Quintodrome")),
|
H1(Class("font-mono text-3xl font-bold text-white"), Text("Download "+b.Name)),
|
||||||
Div(Class("mt-8"),
|
Div(Class("mt-8"),
|
||||||
P(Class("font-mono text-xs uppercase tracking-widest text-zinc-500 mb-3"), Text("Platform")),
|
P(Class("font-mono text-xs uppercase tracking-widest text-zinc-500 mb-3"), Text("Platform")),
|
||||||
Div(Class("flex flex-wrap gap-2"),
|
Div(Class("flex flex-wrap gap-2"),
|
||||||
Group(platformButtons(dl)),
|
Group(platformButtons(dc, dl)),
|
||||||
),
|
),
|
||||||
If(strings.HasPrefix(dl.SelectedOS, "linux"),
|
If(strings.HasPrefix(dl.SelectedOS, "linux"),
|
||||||
linuxFormatToggle(dl),
|
linuxFormatToggle(dc, dl),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
if len(stable) > 0 {
|
if len(stable) > 0 {
|
||||||
content = append(content, stableSection(stable, dl))
|
content = append(content, stableSection(b, stable, dl))
|
||||||
}
|
}
|
||||||
if len(canary) > 0 {
|
if len(canary) > 0 {
|
||||||
content = append(content, canarySection(canary, dl))
|
content = append(content, canarySection(b, canary, dl))
|
||||||
}
|
}
|
||||||
content = append(content,
|
content = append(content,
|
||||||
Div(Class("mt-6 text-center"),
|
Div(Class("mt-6 text-center"),
|
||||||
A(
|
A(
|
||||||
Href("https://github.com/quine-global/quintodrome/releases"),
|
Href(b.GitHubURL+"/releases"),
|
||||||
Class("font-mono text-xs text-zinc-500 hover:text-white transition-colors underline underline-offset-4"),
|
Class("font-mono text-xs text-zinc-500 hover:text-white transition-colors underline underline-offset-4"),
|
||||||
Text("All releases on GitHub ↗"),
|
Text("All releases on GitHub ↗"),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
return page(props,
|
return Page(b, props,
|
||||||
Div(Class("mx-auto max-w-xl py-16"),
|
Div(Class("mx-auto max-w-xl py-16"),
|
||||||
Group(content),
|
Group(content),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func stableSection(stable []Release, dl DownloadProps) Node {
|
func stableSection(b Branding, stable []Release, dl DownloadProps) Node {
|
||||||
latest := stable[0]
|
latest := stable[0]
|
||||||
older := stable[1:]
|
older := stable[1:]
|
||||||
url := latest.DownloadURL(dl.SelectedOS, dl.SelectedArch)
|
url := latest.DownloadURL(dl.SelectedOS, dl.SelectedArch)
|
||||||
|
|
@ -165,7 +199,7 @@ func stableSection(stable []Release, dl DownloadProps) Node {
|
||||||
Text("Latest Stable"),
|
Text("Latest Stable"),
|
||||||
),
|
),
|
||||||
H2(Class("mt-1 font-mono text-xl font-bold text-white"),
|
H2(Class("mt-1 font-mono text-xl font-bold text-white"),
|
||||||
Text("Quintodrome v"+latest.DisplayVersion()),
|
Text(b.Name+" v"+latest.DisplayVersion()),
|
||||||
),
|
),
|
||||||
P(Class("mt-1 font-mono text-xs text-zinc-500"),
|
P(Class("mt-1 font-mono text-xs text-zinc-500"),
|
||||||
Text(latest.Date+" · "+platformLabel(dl.SelectedOS, dl.SelectedArch)+fileExt(dl.SelectedOS)),
|
Text(latest.Date+" · "+platformLabel(dl.SelectedOS, dl.SelectedArch)+fileExt(dl.SelectedOS)),
|
||||||
|
|
@ -189,7 +223,7 @@ func stableSection(stable []Release, dl DownloadProps) Node {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func canarySection(canary []Release, dl DownloadProps) Node {
|
func canarySection(b Branding, canary []Release, dl DownloadProps) Node {
|
||||||
latest := canary[0]
|
latest := canary[0]
|
||||||
older := canary[1:]
|
older := canary[1:]
|
||||||
url := latest.DownloadURL(dl.SelectedOS, dl.SelectedArch)
|
url := latest.DownloadURL(dl.SelectedOS, dl.SelectedArch)
|
||||||
|
|
@ -208,7 +242,7 @@ func canarySection(canary []Release, dl DownloadProps) Node {
|
||||||
Text("Latest Canary"),
|
Text("Latest Canary"),
|
||||||
),
|
),
|
||||||
H2(Class("mt-1 font-mono text-base font-bold text-white"),
|
H2(Class("mt-1 font-mono text-base font-bold text-white"),
|
||||||
Text("Quintodrome v"+latest.DisplayVersion()),
|
Text(b.Name+" v"+latest.DisplayVersion()),
|
||||||
),
|
),
|
||||||
P(Class("mt-1 font-mono text-xs text-zinc-500"),
|
P(Class("mt-1 font-mono text-xs text-zinc-500"),
|
||||||
Text(latest.Date+" · "+platformLabel(dl.SelectedOS, dl.SelectedArch)+fileExt(dl.SelectedOS)),
|
Text(latest.Date+" · "+platformLabel(dl.SelectedOS, dl.SelectedArch)+fileExt(dl.SelectedOS)),
|
||||||
|
|
@ -276,7 +310,7 @@ func releaseRows(releases []Release, dl DownloadProps, linkCls string) []Node {
|
||||||
return nodes
|
return nodes
|
||||||
}
|
}
|
||||||
|
|
||||||
func platformButtons(dl DownloadProps) []Node {
|
func platformButtons(dc DownloadConfig, dl DownloadProps) []Node {
|
||||||
nodes := make([]Node, len(platforms))
|
nodes := make([]Node, len(platforms))
|
||||||
// Treat any linux-* variant as linux for platform button highlight/detection purposes.
|
// Treat any linux-* variant as linux for platform button highlight/detection purposes.
|
||||||
effectiveSelectedOS := dl.SelectedOS
|
effectiveSelectedOS := dl.SelectedOS
|
||||||
|
|
@ -291,9 +325,10 @@ func platformButtons(dl DownloadProps) []Node {
|
||||||
isSelected := p.OS == effectiveSelectedOS && p.Arch == dl.SelectedArch
|
isSelected := p.OS == effectiveSelectedOS && p.Arch == dl.SelectedArch
|
||||||
isDetected := p.OS == effectiveDetectedOS && p.Arch == dl.DetectedArch
|
isDetected := p.OS == effectiveDetectedOS && p.Arch == dl.DetectedArch
|
||||||
|
|
||||||
// Preserve the linux format when switching arch.
|
// Preserve the linux format when switching arch, unless that format
|
||||||
|
// doesn't ship for the target arch (e.g. an x64-only Snap build).
|
||||||
targetOS := p.OS
|
targetOS := p.OS
|
||||||
if p.OS == "linux" && strings.HasPrefix(dl.SelectedOS, "linux-") {
|
if p.OS == "linux" && strings.HasPrefix(dl.SelectedOS, "linux-") && dc.formatSupportsArch(dl.SelectedOS, p.Arch) {
|
||||||
targetOS = dl.SelectedOS
|
targetOS = dl.SelectedOS
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -347,7 +382,7 @@ func macArchDetectScript() Node {
|
||||||
})();`))
|
})();`))
|
||||||
}
|
}
|
||||||
|
|
||||||
func linuxFormatToggle(dl DownloadProps) Node {
|
func linuxFormatToggle(dc DownloadConfig, dl DownloadProps) Node {
|
||||||
selectedCls := "inline-flex items-center rounded-md bg-primary-500 px-3 py-1.5 font-mono text-xs font-semibold text-black"
|
selectedCls := "inline-flex items-center rounded-md bg-primary-500 px-3 py-1.5 font-mono text-xs font-semibold text-black"
|
||||||
unselectedCls := "inline-flex items-center rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 font-mono text-xs text-zinc-300 hover:border-zinc-500 hover:text-white transition-colors"
|
unselectedCls := "inline-flex items-center rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 font-mono text-xs text-zinc-300 hover:border-zinc-500 hover:text-white transition-colors"
|
||||||
cls := func(targetOS string) string {
|
cls := func(targetOS string) string {
|
||||||
|
|
@ -360,13 +395,12 @@ func linuxFormatToggle(dl DownloadProps) Node {
|
||||||
return fmt.Sprintf("/download?os=%s&arch=%s", targetOS, dl.SelectedArch)
|
return fmt.Sprintf("/download?os=%s&arch=%s", targetOS, dl.SelectedArch)
|
||||||
}
|
}
|
||||||
|
|
||||||
buttons := []Node{
|
var buttons []Node
|
||||||
A(Href(href("linux-flatpak")), Class(cls("linux-flatpak")), Text("Flatpak")),
|
for _, f := range dc.Formats {
|
||||||
A(Href(href("linux-snap")), Class(cls("linux-snap")), Text("Snap")),
|
if !containsString(f.Archs, dl.SelectedArch) {
|
||||||
A(Href(href("linux")), Class(cls("linux")), Text("AppImage")),
|
continue
|
||||||
A(Href(href("linux-deb")), Class(cls("linux-deb")), Text("DEB")),
|
}
|
||||||
A(Href(href("linux-rpm")), Class(cls("linux-rpm")), Text("RPM")),
|
buttons = append(buttons, A(Href(href(f.OS)), Class(cls(f.OS)), Text(f.Label)))
|
||||||
A(Href(href("linux-pacman")), Class(cls("linux-pacman")), Text("Pacman")),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Div(Class("mt-4"),
|
return Div(Class("mt-4"),
|
||||||
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 component holds all the common HTML components and utilities
|
||||||
package html
|
// shared across sites. Site-specific branding is threaded through via
|
||||||
|
// [Branding] and [DownloadConfig] rather than hardcoded.
|
||||||
|
package component
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
|
@ -17,14 +19,26 @@ import (
|
||||||
var hashOnce sync.Once
|
var hashOnce sync.Once
|
||||||
var appCSSPath string
|
var appCSSPath string
|
||||||
|
|
||||||
// PageProps are properties for the [page] component.
|
// PageProps are properties for the [Page] component.
|
||||||
type PageProps struct {
|
type PageProps struct {
|
||||||
Title string
|
Title string
|
||||||
Description string
|
Description string
|
||||||
}
|
}
|
||||||
|
|
||||||
// page layout with nav, main content, and footer.
|
// Branding holds the per-site identity used by the shared page chrome.
|
||||||
func page(props PageProps, children ...Node) Node {
|
type Branding struct {
|
||||||
|
Name string // e.g. "Quintodrome"
|
||||||
|
LogoPath string // e.g. "/images/quintodrome-logo.png"
|
||||||
|
GitHubURL string // e.g. "https://github.com/quine-global/quintodrome"
|
||||||
|
|
||||||
|
// FooterNote is the "Built on <link>." fragment rendered after the
|
||||||
|
// shared "© 2026 Quine Global. " copyright line. Each site supplies
|
||||||
|
// its own link and trailing text (e.g. "Navidrome." vs "Hyper by Vercel.").
|
||||||
|
FooterNote Node
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page layout with nav, main content, and footer.
|
||||||
|
func Page(b Branding, props PageProps, children ...Node) Node {
|
||||||
hashOnce.Do(func() {
|
hashOnce.Do(func() {
|
||||||
appCSSPath = getHashedPath("public/styles/app.css")
|
appCSSPath = getHashedPath("public/styles/app.css")
|
||||||
})
|
})
|
||||||
|
|
@ -41,26 +55,26 @@ func page(props PageProps, children ...Node) Node {
|
||||||
Meta(Name("viewport"), Content("width=device-width, initial-scale=1")),
|
Meta(Name("viewport"), Content("width=device-width, initial-scale=1")),
|
||||||
},
|
},
|
||||||
Body: Group{
|
Body: Group{
|
||||||
siteNav(),
|
siteNav(b),
|
||||||
Main(Class("mx-auto max-w-5xl px-6 py-12"),
|
Main(Class("mx-auto max-w-5xl px-6 py-12"),
|
||||||
Group(children),
|
Group(children),
|
||||||
),
|
),
|
||||||
siteFooter(),
|
siteFooter(b),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func siteNav() Node {
|
func siteNav(b Branding) Node {
|
||||||
return Header(Class("border-b border-zinc-800 bg-zinc-950"),
|
return Header(Class("border-b border-zinc-800 bg-zinc-950"),
|
||||||
Div(Class("mx-auto flex max-w-5xl items-center justify-between px-6 py-4"),
|
Div(Class("mx-auto flex max-w-5xl items-center justify-between px-6 py-4"),
|
||||||
A(Href("/"), Class("flex items-center gap-3 no-underline"),
|
A(Href("/"), Class("flex items-center gap-3 no-underline"),
|
||||||
Img(Src("/images/hyper-logo.png"), Alt("Quine Hyper"), Class("h-8 w-8 rounded")),
|
Img(Src(b.LogoPath), Alt(b.Name), Class("h-8 w-8 rounded")),
|
||||||
Span(Class("font-mono text-lg font-bold tracking-tight text-white"),
|
Span(Class("font-mono text-lg font-bold tracking-tight text-white"),
|
||||||
Text("Quine Hyper"),
|
Text(b.Name),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Div(Class("flex items-center gap-4"),
|
Div(Class("flex items-center gap-4"),
|
||||||
A(Href("https://github.com/quine-global/hyper"),
|
A(Href(b.GitHubURL),
|
||||||
Class("font-mono text-sm text-zinc-400 hover:text-white transition-colors"),
|
Class("font-mono text-sm text-zinc-400 hover:text-white transition-colors"),
|
||||||
Text("GitHub"),
|
Text("GitHub"),
|
||||||
),
|
),
|
||||||
|
|
@ -73,20 +87,19 @@ func siteNav() Node {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func siteFooter() Node {
|
func siteFooter(b Branding) Node {
|
||||||
return Footer(Class("mt-24 border-t border-zinc-800 py-8"),
|
return Footer(Class("mt-24 border-t border-zinc-800 py-8"),
|
||||||
Div(Class("mx-auto flex max-w-5xl flex-col items-center justify-between gap-4 px-6 sm:flex-row"),
|
Div(Class("mx-auto flex max-w-5xl flex-col items-center justify-between gap-4 px-6 sm:flex-row"),
|
||||||
Div(Class("flex items-center gap-3"),
|
Div(Class("flex items-center gap-3"),
|
||||||
Img(Src("/images/quineglobal-logo.png"), Alt("Quine Global"), Class("h-6 w-6 rounded-full")),
|
Img(Src("/images/quineglobal-logo.png"), Alt("Quine Global"), Class("h-6 w-6 rounded-full")),
|
||||||
Span(Class("font-mono text-sm text-zinc-500"),
|
Span(Class("font-mono text-sm text-zinc-500"),
|
||||||
Text("© 2026 Quine Global. Built on "),
|
Text("© 2026 Quine Global. "),
|
||||||
A(Href("https://hyper.is"), Class("text-zinc-400 hover:text-white underline"), Text("Hyper")),
|
b.FooterNote,
|
||||||
Text(" by Vercel."),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Div(Class("flex gap-6 font-mono text-sm"),
|
Div(Class("flex gap-6 font-mono text-sm"),
|
||||||
A(Href("/"), Class("text-zinc-500 hover:text-white transition-colors"), Text("Home")),
|
A(Href("/"), Class("text-zinc-500 hover:text-white transition-colors"), Text("Home")),
|
||||||
A(Href("https://github.com/quine-global/hyper"), Class("text-zinc-500 hover:text-white transition-colors"), Text("GitHub")),
|
A(Href(b.GitHubURL), Class("text-zinc-500 hover:text-white transition-colors"), Text("GitHub")),
|
||||||
A(Href("/download"), Class("text-zinc-500 hover:text-white transition-colors"), Text("Download")),
|
A(Href("/download"), Class("text-zinc-500 hover:text-white transition-colors"), Text("Download")),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
31
internal/site/config.go
Normal file
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -10,20 +10,20 @@ import (
|
||||||
. "maragu.dev/gomponents"
|
. "maragu.dev/gomponents"
|
||||||
ghttp "maragu.dev/gomponents/http"
|
ghttp "maragu.dev/gomponents/http"
|
||||||
|
|
||||||
"app/sites/quintodrome/html"
|
"app/internal/component"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Home registers the index and download handlers.
|
// Home registers the index and download handlers.
|
||||||
func (s *Server) Home(r chi.Router) {
|
func (s *Server) Home(r chi.Router) {
|
||||||
r.Get("/", ghttp.Adapt(func(w http.ResponseWriter, r *http.Request) (Node, error) {
|
r.Get("/", ghttp.Adapt(func(w http.ResponseWriter, r *http.Request) (Node, error) {
|
||||||
return html.HomePage(html.PageProps{}), nil
|
return s.config.HomeContent(s.config, component.PageProps{}), nil
|
||||||
}))
|
}))
|
||||||
|
|
||||||
r.Get("/download", ghttp.Adapt(func(w http.ResponseWriter, r *http.Request) (Node, error) {
|
r.Get("/download", ghttp.Adapt(func(w http.ResponseWriter, r *http.Request) (Node, error) {
|
||||||
// On-demand refresh, debounced to once per 2 minutes across all requests.
|
// On-demand refresh, debounced to once per 2 minutes across all requests.
|
||||||
go s.releases.TryRefresh(context.Background(), 2*time.Minute)
|
go s.releases.TryRefresh(context.Background(), 2*time.Minute)
|
||||||
|
|
||||||
detectedOS, detectedArch := detectPlatform(r.Header.Get("User-Agent"))
|
detectedOS, detectedArch := s.detectPlatform(r.Header.Get("User-Agent"))
|
||||||
|
|
||||||
selectedOS := detectedOS
|
selectedOS := detectedOS
|
||||||
selectedArch := detectedArch
|
selectedArch := detectedArch
|
||||||
|
|
@ -37,7 +37,7 @@ func (s *Server) Home(r chi.Router) {
|
||||||
archExplicit = true
|
archExplicit = true
|
||||||
}
|
}
|
||||||
|
|
||||||
return html.DownloadPage(html.PageProps{}, html.DownloadProps{
|
return component.DownloadPage(s.config.Branding, s.config.DownloadConfig, component.PageProps{}, component.DownloadProps{
|
||||||
DetectedOS: detectedOS,
|
DetectedOS: detectedOS,
|
||||||
DetectedArch: detectedArch,
|
DetectedArch: detectedArch,
|
||||||
SelectedOS: selectedOS,
|
SelectedOS: selectedOS,
|
||||||
|
|
@ -48,7 +48,7 @@ func (s *Server) Home(r chi.Router) {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func detectPlatform(ua string) (os, arch string) {
|
func (s *Server) detectPlatform(ua string) (os, arch string) {
|
||||||
u := strings.ToLower(ua)
|
u := strings.ToLower(ua)
|
||||||
switch {
|
switch {
|
||||||
case strings.Contains(u, "windows"):
|
case strings.Contains(u, "windows"):
|
||||||
|
|
@ -56,8 +56,7 @@ func detectPlatform(ua string) (os, arch string) {
|
||||||
case strings.Contains(u, "macintosh"), strings.Contains(u, "mac os"):
|
case strings.Contains(u, "macintosh"), strings.Contains(u, "mac os"):
|
||||||
return "mac", "arm64" // default Apple Silicon; user can override
|
return "mac", "arm64" // default Apple Silicon; user can override
|
||||||
case strings.Contains(u, "linux"):
|
case strings.Contains(u, "linux"):
|
||||||
// Quintodrome currently ships Linux builds as Flatpak/Snap only (no AppImage).
|
return s.config.DefaultLinuxOS, "x64"
|
||||||
return "linux-flatpak", "x64"
|
|
||||||
default:
|
default:
|
||||||
return "mac", "arm64"
|
return "mac", "arm64"
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package http
|
package site
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
package http
|
package site
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
@ -10,26 +11,29 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"app/sites/quintodrome/html"
|
"app/internal/component"
|
||||||
)
|
)
|
||||||
|
|
||||||
const releasesAPI = "https://api.github.com/repos/quine-global/quintodrome/releases?per_page=30"
|
|
||||||
|
|
||||||
// ReleaseCache is a thread-safe cache of GitHub releases.
|
// ReleaseCache is a thread-safe cache of GitHub releases.
|
||||||
type ReleaseCache struct {
|
type ReleaseCache struct {
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
|
config Config
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
releases []html.Release
|
releases []component.Release
|
||||||
lastFetched time.Time
|
lastFetched time.Time
|
||||||
fetchMu sync.Mutex // TryLock prevents concurrent fetches
|
fetchMu sync.Mutex // TryLock prevents concurrent fetches
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *ReleaseCache) releasesAPI() string {
|
||||||
|
return fmt.Sprintf("https://api.github.com/repos/%s/%s/releases?per_page=30", c.config.RepoOwner, c.config.RepoName)
|
||||||
|
}
|
||||||
|
|
||||||
// Get returns cached releases, falling back to hardcoded data if cache is empty.
|
// Get returns cached releases, falling back to hardcoded data if cache is empty.
|
||||||
func (c *ReleaseCache) Get() []html.Release {
|
func (c *ReleaseCache) Get() []component.Release {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
defer c.mu.RUnlock()
|
defer c.mu.RUnlock()
|
||||||
if len(c.releases) == 0 {
|
if len(c.releases) == 0 {
|
||||||
return []html.Release{}
|
return []component.Release{}
|
||||||
}
|
}
|
||||||
return c.releases
|
return c.releases
|
||||||
}
|
}
|
||||||
|
|
@ -40,7 +44,7 @@ func (c *ReleaseCache) ForceRefresh(ctx context.Context) error {
|
||||||
c.fetchMu.Lock()
|
c.fetchMu.Lock()
|
||||||
defer c.fetchMu.Unlock()
|
defer c.fetchMu.Unlock()
|
||||||
|
|
||||||
releases, err := fetchReleases(ctx)
|
releases, err := c.fetchReleases(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.log.Warn("Force refresh failed", "error", err)
|
c.log.Warn("Force refresh failed", "error", err)
|
||||||
return err
|
return err
|
||||||
|
|
@ -74,7 +78,7 @@ func (c *ReleaseCache) TryRefresh(ctx context.Context, minAge time.Duration) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
releases, err := fetchReleases(ctx)
|
releases, err := c.fetchReleases(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.log.Warn("Failed to fetch releases from GitHub", "error", err)
|
c.log.Warn("Failed to fetch releases from GitHub", "error", err)
|
||||||
return
|
return
|
||||||
|
|
@ -104,8 +108,8 @@ type ghAsset struct {
|
||||||
BrowserDownloadURL string `json:"browser_download_url"`
|
BrowserDownloadURL string `json:"browser_download_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchReleases(ctx context.Context) ([]html.Release, error) {
|
func (c *ReleaseCache) fetchReleases(ctx context.Context) ([]component.Release, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, releasesAPI, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.releasesAPI(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -123,13 +127,13 @@ func fetchReleases(ctx context.Context) ([]html.Release, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
releases := make([]html.Release, 0, len(raw))
|
releases := make([]component.Release, 0, len(raw))
|
||||||
for _, gr := range raw {
|
for _, gr := range raw {
|
||||||
if gr.Draft {
|
if gr.Draft {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
version, canaryBuild := parseTag(strings.TrimPrefix(gr.TagName, "v"))
|
version, canaryBuild := parseTag(strings.TrimPrefix(gr.TagName, "v"))
|
||||||
r := html.Release{
|
r := component.Release{
|
||||||
TagName: gr.TagName,
|
TagName: gr.TagName,
|
||||||
Version: version,
|
Version: version,
|
||||||
CanaryBuild: canaryBuild,
|
CanaryBuild: canaryBuild,
|
||||||
|
|
@ -139,7 +143,7 @@ func fetchReleases(ctx context.Context) ([]html.Release, error) {
|
||||||
Assets: make(map[string]map[string]string),
|
Assets: make(map[string]map[string]string),
|
||||||
}
|
}
|
||||||
for _, a := range gr.Assets {
|
for _, a := range gr.Assets {
|
||||||
os, arch, ok := classifyAsset(a.Name)
|
os, arch, ok := c.config.ClassifyAsset(a.Name)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -171,56 +175,3 @@ func parseTag(tag string) (version string, canaryBuild int) {
|
||||||
tag = strings.TrimSuffix(tag, "q")
|
tag = strings.TrimSuffix(tag, "q")
|
||||||
return tag, 0
|
return tag, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// classifyAsset maps an asset filename to an OS and architecture.
|
|
||||||
func classifyAsset(name string) (os, arch string, ok bool) {
|
|
||||||
l := strings.ToLower(name)
|
|
||||||
isArm64 := strings.Contains(l, "arm64") || strings.Contains(l, "aarch64")
|
|
||||||
isArmv7l := strings.Contains(l, "armv7l")
|
|
||||||
switch {
|
|
||||||
case strings.HasSuffix(l, ".exe"):
|
|
||||||
return "windows", "x64", true
|
|
||||||
case isArm64 && strings.HasSuffix(l, ".dmg"):
|
|
||||||
return "mac", "arm64", true
|
|
||||||
case strings.HasSuffix(l, ".dmg"):
|
|
||||||
return "mac", "x64", true
|
|
||||||
case isArm64 && strings.HasSuffix(l, ".appimage"):
|
|
||||||
return "linux", "arm64", true
|
|
||||||
case isArmv7l && strings.HasSuffix(l, ".appimage"):
|
|
||||||
return "", "", false
|
|
||||||
case strings.HasSuffix(l, ".appimage"):
|
|
||||||
return "linux", "x64", true
|
|
||||||
case isArm64 && strings.HasSuffix(l, ".rpm"):
|
|
||||||
return "linux-rpm", "arm64", true
|
|
||||||
case isArmv7l && strings.HasSuffix(l, ".rpm"):
|
|
||||||
return "", "", false
|
|
||||||
case strings.HasSuffix(l, ".rpm"):
|
|
||||||
return "linux-rpm", "x64", true
|
|
||||||
case isArm64 && strings.HasSuffix(l, ".deb"):
|
|
||||||
return "linux-deb", "arm64", true
|
|
||||||
case isArmv7l && strings.HasSuffix(l, ".deb"):
|
|
||||||
return "", "", false
|
|
||||||
case strings.HasSuffix(l, ".deb"):
|
|
||||||
return "linux-deb", "x64", true
|
|
||||||
case isArmv7l && strings.HasSuffix(l, ".snap"):
|
|
||||||
return "", "", false
|
|
||||||
case isArm64 && strings.HasSuffix(l, ".snap"):
|
|
||||||
return "linux-snap", "arm64", true
|
|
||||||
case strings.HasSuffix(l, ".snap"):
|
|
||||||
return "linux-snap", "x64", true
|
|
||||||
case isArm64 && strings.HasSuffix(l, ".pacman"):
|
|
||||||
return "linux-pacman", "arm64", true
|
|
||||||
case isArmv7l && strings.HasSuffix(l, ".pacman"):
|
|
||||||
return "", "", false
|
|
||||||
case strings.HasSuffix(l, ".pacman"):
|
|
||||||
return "linux-pacman", "x64", true
|
|
||||||
case isArm64 && strings.HasSuffix(l, ".flatpak"):
|
|
||||||
return "linux-flatpak", "arm64", true
|
|
||||||
case isArmv7l && strings.HasSuffix(l, ".flatpak"):
|
|
||||||
return "", "", false
|
|
||||||
case strings.HasSuffix(l, ".flatpak"):
|
|
||||||
return "linux-flatpak", "x64", true
|
|
||||||
default:
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 (
|
import (
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
@ -25,6 +25,6 @@ func (s *Server) setupRoutes() {
|
||||||
})
|
})
|
||||||
|
|
||||||
// Also expose the update feed at the short /update path for convenient
|
// Also expose the update feed at the short /update path for convenient
|
||||||
// feed URLs (e.g. https://hyper.quineglobal.com/update?platform=...&version=...)
|
// feed URLs (e.g. https://<site>.quineglobal.com/update?platform=...&version=...)
|
||||||
s.mux.Get("/update", s.updateCheckHandler())
|
s.mux.Get("/update", s.updateCheckHandler())
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
// Package http has the [Server] and HTTP handlers.
|
// Package site is the shared HTTP server framework: the [Server] type,
|
||||||
package http
|
// routes, release cache, and update-check protocol. Site-specific identity
|
||||||
|
// and content are injected via [Config].
|
||||||
|
package site
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -15,13 +17,15 @@ import (
|
||||||
// Server holds dependencies for the HTTP server as well as the HTTP server itself.
|
// Server holds dependencies for the HTTP server as well as the HTTP server itself.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
|
config Config
|
||||||
mux chi.Router
|
mux chi.Router
|
||||||
server *http.Server
|
server *http.Server
|
||||||
releases *ReleaseCache
|
releases *ReleaseCache
|
||||||
}
|
}
|
||||||
|
|
||||||
type NewServerOptions struct {
|
type NewServerOptions struct {
|
||||||
Log *slog.Logger
|
Log *slog.Logger
|
||||||
|
Config Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(opts NewServerOptions) *Server {
|
func NewServer(opts NewServerOptions) *Server {
|
||||||
|
|
@ -33,8 +37,9 @@ func NewServer(opts NewServerOptions) *Server {
|
||||||
|
|
||||||
return &Server{
|
return &Server{
|
||||||
log: opts.Log,
|
log: opts.Log,
|
||||||
|
config: opts.Config,
|
||||||
mux: mux,
|
mux: mux,
|
||||||
releases: &ReleaseCache{log: opts.Log},
|
releases: &ReleaseCache{log: opts.Log, config: opts.Config},
|
||||||
server: &http.Server{
|
server: &http.Server{
|
||||||
Addr: ":8081",
|
Addr: ":8081",
|
||||||
Handler: mux,
|
Handler: mux,
|
||||||
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 (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package http
|
package site
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
@ -10,7 +10,7 @@ import (
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"app/sites/quintodrome/html"
|
"app/internal/component"
|
||||||
)
|
)
|
||||||
|
|
||||||
// hazelUpdate is the response format expected by desktop auto-updaters
|
// hazelUpdate is the response format expected by desktop auto-updaters
|
||||||
|
|
@ -66,7 +66,7 @@ func (s *Server) updateCheckHandler() http.HandlerFunc {
|
||||||
// Find the newest release that is newer than what the client has,
|
// Find the newest release that is newer than what the client has,
|
||||||
// on the correct track (stable vs canary), and preferably has assets
|
// on the correct track (stable vs canary), and preferably has assets
|
||||||
// for the requested platform/arch.
|
// for the requested platform/arch.
|
||||||
var candidate *html.Release
|
var candidate *component.Release
|
||||||
for _, rel := range releases {
|
for _, rel := range releases {
|
||||||
if rel.IsCanary {
|
if rel.IsCanary {
|
||||||
if clientIsCanary && newerCanary(rel, clientBase, clientBuild, true) {
|
if clientIsCanary && newerCanary(rel, clientBase, clientBuild, true) {
|
||||||
|
|
@ -96,7 +96,7 @@ func (s *Server) updateCheckHandler() http.HandlerFunc {
|
||||||
// Build the Hazel-compatible response.
|
// Build the Hazel-compatible response.
|
||||||
resp := hazelUpdate{
|
resp := hazelUpdate{
|
||||||
Name: candidate.TagName,
|
Name: candidate.TagName,
|
||||||
Notes: fmt.Sprintf("Quintodrome %s", candidate.TagName),
|
Notes: fmt.Sprintf("%s %s", s.config.Name, candidate.TagName),
|
||||||
PubDate: candidate.PublishedAt.UTC().Format(time.RFC3339),
|
PubDate: candidate.PublishedAt.UTC().Format(time.RFC3339),
|
||||||
URL: candidate.DownloadURL(internalOS, arch),
|
URL: candidate.DownloadURL(internalOS, arch),
|
||||||
}
|
}
|
||||||
|
|
@ -107,7 +107,7 @@ func (s *Server) updateCheckHandler() http.HandlerFunc {
|
||||||
if resp.URL == "" {
|
if resp.URL == "" {
|
||||||
// Provide a generic GitHub releases URL as a fallback.
|
// Provide a generic GitHub releases URL as a fallback.
|
||||||
// The client already falls back to constructing a tag URL.
|
// The client already falls back to constructing a tag URL.
|
||||||
resp.URL = fmt.Sprintf("https://github.com/quine-global/quintodrome/releases/tag/%s", candidate.TagName)
|
resp.URL = fmt.Sprintf("https://github.com/%s/%s/releases/tag/%s", s.config.RepoOwner, s.config.RepoName, candidate.TagName)
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
@ -152,7 +152,7 @@ func normalizePlatformForAssets(p string) string {
|
||||||
// newerCanary reports whether rel is newer than the client's current canary position.
|
// newerCanary reports whether rel is newer than the client's current canary position.
|
||||||
// For a canary client, the build number is the tie-breaker on the same base version.
|
// For a canary client, the build number is the tie-breaker on the same base version.
|
||||||
// For a stable client opting into canary info, any canary on the same or higher base qualifies.
|
// For a stable client opting into canary info, any canary on the same or higher base qualifies.
|
||||||
func newerCanary(rel html.Release, clientBase string, clientBuild int, clientIsCanary bool) bool {
|
func newerCanary(rel component.Release, clientBase string, clientBuild int, clientIsCanary bool) bool {
|
||||||
cmp := semverCmp(rel.Version, clientBase)
|
cmp := semverCmp(rel.Version, clientBase)
|
||||||
if cmp > 0 {
|
if cmp > 0 {
|
||||||
return true
|
return true
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
package http
|
package site
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"maragu.dev/is"
|
"maragu.dev/is"
|
||||||
|
|
||||||
"app/html"
|
"app/internal/component"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSemverCmp(t *testing.T) {
|
func TestSemverCmp(t *testing.T) {
|
||||||
|
|
@ -28,8 +28,8 @@ func TestSemverCmp(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewerCanary(t *testing.T) {
|
func TestNewerCanary(t *testing.T) {
|
||||||
rel := func(base string, build int) html.Release {
|
rel := func(base string, build int) component.Release {
|
||||||
return html.Release{Version: base, CanaryBuild: build, IsCanary: true}
|
return component.Release{Version: base, CanaryBuild: build, IsCanary: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("higher base is newer for canary client", func(t *testing.T) {
|
t.Run("higher base is newer for canary client", func(t *testing.T) {
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "quintodrome-quineglobal-com",
|
"name": "quineglobal-sites",
|
||||||
"private": true,
|
"private": true,
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"tailwindcss": "^3.4.0"
|
"tailwindcss": "^3.4.0"
|
||||||
|
|
|
||||||
|
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.
|
Quine Hyper's site-specific config. See the root `CLAUDE.md` for the shared
|
||||||
|
server/component architecture — this directory only holds `Config`
|
||||||
## Commands
|
(branding, GitHub repo, Linux download formats), `classifyAsset`, and the
|
||||||
|
landing-page content (`home.go`, `mockup.go`).
|
||||||
```bash
|
|
||||||
make dev # start Go server + Tailwind watcher (Ctrl-C stops both)
|
|
||||||
make start # Go server only
|
|
||||||
make test # go test -shuffle on ./...
|
|
||||||
make css # one-shot Tailwind build
|
|
||||||
make lint # golangci-lint
|
|
||||||
make build # Docker image (linux/amd64)
|
|
||||||
make push # build + push to forge.quinefoundation.com
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
Pure Go HTTP server (`go-chi/chi`). HTML is rendered server-side with `maragu.dev/gomponents` (no templates). CSS is Tailwind, compiled to `public/styles/app.css`.
|
|
||||||
|
|
||||||
```
|
|
||||||
cmd/app/ entry point
|
|
||||||
http/ route handlers, server, release cache, update-check logic
|
|
||||||
html/ gomponents page renderers and shared types (Release, DownloadProps, etc.)
|
|
||||||
public/ static assets (images, compiled CSS)
|
|
||||||
scripts/ release refresh helper script
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key concepts
|
|
||||||
|
|
||||||
**Release cache** (`http/releases.go`): fetches GitHub releases from the `quine-global/hyper` repo via the GitHub API, caches them in memory. `TryRefresh` is debounced (called per-request, no-ops if cache is fresh or a fetch is already running). `ForceRefresh` is used by the internal `/internal/refresh` endpoint.
|
|
||||||
|
|
||||||
**Asset classification** (`classifyAsset` in `http/releases.go`): maps GitHub asset filenames to `(os, arch)` keys used in `Release.Assets`. Supported platforms: `mac/{arm64,x64}`, `windows/x64`, `linux/{arm64,x64}` (AppImage), `linux-rpm/{arm64,x64}`. armv7l and other unrecognised ARM variants are explicitly excluded (return `ok=false`) so they don't get misclassified as x64.
|
|
||||||
|
|
||||||
**Auto-update feed** (`http/updatecheck.go`): implements the Hazel/Squirrel update protocol originally served at `releases.hyper.is`. Responds to `GET /api/update-check` and `GET /update` with 204 (no update) or 200 JSON `{name, notes, pub_date, url}`. Handles stable and canary tracks; platform strings from the Electron client (`darwin`, `win32`, `deb`, etc.) are normalised via `normalizePlatformForAssets`.
|
|
||||||
|
|
||||||
**Download page** (`http/home.go`, `html/download.go`): detects platform from `User-Agent`, supports manual override via `?os=` and `?arch=` query params. Linux format toggle (AppImage vs RPM) is a separate `?os=linux-rpm` variant.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Tests live alongside source in `package http` (internal) or `package http_test` (integration). Run with `make test`. Notable test files:
|
|
||||||
|
|
||||||
- `http/releases_test.go` — `classifyAsset` and `parseTag`
|
|
||||||
- `http/updatecheck_test.go` — `semverCmp`, `newerCanary`, `normalizePlatformForAssets`
|
|
||||||
- `http/server_test.go` — integration test that starts the real server
|
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
@ -8,10 +8,10 @@ import (
|
||||||
|
|
||||||
func TestClassifyAsset(t *testing.T) {
|
func TestClassifyAsset(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
wantOS string
|
wantOS string
|
||||||
wantArch string
|
wantArch string
|
||||||
wantOK bool
|
wantOK bool
|
||||||
}{
|
}{
|
||||||
// Windows
|
// Windows
|
||||||
{"QuineHyper.Setup.4.0.0.exe", "windows", "x64", true},
|
{"QuineHyper.Setup.4.0.0.exe", "windows", "x64", true},
|
||||||
|
|
@ -87,25 +87,3 @@ func TestClassifyAsset(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseTag(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
tag string
|
|
||||||
wantVer string
|
|
||||||
wantBuild int
|
|
||||||
}{
|
|
||||||
{"4.0.0", "4.0.0", 0},
|
|
||||||
{"4.0.0-q", "4.0.0", 0},
|
|
||||||
{"4.0.0q", "4.0.0", 0},
|
|
||||||
{"4.0.0-q-canary.12", "4.0.0", 12},
|
|
||||||
{"4.0.0-q-canary.1", "4.0.0", 1},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range cases {
|
|
||||||
t.Run(c.tag, func(t *testing.T) {
|
|
||||||
gotVer, gotBuild := parseTag(c.tag)
|
|
||||||
is.Equal(t, c.wantVer, gotVer)
|
|
||||||
is.Equal(t, c.wantBuild, gotBuild)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -10,7 +10,8 @@ import (
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
"maragu.dev/env"
|
"maragu.dev/env"
|
||||||
|
|
||||||
"app/http"
|
"app/internal/site"
|
||||||
|
"app/sites/hyper"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
@ -35,8 +36,9 @@ func start(log *slog.Logger) error {
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
// Set up the HTTP server, injecting the database and logger
|
// Set up the HTTP server, injecting the database and logger
|
||||||
s := http.NewServer(http.NewServerOptions{
|
s := site.NewServer(site.NewServerOptions{
|
||||||
Log: log,
|
Log: log,
|
||||||
|
Config: hyper.Config,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Use an errgroup to wait for separate goroutines which can error
|
// Use an errgroup to wait for separate goroutines which can error
|
||||||
|
|
@ -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 (
|
import (
|
||||||
. "maragu.dev/gomponents"
|
. "maragu.dev/gomponents"
|
||||||
. "maragu.dev/gomponents/html"
|
. "maragu.dev/gomponents/html"
|
||||||
|
|
||||||
|
"app/internal/component"
|
||||||
|
"app/internal/site"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HomePage is the Quine Hyper landing page.
|
// HomeContent is the Quine Hyper landing page.
|
||||||
func HomePage(props PageProps) Node {
|
func HomeContent(cfg site.Config, props component.PageProps) Node {
|
||||||
props.Title = "Quine Hyper — The thoughtful terminal"
|
props.Title = "Quine Hyper — The thoughtful terminal"
|
||||||
props.Description = "Quine Hyper is a beautiful, extensible terminal emulator forked from Vercel's Hyper. Built on web technology with a thriving plugin ecosystem."
|
props.Description = "Quine Hyper is a beautiful, extensible terminal emulator forked from Vercel's Hyper. Built on web technology with a thriving plugin ecosystem."
|
||||||
|
|
||||||
return page(props,
|
return component.Page(cfg.Branding, props,
|
||||||
// Hero
|
// Hero
|
||||||
Div(Class("py-20 text-center"),
|
Div(Class("py-20 text-center"),
|
||||||
Div(Class("mb-6 inline-flex items-center gap-2 rounded-full border border-zinc-700 bg-zinc-900 px-4 py-1.5"),
|
Div(Class("mb-6 inline-flex items-center gap-2 rounded-full border border-zinc-700 bg-zinc-900 px-4 py-1.5"),
|
||||||
|
|
@ -28,7 +31,7 @@ func HomePage(props PageProps) Node {
|
||||||
Class("inline-flex items-center gap-2 rounded-lg bg-primary-500 px-8 py-3 font-mono text-base font-semibold text-black hover:bg-primary-400 transition-colors"),
|
Class("inline-flex items-center gap-2 rounded-lg bg-primary-500 px-8 py-3 font-mono text-base font-semibold text-black hover:bg-primary-400 transition-colors"),
|
||||||
Text("Download for free"),
|
Text("Download for free"),
|
||||||
),
|
),
|
||||||
A(Href("https://github.com/quine-global/hyper"),
|
A(Href(cfg.GitHubURL),
|
||||||
Class("inline-flex items-center gap-2 rounded-lg border border-zinc-700 bg-zinc-900 px-8 py-3 font-mono text-base font-semibold text-white hover:border-zinc-500 transition-colors"),
|
Class("inline-flex items-center gap-2 rounded-lg border border-zinc-700 bg-zinc-900 px-8 py-3 font-mono text-base font-semibold text-white hover:border-zinc-500 transition-colors"),
|
||||||
Text("View on GitHub"),
|
Text("View on GitHub"),
|
||||||
),
|
),
|
||||||
|
|
@ -36,32 +39,7 @@ func HomePage(props PageProps) Node {
|
||||||
),
|
),
|
||||||
|
|
||||||
// Terminal window mockup
|
// Terminal window mockup
|
||||||
Div(Class("mx-auto mb-20 max-w-3xl overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-2xl"),
|
terminalMockup(),
|
||||||
// Title bar
|
|
||||||
Div(Class("flex items-center gap-2 border-b border-zinc-800 bg-zinc-900 px-4 py-3"),
|
|
||||||
Div(Class("h-3 w-3 rounded-full bg-red-500 opacity-80")),
|
|
||||||
Div(Class("h-3 w-3 rounded-full bg-yellow-500 opacity-80")),
|
|
||||||
Div(Class("h-3 w-3 rounded-full bg-primary-500 opacity-80")),
|
|
||||||
Span(Class("ml-4 font-mono text-xs text-zinc-500"), Text("quine-hyper — bash — 80×24")),
|
|
||||||
),
|
|
||||||
// Terminal body
|
|
||||||
Div(Class("p-6 font-mono text-sm leading-relaxed"),
|
|
||||||
termPrompt("deep-thought --answer"),
|
|
||||||
termOutput("42"),
|
|
||||||
termPrompt("hyper install infinite-improbability"),
|
|
||||||
termOutput("✔ Engaging drive..."),
|
|
||||||
termOutput("✔ Probability: 2:1 against."),
|
|
||||||
termOutput("✔ Don't Panic."),
|
|
||||||
termPrompt("ls ~/projects"),
|
|
||||||
termOutput("drwxr-xr-x earth/"),
|
|
||||||
termOutput("drwxr-xr-x magrathea/"),
|
|
||||||
termOutput("drwxr-xr-x slartibartfast/"),
|
|
||||||
Div(Class("flex items-center gap-0 text-primary-400"),
|
|
||||||
Span(Class("text-zinc-500"), Text("❯ ")),
|
|
||||||
Span(Class("animate-pulse"), Text("█")),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Features
|
// Features
|
||||||
Div(Class("pb-20"),
|
Div(Class("pb-20"),
|
||||||
|
|
@ -69,7 +47,7 @@ func HomePage(props PageProps) Node {
|
||||||
Text("Why Quine Hyper"),
|
Text("Why Quine Hyper"),
|
||||||
),
|
),
|
||||||
Div(Class("mt-12 grid grid-cols-1 gap-6 md:grid-cols-2"),
|
Div(Class("mt-12 grid grid-cols-1 gap-6 md:grid-cols-2"),
|
||||||
featureCard("Plugin Ecosystem",
|
component.FeatureCard("Plugin Ecosystem",
|
||||||
Group{
|
Group{
|
||||||
Text("Install plugins and themes from npm. Browse "),
|
Text("Install plugins and themes from npm. Browse "),
|
||||||
A(Href("https://hyper.is/plugins"),
|
A(Href("https://hyper.is/plugins"),
|
||||||
|
|
@ -85,15 +63,15 @@ func HomePage(props PageProps) Node {
|
||||||
},
|
},
|
||||||
"npm install hyper-snazzy",
|
"npm install hyper-snazzy",
|
||||||
),
|
),
|
||||||
featureCard("Cross-Platform",
|
component.FeatureCard("Cross-Platform",
|
||||||
Text("Runs on macOS, Windows, and Linux. One consistent experience across every machine you use."),
|
Text("Runs on macOS, Windows, and Linux. One consistent experience across every machine you use."),
|
||||||
"Works everywhere you do.",
|
"Works everywhere you do.",
|
||||||
),
|
),
|
||||||
featureCard("Web-Native",
|
component.FeatureCard("Web-Native",
|
||||||
Text("Built on Electron, React, and xterm.js. Hack on the terminal itself using the same tools you use every day."),
|
Text("Built on Electron, React, and xterm.js. Hack on the terminal itself using the same tools you use every day."),
|
||||||
"HTML + CSS + JS = your terminal",
|
"HTML + CSS + JS = your terminal",
|
||||||
),
|
),
|
||||||
featureCard("Fully Customizable",
|
component.FeatureCard("Fully Customizable",
|
||||||
Text("Tweak every detail with CSS. Change fonts, colors, animations, and layouts — no config file limits."),
|
Text("Tweak every detail with CSS. Change fonts, colors, animations, and layouts — no config file limits."),
|
||||||
"hyper.json → your rules",
|
"hyper.json → your rules",
|
||||||
),
|
),
|
||||||
|
|
@ -110,7 +88,7 @@ func HomePage(props PageProps) Node {
|
||||||
Text(", the open-source terminal by Vercel. We're building on that foundation with new capabilities and active development."),
|
Text(", the open-source terminal by Vercel. We're building on that foundation with new capabilities and active development."),
|
||||||
),
|
),
|
||||||
Div(Class("mt-8"),
|
Div(Class("mt-8"),
|
||||||
A(Href("https://github.com/quine-global/hyper"),
|
A(Href(cfg.GitHubURL),
|
||||||
Class("inline-block rounded-lg border border-zinc-700 bg-zinc-800 px-6 py-3 font-mono text-sm font-semibold text-white hover:border-zinc-500 transition-colors"),
|
Class("inline-block rounded-lg border border-zinc-700 bg-zinc-800 px-6 py-3 font-mono text-sm font-semibold text-white hover:border-zinc-500 transition-colors"),
|
||||||
Text("Star us on GitHub"),
|
Text("Star us on GitHub"),
|
||||||
),
|
),
|
||||||
|
|
@ -118,24 +96,3 @@ func HomePage(props PageProps) Node {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func termPrompt(cmd string) Node {
|
|
||||||
return Div(Class("flex items-center gap-2"),
|
|
||||||
Span(Class("text-primary-400"), Text("❯")),
|
|
||||||
Span(Class("text-white"), Text(cmd)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func termOutput(line string) Node {
|
|
||||||
return Div(Class("pl-5 text-zinc-400"), Text(line))
|
|
||||||
}
|
|
||||||
|
|
||||||
func featureCard(title string, body Node, code string) Node {
|
|
||||||
return Div(Class("rounded-xl border border-zinc-800 bg-zinc-900 p-8"),
|
|
||||||
H3(Class("font-mono text-base font-semibold text-white"), Text(title)),
|
|
||||||
P(Class("mt-3 font-mono text-sm text-zinc-400 leading-relaxed"), body),
|
|
||||||
Div(Class("mt-5 inline-block rounded-md bg-zinc-800 px-3 py-1.5"),
|
|
||||||
Span(Class("font-mono text-xs text-primary-400"), Text(code)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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 (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
@ -98,26 +98,3 @@ func TestClassifyAsset(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseTag(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
tag string
|
|
||||||
wantVer string
|
|
||||||
wantBuild int
|
|
||||||
}{
|
|
||||||
{"4.0.0", "4.0.0", 0},
|
|
||||||
{"4.0.0-q", "4.0.0", 0},
|
|
||||||
{"4.0.0q", "4.0.0", 0},
|
|
||||||
{"4.0.0-q-canary.12", "4.0.0", 12},
|
|
||||||
{"4.0.0-q-canary.1", "4.0.0", 1},
|
|
||||||
{"1.0.0-alpha", "1.0.0-alpha", 0},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range cases {
|
|
||||||
t.Run(c.tag, func(t *testing.T) {
|
|
||||||
gotVer, gotBuild := parseTag(c.tag)
|
|
||||||
is.Equal(t, c.wantVer, gotVer)
|
|
||||||
is.Equal(t, c.wantBuild, gotBuild)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -10,7 +10,8 @@ import (
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
"maragu.dev/env"
|
"maragu.dev/env"
|
||||||
|
|
||||||
"app/sites/quintodrome/http"
|
"app/internal/site"
|
||||||
|
"app/sites/quintodrome"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
@ -35,8 +36,9 @@ func start(log *slog.Logger) error {
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
// Set up the HTTP server, injecting the database and logger
|
// Set up the HTTP server, injecting the database and logger
|
||||||
s := http.NewServer(http.NewServerOptions{
|
s := site.NewServer(site.NewServerOptions{
|
||||||
Log: log,
|
Log: log,
|
||||||
|
Config: quintodrome.Config,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Use an errgroup to wait for separate goroutines which can error
|
// Use an errgroup to wait for separate goroutines which can error
|
||||||
|
|
@ -1,16 +1,19 @@
|
||||||
package html
|
package quintodrome
|
||||||
|
|
||||||
import (
|
import (
|
||||||
. "maragu.dev/gomponents"
|
. "maragu.dev/gomponents"
|
||||||
. "maragu.dev/gomponents/html"
|
. "maragu.dev/gomponents/html"
|
||||||
|
|
||||||
|
"app/internal/component"
|
||||||
|
"app/internal/site"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HomePage is the Quintodrome landing page.
|
// HomeContent is the Quintodrome landing page.
|
||||||
func HomePage(props PageProps) Node {
|
func HomeContent(cfg site.Config, props component.PageProps) Node {
|
||||||
props.Title = "Quintodrome — Your music, self-hosted"
|
props.Title = "Quintodrome — Your music, self-hosted"
|
||||||
props.Description = "Quintodrome wraps Navidrome, your self-hosted music server, into a native desktop app for macOS, Windows, and Linux."
|
props.Description = "Quintodrome wraps Navidrome, your self-hosted music server, into a native desktop app for macOS, Windows, and Linux."
|
||||||
|
|
||||||
return page(props,
|
return component.Page(cfg.Branding, props,
|
||||||
// Hero
|
// Hero
|
||||||
Div(Class("py-20 text-center"),
|
Div(Class("py-20 text-center"),
|
||||||
Div(Class("mb-6 inline-flex items-center gap-2 rounded-full border border-zinc-700 bg-zinc-900 px-4 py-1.5"),
|
Div(Class("mb-6 inline-flex items-center gap-2 rounded-full border border-zinc-700 bg-zinc-900 px-4 py-1.5"),
|
||||||
|
|
@ -28,7 +31,7 @@ func HomePage(props PageProps) Node {
|
||||||
Class("inline-flex items-center gap-2 rounded-lg bg-primary-500 px-8 py-3 font-mono text-base font-semibold text-black hover:bg-primary-400 transition-colors"),
|
Class("inline-flex items-center gap-2 rounded-lg bg-primary-500 px-8 py-3 font-mono text-base font-semibold text-black hover:bg-primary-400 transition-colors"),
|
||||||
Text("Download for free"),
|
Text("Download for free"),
|
||||||
),
|
),
|
||||||
A(Href("https://github.com/quine-global/quintodrome"),
|
A(Href(cfg.GitHubURL),
|
||||||
Class("inline-flex items-center gap-2 rounded-lg border border-zinc-700 bg-zinc-900 px-8 py-3 font-mono text-base font-semibold text-white hover:border-zinc-500 transition-colors"),
|
Class("inline-flex items-center gap-2 rounded-lg border border-zinc-700 bg-zinc-900 px-8 py-3 font-mono text-base font-semibold text-white hover:border-zinc-500 transition-colors"),
|
||||||
Text("View on GitHub"),
|
Text("View on GitHub"),
|
||||||
),
|
),
|
||||||
|
|
@ -36,39 +39,7 @@ func HomePage(props PageProps) Node {
|
||||||
),
|
),
|
||||||
|
|
||||||
// Player mockup
|
// Player mockup
|
||||||
Div(Class("mx-auto mb-20 max-w-3xl overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-2xl"),
|
musicPlayerMockup(),
|
||||||
// Title bar
|
|
||||||
Div(Class("flex items-center gap-2 border-b border-zinc-800 bg-zinc-900 px-4 py-3"),
|
|
||||||
Div(Class("h-3 w-3 rounded-full bg-red-500 opacity-80")),
|
|
||||||
Div(Class("h-3 w-3 rounded-full bg-yellow-500 opacity-80")),
|
|
||||||
Div(Class("h-3 w-3 rounded-full bg-primary-500 opacity-80")),
|
|
||||||
Span(Class("ml-4 font-mono text-xs text-zinc-500"), Text("quintodrome — now playing")),
|
|
||||||
),
|
|
||||||
// Now-playing body
|
|
||||||
Div(Class("p-8"),
|
|
||||||
Div(Class("flex items-center gap-5"),
|
|
||||||
Div(Class("h-20 w-20 shrink-0 rounded-lg bg-gradient-to-br from-primary-400 to-primary-500")),
|
|
||||||
Div(Class("min-w-0 text-left"),
|
|
||||||
P(Class("truncate font-mono text-base font-semibold text-white"), Text("Brandenburg Concerto No. 3")),
|
|
||||||
P(Class("mt-1 truncate font-mono text-sm text-zinc-400"), Text("J.S. Bach — Library/Classical")),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Div(Class("mt-6"),
|
|
||||||
Div(Class("h-1.5 w-full overflow-hidden rounded-full bg-zinc-800"),
|
|
||||||
Div(Class("h-full w-1/3 rounded-full bg-primary-400")),
|
|
||||||
),
|
|
||||||
Div(Class("mt-2 flex justify-between font-mono text-xs text-zinc-500"),
|
|
||||||
Span(Text("1:42")),
|
|
||||||
Span(Text("5:07")),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Div(Class("mt-6 flex items-center justify-center gap-6 font-mono text-lg text-zinc-400"),
|
|
||||||
Span(Text("⏮")),
|
|
||||||
Span(Class("text-2xl text-white"), Text("⏸")),
|
|
||||||
Span(Text("⏭")),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Features
|
// Features
|
||||||
Div(Class("pb-20"),
|
Div(Class("pb-20"),
|
||||||
|
|
@ -76,15 +47,15 @@ func HomePage(props PageProps) Node {
|
||||||
Text("Why Quintodrome"),
|
Text("Why Quintodrome"),
|
||||||
),
|
),
|
||||||
Div(Class("mt-12 grid grid-cols-1 gap-6 md:grid-cols-2"),
|
Div(Class("mt-12 grid grid-cols-1 gap-6 md:grid-cols-2"),
|
||||||
featureCard("Zero Setup",
|
component.FeatureCard("Zero Setup",
|
||||||
Text("Launch the app and it provisions and starts its own music server for you — no admin account to create, no config file to write."),
|
Text("Launch the app and it provisions and starts its own music server for you — no admin account to create, no config file to write."),
|
||||||
"double-click → listening",
|
"double-click → listening",
|
||||||
),
|
),
|
||||||
featureCard("Cross-Platform",
|
component.FeatureCard("Cross-Platform",
|
||||||
Text("Native apps for macOS, Windows, and Linux, all wrapping the same self-hosted server underneath."),
|
Text("Native apps for macOS, Windows, and Linux, all wrapping the same self-hosted server underneath."),
|
||||||
"Works everywhere you do.",
|
"Works everywhere you do.",
|
||||||
),
|
),
|
||||||
featureCard("Your Library, Your Rules",
|
component.FeatureCard("Your Library, Your Rules",
|
||||||
Group{
|
Group{
|
||||||
Text("Point it at your music folder and it's yours: no subscription, no catalog gaps, no algorithm. Built on "),
|
Text("Point it at your music folder and it's yours: no subscription, no catalog gaps, no algorithm. Built on "),
|
||||||
A(Href("https://www.navidrome.org"),
|
A(Href("https://www.navidrome.org"),
|
||||||
|
|
@ -95,7 +66,7 @@ func HomePage(props PageProps) Node {
|
||||||
},
|
},
|
||||||
"~/Music → your library",
|
"~/Music → your library",
|
||||||
),
|
),
|
||||||
featureCard("Subsonic Compatible",
|
component.FeatureCard("Subsonic Compatible",
|
||||||
Text("Speaks the Subsonic/OpenSubsonic API, so your library also works with the mobile and desktop clients you already use."),
|
Text("Speaks the Subsonic/OpenSubsonic API, so your library also works with the mobile and desktop clients you already use."),
|
||||||
"stream anywhere, from one server",
|
"stream anywhere, from one server",
|
||||||
),
|
),
|
||||||
|
|
@ -112,7 +83,7 @@ func HomePage(props PageProps) Node {
|
||||||
Text(", the open-source self-hosted music server. We wrap it in a native desktop shell so there's nothing to install or configure separately."),
|
Text(", the open-source self-hosted music server. We wrap it in a native desktop shell so there's nothing to install or configure separately."),
|
||||||
),
|
),
|
||||||
Div(Class("mt-8"),
|
Div(Class("mt-8"),
|
||||||
A(Href("https://github.com/quine-global/quintodrome"),
|
A(Href(cfg.GitHubURL),
|
||||||
Class("inline-block rounded-lg border border-zinc-700 bg-zinc-800 px-6 py-3 font-mono text-sm font-semibold text-white hover:border-zinc-500 transition-colors"),
|
Class("inline-block rounded-lg border border-zinc-700 bg-zinc-800 px-6 py-3 font-mono text-sm font-semibold text-white hover:border-zinc-500 transition-colors"),
|
||||||
Text("Star us on GitHub"),
|
Text("Star us on GitHub"),
|
||||||
),
|
),
|
||||||
|
|
@ -120,13 +91,3 @@ func HomePage(props PageProps) Node {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func featureCard(title string, body Node, code string) Node {
|
|
||||||
return Div(Class("rounded-xl border border-zinc-800 bg-zinc-900 p-8"),
|
|
||||||
H3(Class("font-mono text-base font-semibold text-white"), Text(title)),
|
|
||||||
P(Class("mt-3 font-mono text-sm text-zinc-400 leading-relaxed"), body),
|
|
||||||
Div(Class("mt-5 inline-block rounded-md bg-zinc-800 px-3 py-1.5"),
|
|
||||||
Span(Class("font-mono text-xs text-primary-400"), Text(code)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
content: ["./html/**/*.go", "./http/**/*.go"],
|
content: ["./internal/**/*.go", "./sites/**/*.go"],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue