From 268d5458430712cd4c255395bf97a1e3ec0a69a8 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Mon, 5 Oct 2026 12:48:31 +0200 Subject: [PATCH] A releases manifest renders as a download card, the device's file first A kind: url resource whose JSON is a releases manifest - latest, versions newest first with per-platform assets, channels such as a package repo or TestFlight - is one card: the newest version, the file for the visitor's own platform (read from the browser on hydration; the server renders every file), the Linux package repo lines beside a Linux file, TestFlight for iPhones and Macs, and the rest folded under "All downloads". Decided by shape, as every resource is; written for redoal's public releases at dl.redoal.com. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 4 + Cargo.toml | 1 + docs/content-reference.md | 12 +++ src/app.rs | 162 ++++++++++++++++++++++++++++++++++++++ src/i18n.rs | 9 +++ style/main.css | 6 ++ 6 files changed, 194 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06d1b99..8310b70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ One line per change, grouped by the release that shipped it. Newest first. A release is a Cargo.toml bump plus a `v` tag; the content repos pin the tag they run (see the end of this file). +## Unreleased + +- A `kind: url` resource whose JSON is a releases manifest (`latest`, `versions` with per-platform `assets`, `channels` such as a package repo or TestFlight) renders as a download card: the file for the visitor's own platform first, decided in the browser, the rest and the channels folded. Written for redoal's public releases at dl.redoal.com (docs: content-reference, "A releases manifest"). + ## 0.6.18 (2026-10-03) - `iris check --findings ` writes every finding as JSON: a stable `id` (scope, need, code and subject, nothing of the wording), `level`, `code`, `scope`, `need`, `subject`, `message` and `fix`. Every message that knows its fix now says it after "fix:". The codes are listed in docs/lint.md. diff --git a/Cargo.toml b/Cargo.toml index 30a2d45..48bbde3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,7 @@ web-sys = { version = "0.3", features = [ "FileList", "FormData", "HtmlInputElement", + "Navigator", "Request", "RequestInit", "Response", diff --git a/docs/content-reference.md b/docs/content-reference.md index e657d13..2bc89b3 100644 --- a/docs/content-reference.md +++ b/docs/content-reference.md @@ -302,6 +302,18 @@ stays about the work in hand however long the site has run; nothing is hidden from a read-only list, and `leaves_after` still takes a record out of every list altogether. +A **releases manifest** - a `kind: url` resource whose JSON is +`{ name, latest, versions: [{ version, date, notes, assets: [{ platform, +url, sha256, size }] }], channels: { arch: { repo, package, note }, +ios: { testflight, note } } }`, newest version first, as a release +pipeline writes it - renders as one card: the newest version, the file +for the visitor's own platform first (read from the browser: `ios`, +`macos`, `linux-x86_64`, `linux-aarch64`, `windows`, `android`), the +other files and the channels folded under "All downloads". Linux gets +the package repo lines beside its file; iPhones and Macs get +TestFlight. Platforms a manifest lacks fall through to the folded +list. + `states` and `where` narrow a listing. They matter most for a register that is public by law — public about what was decided, not about what was withdrawn — while the desk working the same bucket is a different diff --git a/src/app.rs b/src/app.rs index 813b44e..5f34180 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2124,9 +2124,147 @@ fn ResourceValue( } } + // A releases manifest (content-reference, "A releases manifest"): + // the newest version, its files per platform, and the standing + // channels, with the visitor's own platform picked in the browser. + if is_releases_manifest(&value) { + return view! { }.into_any(); + } + view! {
{value.to_string()}
}.into_any() } +/// `{ name, latest, versions: [{ version, date, notes, assets: [{ +/// platform, url, sha256, size }] }], channels: {...} }` - the shape a +/// release pipeline writes (redoal's does) for a site to offer downloads. +pub fn is_releases_manifest(value: &serde_json::Value) -> bool { + value.get("latest").and_then(|v| v.as_str()).is_some() + && value.get("versions").and_then(|v| v.as_array()).is_some_and(|v| !v.is_empty()) +} + +/// The platform a browser says it is, in the manifest's words: +/// `ios`, `macos`, `android`, `windows`, `linux-x86_64`, +/// `linux-aarch64`, or `other`. Read, never trusted for anything but +/// which download to put first. +pub fn platform_of(user_agent: &str) -> &'static str { + let ua = user_agent.to_ascii_lowercase(); + if ua.contains("iphone") || ua.contains("ipad") { + "ios" + } else if ua.contains("mac os x") || ua.contains("macintosh") { + "macos" + } else if ua.contains("android") { + "android" + } else if ua.contains("windows") { + "windows" + } else if ua.contains("linux") || ua.contains("x11") { + if ua.contains("aarch64") || ua.contains("arm64") { + "linux-aarch64" + } else { + "linux-x86_64" + } + } else { + "other" + } +} + +/// The newest release as a card: the file for this device first (the +/// platform is read in the browser, so the server renders every file +/// and the pick lands on hydration), the rest after, and the channels +/// that are not files - a package repo, TestFlight. +#[component] +fn ReleasesCard(manifest: serde_json::Value) -> impl IntoView { + let name = manifest.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let latest = manifest.get("latest").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let newest = manifest["versions"][0].clone(); + let date = newest.get("date").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let notes = newest.get("notes").and_then(|v| v.as_str()).filter(|u| u.starts_with("https://")).map(str::to_string); + let assets: Vec<(String, String, String)> = newest + .get("assets") + .and_then(|a| a.as_array()) + .map(|a| { + a.iter() + .filter_map(|x| { + let platform = x.get("platform")?.as_str()?.to_string(); + let url = x.get("url")?.as_str()?.to_string(); + let sha = x.get("sha256").and_then(|s| s.as_str()).unwrap_or("").to_string(); + Some((platform, url, sha)) + }) + .collect() + }) + .unwrap_or_default(); + let channels = manifest.get("channels").cloned().unwrap_or(serde_json::Value::Null); + let arch = channels.get("arch").cloned(); + let ios = channels.get("ios").cloned(); + + let platform = RwSignal::new(None::<&'static str>); + Effect::new(move |_| { + #[cfg(feature = "hydrate")] + if let Some(ua) = web_sys::window().and_then(|w| w.navigator().user_agent().ok()) { + platform.set(Some(platform_of(&ua))); + } + }); + let label = |p: &str| match p { + "linux-x86_64" => "Linux (x86_64)", + "linux-aarch64" => "Linux (aarch64)", + "macos" | "macos-arm64" => "macOS", + "windows" | "windows-x86_64" => "Windows", + other => other, + }.to_string(); + let assets_for_list = assets.clone(); + let arch_for_pick = arch.clone(); + let ios_for_pick = ios.clone(); + let assets_for_pick = assets.clone(); + view! { +
+
+ {name.clone()} " " {latest.clone()} + {(!date.is_empty()).then(|| view! { " · " {date.clone()} })} + {notes.clone().map(|u| view! { " · " {move || t(&lang().get(), "release_notes")} })} +
+ // What this device should take, decided in the browser. + {move || { + let p = platform.get()?; + let arch = arch_for_pick.clone(); + let ios = ios_for_pick.clone(); + let own = assets_for_pick.iter().find(|(pl, _, _)| pl == p).cloned(); + let pick: Option = match (p, own) { + (_, Some((pl, url, _))) => Some(view! { +

