From 6bf3a2b355d38a909b42123d152754dbf70abd04 Mon Sep 17 00:00:00 2001 From: James Kane Date: Sun, 2 Aug 2026 09:11:20 -0500 Subject: [PATCH] feat(web): Navigator download page with per-platform installer links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `/download` — Windows / macOS / Linux installers for the Navigator edge app — plus `/download/{windows,macos,linux}`, permanent redirects that always land on the current build. The links can't be hard-coded. Tauri stamps the app version into every filename (`navigator_0.1.0_x64-setup.exe`), and GitHub's `/releases/latest` excludes pre-releases — which is all the Navigator publishes, so "latest" currently resolves to the `assets-chm13v2.0` reference-data release, not the app. So `du-external::github` lists releases newest-first and takes the first one that actually carries installer assets (drafts skipped, pre-releases kept), classifies each asset by platform/arch/format, and picks a primary build per platform: x64 .exe, universal .dmg, x86_64 AppImage. du-web caches the resolution process-wide (30 min on success, 5 min after a failure) so the page costs ~2 GitHub calls an hour against an unauthenticated limit of 60. If GitHub is unreachable the page degrades to the releases listing rather than erroring. The visitor's own platform is detected from the User-Agent and offered first; secondary builds (arm64, .deb) and the SHA256SUMS link stay on the page. Also links the page from the Tools nav, the home "participate" section and the FAQ answer that mentions the Navigator, adds it to the sitemap, and corrects that FAQ answer's claim that the Navigator is built on the JVM (it is Rust). `DU_NAVIGATOR_REPO` overrides the source repo; `GITHUB_TOKEN`, if set, raises the API rate limit. Co-Authored-By: Claude Opus 5 (1M context) --- rust/README.md | 1 + rust/crates/du-external/src/github.rs | 394 ++++++++++++++++++ rust/crates/du-external/src/lib.rs | 6 +- rust/crates/du-web/src/routes/download.rs | 354 ++++++++++++++++ rust/crates/du-web/src/routes/mod.rs | 2 + rust/crates/du-web/src/routes/pages.rs | 2 +- rust/crates/du-web/templates/base.html | 2 + rust/crates/du-web/templates/index.html | 3 +- .../du-web/templates/static/download.html | 72 ++++ rust/crates/du-web/templates/static/page.html | 3 +- rust/locales/en.txt | 20 + rust/locales/es.txt | 20 + rust/locales/fr.txt | 20 + 13 files changed, 894 insertions(+), 5 deletions(-) create mode 100644 rust/crates/du-external/src/github.rs create mode 100644 rust/crates/du-web/src/routes/download.rs create mode 100644 rust/crates/du-web/templates/static/download.html diff --git a/rust/README.md b/rust/README.md index c40d5de8..9e74543f 100644 --- a/rust/README.md +++ b/rust/README.md @@ -109,6 +109,7 @@ Postgres schemas: `core`, `tree`, `genomics`, `pubs`, `ident`, `fed`, `ibd`, | References + per-publication biosamples; suggest-a-paper | `/references` (+ report), `/references/submit` (public DOI → candidate queue) | | Biosample map (PostGIS → Leaflet GeoJSON) | `/biosamples/map` `/biosamples/geo-data` | | Coverage benchmarks + per-lab drill-down | `/coverage-benchmarks` `/coverage/labs` | +| Navigator downloads — installers resolved from GitHub Releases | `/download` (per-platform builds, cached 30 min); `/download/{windows,macos,linux}` permanent redirects to the current installer | | Profile (view + display-name update); contact (reCAPTCHA) | `/profile` `/contact` | | sitemap / robots / health; cookie-consent banner | `/sitemap.xml` `/robots.txt` `/health` `/cookie-consent` | | Public JSON API + OpenAPI 3 / Swagger UI | `/api`, `/api/v1/*` (see below) | diff --git a/rust/crates/du-external/src/github.rs b/rust/crates/du-external/src/github.rs new file mode 100644 index 00000000..08833764 --- /dev/null +++ b/rust/crates/du-external/src/github.rs @@ -0,0 +1,394 @@ +//! GitHub Releases client — resolves the current Navigator installer downloads. +//! +//! The obvious approach (link `…/releases/latest/download/`) does not work +//! for this project on two counts: +//! +//! 1. GitHub's "latest" excludes pre-releases, and every Navigator installer +//! release is an alpha pre-release. The repo's newest *stable* release is a +//! reference-data drop (`assets-chm13v2.0`), so "latest" points at the wrong +//! thing entirely. +//! 2. Tauri bundles the app version into every filename +//! (`navigator_0.1.0_x64-setup.exe`), so there is no stable asset name to +//! hard-code. +//! +//! So the site resolves downloads at runtime: list releases (newest first, drafts +//! skipped, pre-releases kept) and take the first one that actually carries +//! installer assets. Parsing and asset classification are pure and unit-tested; +//! only [`GithubClient::releases`] touches the network. + +use crate::error::ExternalError; +use chrono::{DateTime, Utc}; +use serde::Deserialize; + +const DEFAULT_BASE: &str = "https://api.github.com"; +/// GitHub rejects API requests without a User-Agent. +const USER_AGENT: &str = "decoding-us.com (+https://decoding-us.com)"; +/// How many releases to scan for installers. Generous enough to see past a run of +/// asset-only or notes-only releases without paging. +const SCAN_DEPTH: u8 = 30; + +/// Which platform an installer targets. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Platform { + Windows, + MacOs, + Linux, +} + +impl Platform { + /// Stable slug used in `/download/{slug}` URLs. + pub fn slug(self) -> &'static str { + match self { + Self::Windows => "windows", + Self::MacOs => "macos", + Self::Linux => "linux", + } + } + + pub fn parse(s: &str) -> Option { + match s { + "windows" | "win" => Some(Self::Windows), + "macos" | "mac" | "osx" => Some(Self::MacOs), + "linux" => Some(Self::Linux), + _ => None, + } + } +} + +/// A downloadable installer: one release asset, classified. +#[derive(Debug, Clone, PartialEq)] +pub struct Installer { + pub platform: Platform, + /// `x86_64`, `arm64`, or `universal`. + pub arch: &'static str, + /// Package format shown to the user: `exe`, `msi`, `dmg`, `AppImage`, `deb`, `rpm`. + pub kind: &'static str, + pub name: String, + pub url: String, + pub size: u64, + /// Preferred pick for this platform's one-click `/download/{platform}` link. + /// The mainstream desktop build: x64 Windows installer, universal macOS + /// disk image, x86_64 Linux AppImage. + pub primary: bool, +} + +/// A resolved release with its installer downloads. +#[derive(Debug, Clone, PartialEq)] +pub struct ReleaseDownloads { + /// Git tag (`v0.1.0-alpha.15`). + pub tag: String, + /// Release title, when it differs from the tag. + pub name: Option, + pub html_url: String, + pub published_at: Option>, + pub prerelease: bool, + pub installers: Vec, + /// The `SHA256SUMS` asset, when the release publishes one. + pub checksums_url: Option, +} + +impl ReleaseDownloads { + /// The one-click download for a platform: its primary installer, else its + /// first installer of any kind. + pub fn primary_for(&self, p: Platform) -> Option<&Installer> { + let mut for_platform = self.installers.iter().filter(|i| i.platform == p); + let first = for_platform.clone().find(|i| i.primary); + first.or_else(|| for_platform.next()) + } + + /// Installers for a platform, primary first — what the download page lists. + pub fn for_platform(&self, p: Platform) -> Vec<&Installer> { + let mut v: Vec<&Installer> = self.installers.iter().filter(|i| i.platform == p).collect(); + v.sort_by_key(|i| !i.primary); + v + } +} + +// ── raw API shapes ─────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct ApiRelease { + tag_name: String, + name: Option, + html_url: String, + published_at: Option, + #[serde(default)] + draft: bool, + #[serde(default)] + prerelease: bool, + #[serde(default)] + assets: Vec, +} + +#[derive(Deserialize)] +struct ApiAsset { + name: String, + browser_download_url: String, + #[serde(default)] + size: u64, +} + +/// Classify a release asset by filename. Returns `None` for anything that isn't a +/// user-installable build (checksums, signatures, update manifests, source +/// tarballs, the `.app.tar.gz` updater bundle). +fn classify(name: &str, url: &str, size: u64) -> Option { + let lower = name.to_ascii_lowercase(); + // Tauri's updater artifacts sit beside the installers; they are not downloads. + if lower.ends_with(".sig") || lower.ends_with(".app.tar.gz") || lower.ends_with(".tar.gz.sig") { + return None; + } + let (platform, kind) = if lower.ends_with("-setup.exe") || lower.ends_with(".exe") { + (Platform::Windows, "exe") + } else if lower.ends_with(".msi") { + (Platform::Windows, "msi") + } else if lower.ends_with(".dmg") { + (Platform::MacOs, "dmg") + } else if lower.ends_with(".appimage") { + (Platform::Linux, "AppImage") + } else if lower.ends_with(".deb") { + (Platform::Linux, "deb") + } else if lower.ends_with(".rpm") { + (Platform::Linux, "rpm") + } else { + return None; + }; + + // Architecture from the filename's arch token. Tauri spells the same + // architecture differently per bundle (x64 / x86_64 / amd64). + let arch = if lower.contains("universal") { + "universal" + } else if lower.contains("aarch64") || lower.contains("arm64") { + "arm64" + } else { + // Windows/macOS bundles occasionally omit the token; x86_64 is the default target. + "x86_64" + }; + + let primary = match platform { + Platform::Windows => kind == "exe" && arch == "x86_64", + Platform::MacOs => kind == "dmg" && (arch == "universal" || arch == "x86_64"), + // AppImage runs on any distro; .deb is Debian/Ubuntu-only. + Platform::Linux => kind == "AppImage" && arch == "x86_64", + }; + + Some(Installer { + platform, + arch, + kind, + name: name.to_string(), + url: url.to_string(), + size, + primary, + }) +} + +fn to_downloads(r: ApiRelease) -> ReleaseDownloads { + let checksums_url = r + .assets + .iter() + .find(|a| { + let l = a.name.to_ascii_lowercase(); + l.starts_with("sha256") || l.ends_with(".sha256") + }) + .map(|a| a.browser_download_url.clone()); + let installers = + r.assets.iter().filter_map(|a| classify(&a.name, &a.browser_download_url, a.size)).collect(); + ReleaseDownloads { + name: r.name.filter(|n| !n.trim().is_empty() && *n != r.tag_name), + tag: r.tag_name, + html_url: r.html_url, + published_at: r.published_at.as_deref().and_then(|s| DateTime::parse_from_rfc3339(s).ok()).map(Into::into), + prerelease: r.prerelease, + installers, + checksums_url, + } +} + +/// Pick the newest release that actually ships installers. +/// +/// The API returns releases newest-first. Drafts are skipped (not public); +/// pre-releases are kept, because that is all the Navigator publishes today. +/// Releases carrying only data assets or release notes are skipped, which is what +/// keeps the reference-data drop (`assets-chm13v2.0`) from being served as the app. +fn pick_installer_release(json: &str) -> Result, ExternalError> { + let releases: Vec = + serde_json::from_str(json).map_err(|e| ExternalError::Parse(e.to_string()))?; + Ok(releases + .into_iter() + .filter(|r| !r.draft) + .map(to_downloads) + .find(|r| !r.installers.is_empty())) +} + +pub struct GithubClient { + http: reqwest::Client, + base: String, + /// Optional PAT. Unauthenticated is 60 requests/hour per IP, which the + /// caller's cache keeps us far inside; a token raises it if ever needed. + token: Option, +} + +impl Default for GithubClient { + fn default() -> Self { + Self::new() + } +} + +impl GithubClient { + pub fn new() -> Self { + GithubClient { + http: reqwest::Client::new(), + base: DEFAULT_BASE.to_string(), + token: std::env::var("GITHUB_TOKEN").ok().filter(|t| !t.is_empty()), + } + } + + /// Point the client at a different API origin (tests). + pub fn with_base(mut self, base: impl Into) -> Self { + self.base = base.into(); + self + } + + /// The newest release of `owner/repo` that ships installers, or `None` when + /// the repo has published none. + pub async fn latest_installer_release( + &self, + owner: &str, + repo: &str, + ) -> Result, ExternalError> { + let url = format!("{}/repos/{owner}/{repo}/releases?per_page={SCAN_DEPTH}", self.base); + let mut req = self + .http + .get(url) + .header(reqwest::header::USER_AGENT, USER_AGENT) + .header(reqwest::header::ACCEPT, "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28"); + if let Some(t) = &self.token { + req = req.bearer_auth(t); + } + let body = req.send().await?.error_for_status()?.text().await?; + pick_installer_release(&body) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Trimmed shape of the real `JamesKane/decodingus-navigator` response: an + /// installer pre-release, then the stable reference-data release that + /// GitHub's own "latest" would hand back. + const FIXTURE: &str = r#"[ + { + "tag_name": "v0.1.0-alpha.15", + "name": "v0.1.0-alpha.15", + "html_url": "https://github.com/o/r/releases/tag/v0.1.0-alpha.15", + "published_at": "2026-08-01T12:38:34Z", + "draft": false, + "prerelease": true, + "assets": [ + {"name": "DUNavigator_0.1.0_universal.dmg", "browser_download_url": "https://x/dmg", "size": 142161474}, + {"name": "navigator_0.1.0_aarch64.AppImage", "browser_download_url": "https://x/aarch64.AppImage", "size": 129448456}, + {"name": "navigator_0.1.0_amd64.deb", "browser_download_url": "https://x/amd64.deb", "size": 137012312}, + {"name": "navigator_0.1.0_arm64.deb", "browser_download_url": "https://x/arm64.deb", "size": 136128772}, + {"name": "navigator_0.1.0_x64-setup.exe", "browser_download_url": "https://x/exe", "size": 109567821}, + {"name": "navigator_0.1.0_x86_64.AppImage", "browser_download_url": "https://x/x86_64.AppImage", "size": 130357752}, + {"name": "SHA256SUMS", "browser_download_url": "https://x/sums", "size": 575} + ] + }, + { + "tag_name": "assets-chm13v2.0", + "name": "Ancestry/IBD + STR assets (chm13v2.0)", + "html_url": "https://github.com/o/r/releases/tag/assets-chm13v2.0", + "published_at": "2026-07-11T16:28:52Z", + "draft": false, + "prerelease": false, + "assets": [{"name": "ancestry-panel.tar.zst", "browser_download_url": "https://x/panel", "size": 12}] + } + ]"#; + + fn fixture() -> ReleaseDownloads { + pick_installer_release(FIXTURE).unwrap().expect("a release with installers") + } + + #[test] + fn picks_the_installer_release_over_githubs_latest() { + let r = fixture(); + // The data-only release is newer in GitHub's "latest" sense (it is the only + // non-prerelease) but ships no installers, so it must not win. + assert_eq!(r.tag, "v0.1.0-alpha.15"); + assert!(r.prerelease, "the app only publishes alphas today"); + assert_eq!(r.name, None, "a title equal to the tag isn't worth repeating"); + assert_eq!(r.published_at.unwrap().to_rfc3339(), "2026-08-01T12:38:34+00:00"); + assert_eq!(r.checksums_url.as_deref(), Some("https://x/sums")); + } + + #[test] + fn classifies_every_installer_and_drops_the_rest() { + let r = fixture(); + assert_eq!(r.installers.len(), 6, "6 installers; SHA256SUMS is not one"); + let win = r.for_platform(Platform::Windows); + assert_eq!(win.len(), 1); + assert_eq!((win[0].kind, win[0].arch), ("exe", "x86_64")); + + let mac = r.for_platform(Platform::MacOs); + assert_eq!(mac.len(), 1); + assert_eq!((mac[0].kind, mac[0].arch), ("dmg", "universal")); + + // Linux ships four: AppImage + deb, each x86_64 + arm64. + let linux = r.for_platform(Platform::Linux); + assert_eq!(linux.len(), 4); + assert!(linux[0].primary, "primary sorts first"); + assert_eq!((linux[0].kind, linux[0].arch), ("AppImage", "x86_64")); + } + + #[test] + fn one_click_link_resolves_per_platform() { + let r = fixture(); + assert_eq!(r.primary_for(Platform::Windows).unwrap().url, "https://x/exe"); + assert_eq!(r.primary_for(Platform::MacOs).unwrap().url, "https://x/dmg"); + // Not the arm64 AppImage and not the .deb. + assert_eq!(r.primary_for(Platform::Linux).unwrap().url, "https://x/x86_64.AppImage"); + } + + #[test] + fn falls_back_to_any_installer_when_none_is_primary() { + let json = r#"[{"tag_name":"v1","html_url":"h","assets":[ + {"name":"navigator_1.0.0_arm64.deb","browser_download_url":"https://x/arm64.deb","size":1}]}]"#; + let r = pick_installer_release(json).unwrap().unwrap(); + assert!(!r.installers[0].primary, "an arm64 .deb is nobody's default"); + assert_eq!(r.primary_for(Platform::Linux).unwrap().url, "https://x/arm64.deb"); + assert_eq!(r.primary_for(Platform::Windows), None, "no Windows build in this release"); + } + + #[test] + fn skips_drafts_and_updater_artifacts() { + let json = r#"[ + {"tag_name":"draft","html_url":"h","draft":true,"assets":[ + {"name":"navigator_9.9.9_x64-setup.exe","browser_download_url":"https://x/draft","size":1}]}, + {"tag_name":"v1","html_url":"h","assets":[ + {"name":"DUNavigator.app.tar.gz","browser_download_url":"https://x/updater","size":1}, + {"name":"navigator_1.0.0_x64-setup.exe.sig","browser_download_url":"https://x/sig","size":1}, + {"name":"navigator_1.0.0_x64-setup.exe","browser_download_url":"https://x/exe","size":1}]} + ]"#; + let r = pick_installer_release(json).unwrap().unwrap(); + assert_eq!(r.tag, "v1", "an unpublished draft is not a download"); + assert_eq!(r.installers.len(), 1, "updater bundle and signature are not installers"); + } + + #[test] + fn no_installers_anywhere_is_not_an_error() { + let json = r#"[{"tag_name":"assets-only","html_url":"h","assets":[ + {"name":"panel.tar.zst","browser_download_url":"https://x/p","size":1}]}]"#; + assert_eq!(pick_installer_release(json).unwrap(), None); + assert_eq!(pick_installer_release("[]").unwrap(), None); + } + + #[test] + fn platform_slugs_round_trip() { + for p in [Platform::Windows, Platform::MacOs, Platform::Linux] { + assert_eq!(Platform::parse(p.slug()), Some(p)); + } + assert_eq!(Platform::parse("solaris"), None); + } +} diff --git a/rust/crates/du-external/src/lib.rs b/rust/crates/du-external/src/lib.rs index 2f1f61da..17ba43aa 100644 --- a/rust/crates/du-external/src/lib.rs +++ b/rust/crates/du-external/src/lib.rs @@ -1,11 +1,13 @@ //! External service clients (plan §7). OpenAlex (publication enrichment + //! discovery), ENA (study metadata), NCBI/PubMed (publication enrichment by -//! PMID). HTTP via reqwest; JSON→domain parsing is pure and unit-tested. AWS -//! SES/Secrets + reCAPTCHA land here later. +//! PMID), GitHub Releases (Navigator installer downloads). HTTP via reqwest; +//! JSON→domain parsing is pure and unit-tested. AWS SES/Secrets + reCAPTCHA land +//! here later. pub mod email; pub mod ena; pub mod error; +pub mod github; pub mod ncbi; pub mod openalex; pub mod secrets; diff --git a/rust/crates/du-web/src/routes/download.rs b/rust/crates/du-web/src/routes/download.rs new file mode 100644 index 00000000..dcc152cf --- /dev/null +++ b/rust/crates/du-web/src/routes/download.rs @@ -0,0 +1,354 @@ +//! Navigator download page (`/download`) + stable per-platform links. +//! +//! The Navigator edge app ships via GitHub Releases. Installer URLs can't be +//! hard-coded — Tauri stamps the app version into every filename, and GitHub's +//! `/releases/latest` skips pre-releases (which is all the Navigator publishes, +//! so "latest" resolves to an unrelated reference-data release). So the page +//! resolves the newest release carrying installers at request time, through a +//! process-wide cache, and `/download/{windows,macos,linux}` 302s straight to +//! that release's asset — permanent URLs that always land on the current build. +//! +//! GitHub being unreachable is not an error state for the site: the page (and the +//! redirects) fall back to the repo's releases listing. + +use crate::i18n::{Locale, T}; +use crate::render::html; +use crate::state::AppState; +use axum::extract::Path; +use axum::response::{IntoResponse, Redirect, Response}; +use axum::routing::get; +use axum::Router; +use du_external::github::{GithubClient, Installer, Platform, ReleaseDownloads}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use crate::auth::{MaybeUser, NavUser}; + +/// `owner/repo` publishing the Navigator installers. Overridable so a fork or a +/// staging deploy can point elsewhere. +const DEFAULT_REPO: &str = "JamesKane/decodingus-navigator"; +/// Re-resolve at most this often. Well inside GitHub's unauthenticated 60/hour. +const OK_TTL: Duration = Duration::from_secs(30 * 60); +/// Back off briefly after a failure instead of hammering the API per request. +const ERR_TTL: Duration = Duration::from_secs(5 * 60); + +pub fn router() -> Router { + Router::new() + .route("/download", get(page)) + // Not hx-boosted: these are cross-origin redirects to GitHub. + .route("/download/:platform", get(redirect_platform)) +} + +fn repo() -> (String, String) { + let spec = std::env::var("DU_NAVIGATOR_REPO").unwrap_or_else(|_| DEFAULT_REPO.to_string()); + match spec.split_once('/') { + Some((o, r)) if !o.is_empty() && !r.is_empty() => (o.to_string(), r.to_string()), + _ => { + tracing::warn!(spec, "DU_NAVIGATOR_REPO is not owner/repo — using the default"); + DEFAULT_REPO.split_once('/').map(|(o, r)| (o.to_string(), r.to_string())).unwrap() + } + } +} + +fn releases_url() -> String { + let (owner, repo) = repo(); + format!("https://github.com/{owner}/{repo}/releases") +} + +// ── resolution cache ───────────────────────────────────────────────────────── + +/// Last resolution + when it happened. `release: None` records "GitHub said +/// nothing useful (or didn't answer)", which is cached briefly too. +struct Entry { + at: Instant, + release: Option>, +} + +static CACHE: OnceLock>> = OnceLock::new(); + +fn cache() -> &'static Mutex> { + CACHE.get_or_init(|| Mutex::new(None)) +} + +/// The current release, from cache when fresh. Concurrent misses may each fetch; +/// that's a bounded, harmless duplicate rather than a lock held across an await. +async fn current_release() -> Option> { + { + let guard = cache().lock().expect("download cache mutex"); + if let Some(e) = guard.as_ref() { + let ttl = if e.release.is_some() { OK_TTL } else { ERR_TTL }; + if e.at.elapsed() < ttl { + return e.release.clone(); + } + } + } + + let (owner, name) = repo(); + let fetched = match GithubClient::new().latest_installer_release(&owner, &name).await { + Ok(Some(r)) => Some(Arc::new(r)), + Ok(None) => { + tracing::warn!(repo = %format!("{owner}/{name}"), "no release publishes installer assets"); + None + } + Err(e) => { + // Serve the fallback rather than a 500: a download page that links the + // releases listing still gets people the app. + tracing::warn!(error = %e, "GitHub release lookup failed"); + None + } + }; + *cache().lock().expect("download cache mutex") = + Some(Entry { at: Instant::now(), release: fetched.clone() }); + fetched +} + +// ── view model ─────────────────────────────────────────────────────────────── + +/// One downloadable file. The list is ordered primary-first, which is what the +/// template keys the big button off. +struct FileView { + name: String, + url: String, + size: String, + kind: String, + arch: String, +} + +struct PlatformView { + slug: &'static str, + label: String, + icon: &'static str, + /// Requirements/format note, e.g. "Windows 10 or later". + note: String, + files: Vec, + /// Matches the visitor's own OS (per User-Agent) — rendered first, highlighted. + detected: bool, +} + +struct DownloadView { + /// `None` when GitHub couldn't be reached or publishes no installers; the + /// template then falls back to the releases listing. + version: Option, + published: Option, + prerelease: bool, + release_url: String, + releases_url: String, + checksums_url: Option, + platforms: Vec, +} + +#[derive(askama::Template)] +#[template(path = "static/download.html")] +struct DownloadTemplate { + t: T, + next: String, + user: Option, + d: DownloadView, +} + +/// Human-readable size. GitHub reports exact bytes; installers are ~100–140 MB, +/// so MB with one decimal is the useful granularity. +fn fmt_size(bytes: u64) -> String { + const MB: f64 = 1024.0 * 1024.0; + if bytes == 0 { + return String::new(); + } + format!("{:.1} MB", bytes as f64 / MB) +} + +/// Guess the visitor's platform from the User-Agent so their build is offered +/// first. Only ever a convenience — every platform stays visible and downloadable. +fn detect_platform(user_agent: &str) -> Option { + let ua = user_agent.to_ascii_lowercase(); + // Order matters: Android UAs contain "linux", and iOS UAs contain "mac os x". + if ua.contains("android") || ua.contains("iphone") || ua.contains("ipad") { + return None; + } + if ua.contains("windows") { + Some(Platform::Windows) + } else if ua.contains("mac os") || ua.contains("macintosh") { + Some(Platform::MacOs) + } else if ua.contains("linux") || ua.contains("x11") { + Some(Platform::Linux) + } else { + None + } +} + +fn to_file(i: &Installer) -> FileView { + FileView { + name: i.name.clone(), + url: i.url.clone(), + size: fmt_size(i.size), + kind: i.kind.to_string(), + arch: i.arch.to_string(), + } +} + +fn build_view(release: Option<&ReleaseDownloads>, t: &T, detected: Option) -> DownloadView { + let specs = [ + (Platform::Windows, "windows", "bi-windows", "dl.note.windows"), + (Platform::MacOs, "macos", "bi-apple", "dl.note.macos"), + (Platform::Linux, "linux", "bi-ubuntu", "dl.note.linux"), + ]; + let mut platforms: Vec = specs + .iter() + .map(|(p, slug, icon, note)| PlatformView { + slug, + label: t.get(&format!("dl.platform.{slug}")).to_string(), + icon, + note: t.get(note).to_string(), + files: release.map(|r| r.for_platform(*p).into_iter().map(to_file).collect()).unwrap_or_default(), + detected: detected == Some(*p), + }) + .collect(); + // The visitor's own platform leads; the rest keep Windows/macOS/Linux order. + platforms.sort_by_key(|p| !p.detected); + + DownloadView { + version: release.map(|r| r.tag.clone()), + published: release.and_then(|r| r.published_at).map(|d| d.format("%Y-%m-%d").to_string()), + prerelease: release.map(|r| r.prerelease).unwrap_or(false), + release_url: release.map(|r| r.html_url.clone()).unwrap_or_else(releases_url), + releases_url: releases_url(), + checksums_url: release.and_then(|r| r.checksums_url.clone()), + platforms, + } +} + +async fn page(locale: Locale, user: MaybeUser, headers: axum::http::HeaderMap) -> Response { + let ua = headers.get(axum::http::header::USER_AGENT).and_then(|v| v.to_str().ok()).unwrap_or(""); + let release = current_release().await; + let d = build_view(release.as_deref(), &locale.t, detect_platform(ua)); + html(&DownloadTemplate { t: locale.t, next: locale.next, user: user.nav(), d }) +} + +/// `GET /download/{windows|macos|linux}` — the permanent link. 302s to the +/// current release's installer for that platform, or to the releases listing when +/// it can't be resolved (unknown platform slug included: better a real page than +/// a 404). +async fn redirect_platform(Path(platform): Path) -> Response { + let target = match Platform::parse(&platform.to_ascii_lowercase()) { + Some(p) => current_release() + .await + .and_then(|r| r.primary_for(p).map(|i| i.url.clone())) + .unwrap_or_else(releases_url), + None => releases_url(), + }; + // Temporary: the target changes with every release, so it must not be cached. + Redirect::temporary(&target).into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::i18n::Lang; + use askama::Template; + use du_external::github::Installer; + + fn t() -> T { + T::new(Lang::En) + } + + fn installer(platform: Platform, kind: &'static str, arch: &'static str, primary: bool) -> Installer { + Installer { + platform, + arch, + kind, + name: format!("navigator_0.1.0_{arch}.{kind}"), + url: format!("https://x/{arch}.{kind}"), + size: 110_000_000, + primary, + } + } + + fn release() -> ReleaseDownloads { + ReleaseDownloads { + tag: "v0.1.0-alpha.15".into(), + name: None, + html_url: "https://github.com/o/r/releases/tag/v0.1.0-alpha.15".into(), + published_at: Some("2026-08-01T12:38:34Z".parse().unwrap()), + prerelease: true, + installers: vec![ + installer(Platform::Windows, "exe", "x86_64", true), + installer(Platform::MacOs, "dmg", "universal", true), + installer(Platform::Linux, "AppImage", "x86_64", true), + installer(Platform::Linux, "deb", "arm64", false), + ], + checksums_url: Some("https://x/sums".into()), + } + } + + #[test] + fn page_lists_every_platform_with_version_and_checksums() { + let html = DownloadTemplate { + t: t(), + next: "/".into(), + user: None, + d: build_view(Some(&release()), &t(), None), + } + .render() + .unwrap(); + assert!(html.contains("v0.1.0-alpha.15"), "names the resolved version"); + assert!(html.contains("2026-08-01"), "and when it shipped"); + assert!(html.contains("https://x/x86_64.exe"), "windows installer"); + assert!(html.contains("https://x/universal.dmg"), "macOS installer"); + assert!(html.contains("https://x/x86_64.AppImage"), "linux installer"); + assert!(html.contains("https://x/arm64.deb"), "and the secondary linux build"); + assert!(html.contains("https://x/sums"), "checksums for verification"); + assert!(html.contains("104.9 MB"), "sizes are human-readable"); + assert!(html.contains(t().get("dl.prerelease")), "alpha builds are labelled as such"); + } + + #[test] + fn unresolved_release_still_links_the_releases_page() { + let d = build_view(None, &t(), None); + assert_eq!(d.version, None); + assert!(d.platforms.iter().all(|p| p.files.is_empty())); + let html = + DownloadTemplate { t: t(), next: "/".into(), user: None, d }.render().unwrap(); + assert!(html.contains(&releases_url()), "falls back to the releases listing"); + // A substring, not the whole string: Askama escapes the apostrophe in "couldn't". + assert!(html.contains("the direct links are unavailable"), "and says why there are no buttons"); + assert!(html.contains("alert-warning"), "as a visible warning"); + } + + #[test] + fn visitors_own_platform_is_offered_first() { + let d = build_view(Some(&release()), &t(), Some(Platform::Linux)); + assert_eq!(d.platforms[0].slug, "linux"); + assert!(d.platforms[0].detected); + assert_eq!(d.platforms.len(), 3, "the others are still listed"); + // With no detection the canonical order stands. + let d = build_view(Some(&release()), &t(), None); + assert_eq!(d.platforms.iter().map(|p| p.slug).collect::>(), ["windows", "macos", "linux"]); + } + + #[test] + fn user_agent_detection_covers_the_desktop_three() { + let win = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"; + let mac = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"; + let linux = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"; + assert_eq!(detect_platform(win), Some(Platform::Windows)); + assert_eq!(detect_platform(mac), Some(Platform::MacOs)); + assert_eq!(detect_platform(linux), Some(Platform::Linux)); + // Phones run neither installer: don't pretend one of them is "theirs". + assert_eq!(detect_platform("Mozilla/5.0 (Linux; Android 14; Pixel 8)"), None); + assert_eq!(detect_platform("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)"), None); + assert_eq!(detect_platform(""), None); + } + + #[test] + fn sizes_render_in_megabytes() { + assert_eq!(fmt_size(109_567_821), "104.5 MB"); + assert_eq!(fmt_size(0), "", "an unknown size shows nothing rather than 0 MB"); + } + + #[test] + fn repo_override_is_validated() { + // Default when unset (the common case; env is process-wide so only the + // parse rule is asserted here). + assert_eq!(DEFAULT_REPO.split_once('/'), Some(("JamesKane", "decodingus-navigator"))); + assert!(releases_url().ends_with("/releases")); + } +} diff --git a/rust/crates/du-web/src/routes/mod.rs b/rust/crates/du-web/src/routes/mod.rs index 6c4978bf..49c03b6b 100644 --- a/rust/crates/du-web/src/routes/mod.rs +++ b/rust/crates/du-web/src/routes/mod.rs @@ -26,6 +26,7 @@ pub mod curator_inbox; pub mod curator_regions; pub mod curator_variants; pub mod dedup; +pub mod download; pub mod maps; pub mod naming; pub mod denovo_conflicts; @@ -68,6 +69,7 @@ pub fn app(state: AppState) -> Router { .merge(coverage::router()) .merge(str_markers::router()) .merge(pages::router()) + .merge(download::router()) .merge(auth_routes::router()) .merge(curator::router()) .merge(curator_inbox::router()) diff --git a/rust/crates/du-web/src/routes/pages.rs b/rust/crates/du-web/src/routes/pages.rs index d082a599..d7b5b07c 100644 --- a/rust/crates/du-web/src/routes/pages.rs +++ b/rust/crates/du-web/src/routes/pages.rs @@ -66,7 +66,7 @@ fn base_url() -> String { /// The public, indexable pages (curator/auth surfaces are intentionally omitted). const PUBLIC_PATHS: &[&str] = &["/", "/ytree", "/mtree", "/variants", "/references", "/coverage-benchmarks", "/about", "/contact", - "/reputation", "/terms", "/privacy", "/cookies", "/faq"]; + "/download", "/reputation", "/terms", "/privacy", "/cookies", "/faq"]; /// Max sample URLs in one sitemap file. The spec ceiling is 50,000; if the public /// catalog ever exceeds this we log and this becomes a sitemap-index split. diff --git a/rust/crates/du-web/templates/base.html b/rust/crates/du-web/templates/base.html index 7c64fb69..bc120e7f 100644 --- a/rust/crates/du-web/templates/base.html +++ b/rust/crates/du-web/templates/base.html @@ -37,6 +37,8 @@
  • {{ t.get("nav.strMarkers") }}
  • {# Full page load (not boosted) so Leaflet's scripts/styles initialize. #}
  • {{ t.get("nav.map") }}
  • +
  • +
  • {{ t.get("nav.download") }}
  • diff --git a/rust/crates/du-web/templates/index.html b/rust/crates/du-web/templates/index.html index 3129e106..9d8208a4 100644 --- a/rust/crates/du-web/templates/index.html +++ b/rust/crates/du-web/templates/index.html @@ -91,7 +91,8 @@

    {{ t.get("home.participate.collab.title") }}

    - diff --git a/rust/crates/du-web/templates/static/download.html b/rust/crates/du-web/templates/static/download.html new file mode 100644 index 00000000..b78d69d9 --- /dev/null +++ b/rust/crates/du-web/templates/static/download.html @@ -0,0 +1,72 @@ +{% extends "base.html" %} +{% block title %}{{ t.get("dl.title") }} — {{ t.get("app.name") }}{% endblock %} +{% block content %} +
    +

    {{ t.get("dl.title") }}

    +

    {{ t.get("dl.intro") }}

    + + {# Version line. Absent when GitHub couldn't be reached — the cards then carry + the releases-listing fallback instead of direct installer links. #} + {% if let Some(v) = d.version %} +

    + {{ v }} + {% if let Some(p) = d.published %} · {{ p }}{% endif %} + {% if d.prerelease %}{{ t.get("dl.prerelease") }}{% endif %} +

    + {% else %} +
    + {{ t.get("dl.unavailable") }} + {{ t.get("dl.all_releases") }} +
    + {% endif %} + +
    + {% for p in d.platforms %} +
    +
    +
    +
    + +

    {{ p.label }}

    + {% if p.detected %}{{ t.get("dl.your_system") }}{% endif %} +
    +

    {{ p.note }}

    + + {% for f in p.files %} + {% if loop.first %} + {# Direct asset link. `download` so the browser saves rather than + navigates, `nofollow` so crawlers don't pull ~100 MB installers. #} + + {{ t.get("dl.get") }} ({{ f.kind }}) + +
    {{ f.name }} · {{ f.size }}
    + {% else %} + {% if loop.index == 2 %}
    {{ t.get("dl.other_builds") }}
    {% endif %} +
    + {{ f.arch }} · {{ f.kind }} + · {{ f.size }} +
    + {% endif %} + {% else %} + {{ t.get("dl.all_releases") }} + {% endfor %} +
    +
    +
    + {% endfor %} +
    + +
    + {% if let Some(sums) = d.checksums_url %} +

    {{ t.get("dl.verify") }} + SHA256SUMS.

    + {% endif %} +

    {{ t.get("dl.permalink") }} + {% for p in d.platforms %}/download/{{ p.slug }}{% if !loop.last %} · {% endif %}{% endfor %}

    +

    {{ t.get("dl.source") }} + {{ t.get("dl.all_releases") }}.

    +
    +
    +{% endblock %} diff --git a/rust/crates/du-web/templates/static/page.html b/rust/crates/du-web/templates/static/page.html index 0e839ea9..51f087bc 100644 --- a/rust/crates/du-web/templates/static/page.html +++ b/rust/crates/du-web/templates/static/page.html @@ -72,7 +72,8 @@

    Can I upload my Big Y, WGS, or other DNA files?Analyze Locally: Process BAM/CRAM files directly on your machine to generate coverage metrics and haplogroup determinations without uploading massive files.
  • Integrate with Atmosphere: Future versions will allow you to publish anonymized summaries to your Personal Data Server, enabling you to share insights with the federated network while keeping your raw data private.
  • -

    The Navigator is built on the JVM (Java/Scala) for performance and cross-platform compatibility. It represents the core of our privacy-first philosophy: bring the analysis to the data, not the data to the analysis.

    +

    The Navigator is a native desktop application (built in Rust) for performance and cross-platform compatibility. It represents the core of our privacy-first philosophy: bring the analysis to the data, not the data to the analysis.

    +

    {{ t.get("nav.download") }}

    diff --git a/rust/locales/en.txt b/rust/locales/en.txt index efed1234..95d335d9 100644 --- a/rust/locales/en.txt +++ b/rust/locales/en.txt @@ -809,3 +809,23 @@ str.markers.legend.null=A reported value of 0 is a null allele (a deleted locus) str.markers.legend.default=Markers without a published mutation rate are scored in the branch-age model at the default rate of str.markers.legend.motif=Motifs and repeat lengths are shown only where a published source gives one; most extended markers have none. str.markers.refreshed=Figures last recomputed + +# Navigator download page (/download) +nav.download=Get the Navigator +dl.title=Download the Navigator +dl.intro=The Decoding-Us Navigator is the edge application that analyzes your BAM/CRAM files on your own machine — raw data never leaves your computer. Pick your platform below; the links always resolve to the current release. +dl.platform.windows=Windows +dl.platform.macos=macOS +dl.platform.linux=Linux +dl.note.windows=Windows 10 or later, 64-bit. Installer (.exe). +dl.note.macos=macOS 12 or later. Universal disk image — Apple Silicon and Intel. +dl.note.linux=AppImage runs on most distributions; .deb for Debian/Ubuntu. +dl.get=Download +dl.other_builds=Other builds: +dl.your_system=Your system +dl.prerelease=Alpha +dl.verify=Verify your download against +dl.permalink=Permanent per-platform links: +dl.source=Every build, including older versions and release notes: +dl.all_releases=all releases on GitHub +dl.unavailable=The release list couldn't be reached just now, so the direct links are unavailable. You can still download every build from GitHub: diff --git a/rust/locales/es.txt b/rust/locales/es.txt index d102b4eb..6266f0b9 100644 --- a/rust/locales/es.txt +++ b/rust/locales/es.txt @@ -803,3 +803,23 @@ str.markers.legend.null=Un valor de 0 es un alelo nulo (un locus eliminado), no str.markers.legend.default=Los marcadores sin tasa de mutación publicada se puntúan en el modelo de edad de rama con la tasa por defecto de str.markers.legend.motif=Los motivos y las longitudes de repetición solo se muestran cuando una fuente publicada los proporciona; la mayoría de los marcadores extendidos carecen de ellos. str.markers.refreshed=Cifras recalculadas por última vez el + +# Navigator download page (/download) +nav.download=Obtener el Navigator +dl.title=Descargar el Navigator +dl.intro=El Decoding-Us Navigator es la aplicación de borde que analiza sus archivos BAM/CRAM en su propio equipo: los datos brutos nunca salen de su ordenador. Elija su plataforma abajo; los enlaces siempre apuntan a la versión actual. +dl.platform.windows=Windows +dl.platform.macos=macOS +dl.platform.linux=Linux +dl.note.windows=Windows 10 o posterior, 64 bits. Instalador (.exe). +dl.note.macos=macOS 12 o posterior. Imagen de disco universal: Apple Silicon e Intel. +dl.note.linux=AppImage funciona en la mayoría de distribuciones; .deb para Debian/Ubuntu. +dl.get=Descargar +dl.other_builds=Otras compilaciones: +dl.your_system=Su sistema +dl.prerelease=Alfa +dl.verify=Verifique su descarga con +dl.permalink=Enlaces permanentes por plataforma: +dl.source=Todas las compilaciones, incluidas las versiones anteriores y las notas: +dl.all_releases=todas las versiones en GitHub +dl.unavailable=No se ha podido consultar la lista de versiones, así que los enlaces directos no están disponibles. Aún puede descargar todas las compilaciones desde GitHub: diff --git a/rust/locales/fr.txt b/rust/locales/fr.txt index 4e863811..76a7f92f 100644 --- a/rust/locales/fr.txt +++ b/rust/locales/fr.txt @@ -803,3 +803,23 @@ str.markers.legend.null=Une valeur de 0 correspond à un allèle nul (locus supp str.markers.legend.default=Les marqueurs sans taux de mutation publié sont évalués dans le modèle d'âge de branche au taux par défaut de str.markers.legend.motif=Les motifs et longueurs de répétition ne sont affichés que lorsqu'une source publiée les fournit ; la plupart des marqueurs étendus n'en ont pas. str.markers.refreshed=Chiffres recalculés le + +# Navigator download page (/download) +nav.download=Obtenir le Navigator +dl.title=Télécharger le Navigator +dl.intro=Le Decoding-Us Navigator est l'application de périphérie qui analyse vos fichiers BAM/CRAM sur votre propre machine — les données brutes ne quittent jamais votre ordinateur. Choisissez votre plateforme ci-dessous ; les liens pointent toujours vers la version actuelle. +dl.platform.windows=Windows +dl.platform.macos=macOS +dl.platform.linux=Linux +dl.note.windows=Windows 10 ou ultérieur, 64 bits. Programme d'installation (.exe). +dl.note.macos=macOS 12 ou ultérieur. Image disque universelle — Apple Silicon et Intel. +dl.note.linux=L'AppImage fonctionne sur la plupart des distributions ; .deb pour Debian/Ubuntu. +dl.get=Télécharger +dl.other_builds=Autres versions : +dl.your_system=Votre système +dl.prerelease=Alpha +dl.verify=Vérifiez votre téléchargement avec +dl.permalink=Liens permanents par plateforme : +dl.source=Toutes les versions, y compris les anciennes et les notes de version : +dl.all_releases=toutes les versions sur GitHub +dl.unavailable=La liste des versions n'a pas pu être consultée, les liens directs sont donc indisponibles. Vous pouvez toujours télécharger toutes les versions depuis GitHub :