diff --git a/rust/README.md b/rust/README.md index c40d5de..9e74543 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 0000000..0883376 --- /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 2f1f61d..17ba43a 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 0000000..dcc152c --- /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 6c4978b..49c03b6 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 d082a59..d7b5b07 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 7c64fb6..bc120e7 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 3129e10..9d8208a 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 0000000..b78d69d --- /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 0e839ea..51f087b 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 efed123..95d335d 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 d102b4e..6266f0b 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 4e86381..76a7f92 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 :