{t(&lang().get(), "for_this_device")} ": " {label(&pl)}

+ {(p.starts_with("linux") && arch.is_some()).then(|| { + let a = arch.clone().unwrap(); + let repo = a.get("repo").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let pkg = a.get("package").and_then(|v| v.as_str()).unwrap_or("").to_string(); + view! {
{repo} "\n\npacman -Syu " {pkg}
} + })} + }.into_any()), + ("ios" | "macos", None) => ios.as_ref().and_then(|i| i.get("testflight").and_then(|v| v.as_str()).map(str::to_string)).map(|tf| { + let note = ios.as_ref().and_then(|i| i.get("note").and_then(|v| v.as_str())).unwrap_or("").to_string(); + view! {

{t(&lang().get(), "for_this_device")} ": TestFlight" " " {note}

}.into_any() + }), + _ => None, + }; + pick + }} +
+ {move || t(&lang().get(), "all_downloads")} +
    + {assets_for_list.iter().map(|(pl, url, sha)| view! { +
  • {label(pl)} {(!sha.is_empty()).then(|| view! { " sha256 " {sha[..sha.len().min(12)].to_string()} "…" })}
  • + }).collect::>()} + {arch.as_ref().map(|a| { + let note = a.get("note").and_then(|v| v.as_str()).unwrap_or("").to_string(); + view! {
  • {note}
  • } + })} + {ios.as_ref().and_then(|i| i.get("testflight").and_then(|v| v.as_str()).map(|tf| { + let note = i.get("note").and_then(|v| v.as_str()).unwrap_or("").to_string(); + view! {
  • "TestFlight" " " {note}
  • } + }))} +
+
+
+ } +} + /// Pulls the first present, non-null string field out of a JSON object /// matching one of `keys`, tried in order - shared between `ItemCard` /// and `SelectField`'s option rendering, since both need "guess a @@ -3365,3 +3503,27 @@ mod picture_tests { assert_eq!(picture_of(&answer, None), None); } } + +#[cfg(test)] +mod releases_tests { + use super::*; + + #[test] + fn a_browser_names_its_platform_in_the_manifests_words() { + assert_eq!(platform_of("Mozilla/5.0 (iPhone; CPU iPhone OS 19_0 like Mac OS X)"), "ios"); + assert_eq!(platform_of("Mozilla/5.0 (Macintosh; Intel Mac OS X 15_0)"), "macos"); + assert_eq!(platform_of("Mozilla/5.0 (X11; Linux x86_64) Firefox"), "linux-x86_64"); + assert_eq!(platform_of("Mozilla/5.0 (X11; Linux aarch64) Firefox"), "linux-aarch64"); + assert_eq!(platform_of("Mozilla/5.0 (Windows NT 10.0; Win64; x64)"), "windows"); + assert_eq!(platform_of("Mozilla/5.0 (Linux; Android 15)"), "android"); + assert_eq!(platform_of("curl/8"), "other"); + } + + #[test] + fn only_a_manifest_shape_is_a_manifest() { + let m = serde_json::json!({"name":"redoal","latest":"0.1.17","versions":[{"version":"0.1.17","assets":[]}]}); + assert!(is_releases_manifest(&m)); + assert!(!is_releases_manifest(&serde_json::json!({"latest":"1","versions":[]}))); + assert!(!is_releases_manifest(&serde_json::json!([{"name":"x"}]))); + } +} diff --git a/src/i18n.rs b/src/i18n.rs index 1ccecba..0f5832d 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -25,6 +25,9 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> { ("en", "sign_out") => "Sign out", ("en", "nothing_here_yet") => "Nothing here yet.", ("en", "show_older") => "Show older", + ("en", "for_this_device") => "For this device", + ("en", "all_downloads") => "All downloads", + ("en", "release_notes") => "release notes", ("en", "upload_failed") => "The file did not arrive, so nothing was sent. Try again.", ("en", "upload_too_large") => "The file is larger than 20 MB, so nothing was sent. Choose a smaller one, or a picture taken at a lower resolution.", ("en", "upload_unavailable") => "This site cannot take files at the moment, so nothing was sent. Let whoever runs the site know.", @@ -62,6 +65,9 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> { ("no", "sign_out") => "Logg ut", ("no", "nothing_here_yet") => "Ingenting her ennå.", ("no", "show_older") => "Vis eldre", + ("no", "for_this_device") => "Til denne enheten", + ("no", "all_downloads") => "Alle nedlastinger", + ("no", "release_notes") => "utgivelsesnotater", ("no", "upload_failed") => "Filen kom ikke fram, så ingenting ble sendt. Prøv igjen.", ("no", "upload_too_large") => "Filen er større enn 20 MB, så ingenting ble sendt. Velg en mindre fil, eller et bilde med lavere oppløsning.", ("no", "upload_unavailable") => "Siden kan ikke ta imot filer akkurat nå, så ingenting ble sendt. Si fra til den som har siden.", @@ -117,6 +123,9 @@ mod tests { "sign_out", "nothing_here_yet", "show_older", + "for_this_device", + "all_downloads", + "release_notes", "upload_failed", "upload_too_large", "upload_unavailable", diff --git a/style/main.css b/style/main.css index b392956..f95a711 100644 --- a/style/main.css +++ b/style/main.css @@ -1294,6 +1294,12 @@ textarea:focus { cursor: pointer; } +/* A releases manifest: the pick for this device, the rest folded. */ +.releases .pill { display: inline-block; } +.releases pre { white-space: pre-wrap; font-size: 0.85rem; margin: 0.4rem 0 0; } +.releases-all summary { color: var(--ink-dim); font-size: 0.9rem; cursor: pointer; } +.releases-all ul { margin: 0.4rem 0 0 1rem; font-size: 0.9rem; } + .show-older:hover { color: inherit; border-color: var(--ink-dim);