diff --git a/src/main.rs b/src/main.rs index e535a64..e68a879 100644 --- a/src/main.rs +++ b/src/main.rs @@ -99,6 +99,57 @@ fn open_url(url: &str) { let _ = webbrowser::open(url); } +// ------------------------------------------------------------- update check + +/// (major, minor, patch, (pre-release kind, number)); a release is kind 3, above alpha/beta/rc. +type Ver = (u32, u32, u32, (u8, u32)); + +fn ver(v: &str) -> Option { + let (core, pre) = match v.split_once('-') { + Some((c, p)) => (c, Some(p)), + None => (v, None), + }; + let mut n = core.split('.'); + let mut num = || n.next()?.parse().ok(); + let nums = (num()?, num()?, num()?); + if n.next().is_some() { + return None; // 1.2.3.4 isn't a version we ship + } + let (name, n) = match pre { + Some(p) => p.split_once('.').unwrap_or((p, "0")), + None => return Some((nums.0, nums.1, nums.2, (3, 0))), + }; + let kind = match name { + "alpha" => 0, + "beta" => 1, + "rc" => 2, + _ => return None, + }; + Some((nums.0, nums.1, nums.2, (kind, n.parse().ok()?))) +} + +/// True only when `latest` really is ahead: garbage on either side means no update. +fn is_newer(latest: &str, current: &str) -> bool { + match (ver(latest), ver(current)) { + (Some(l), Some(c)) => l > c, + _ => false, + } +} + +/// The tag out of curl -I's `location:` header, without the leading "v". +fn latest_tag(headers: &str) -> Option<&str> { + let url = headers + .lines() + .rev() + .find(|l| l.trim_start().get(..9).is_some_and(|p| p.eq_ignore_ascii_case("location:")))? + .split_once(':')? + .1 + .trim(); + let tag = url.split("/tag/").nth(1)?.split('/').next()?; + let tag = tag.strip_prefix('v').unwrap_or(tag); + ver(tag).is_some().then_some(tag) +} + fn bug_report_url(st: &State) -> String { fn line(d: &Dev, name: impl Fn(&T) -> String) -> String { match d { @@ -763,6 +814,11 @@ impl App { if ui.add(egui::Button::new("🐞 Report a bug").min_size(vec2(ui.available_width(), 34.0))).clicked() { open_url(&bug_report_url(st)); } + if let Some(latest) = &st.update + && ui.small_button(format!("⬆ Rig Hub {latest} is out")).on_hover_text("Opens the release page").clicked() + { + open_url(&format!("{REPO}/releases/latest")); + } if let Some(h) = st.headset.ok() { let text = match h.battery { audio::Battery::Level(n) => format!("🎧 {n}%"), @@ -1413,6 +1469,48 @@ fn main() -> eframe::Result { } } +#[cfg(test)] +mod update_tests { + use super::{is_newer, latest_tag}; + + #[test] + fn newer_same_older() { + assert!(is_newer("0.1.1", "0.1.0")); + assert!(!is_newer("0.1.0", "0.1.0")); // same: no update + assert!(!is_newer("0.1.0", "0.1.1")); // older + assert!(is_newer("0.2.0", "0.1.9")); + assert!(is_newer("1.0.0", "0.9.9")); + } + + #[test] + fn pre_releases_compare_numerically_and_below_their_release() { + assert!(is_newer("0.1.0-alpha.10", "0.1.0-alpha.3")); // numeric, not "10" < "3" + assert!(!is_newer("0.1.0-alpha.3", "0.1.0-alpha.10")); + assert!(is_newer("0.1.0-beta.1", "0.1.0-alpha.10")); + assert!(is_newer("0.1.0", "0.1.0-beta.1")); + assert!(is_newer("0.1.0", "0.1.0-alpha.3")); + assert!(is_newer("0.1.1-alpha.1", "0.1.0")); // newer core beats older pre-release + } + + #[test] + fn garbage_means_no_update() { + assert!(!is_newer("", "0.1.0")); + assert!(!is_newer("banana", "0.1.0")); + assert!(!is_newer("0.1", "0.1.0")); + assert!(!is_newer("0.1.0-omega.5", "0.1.0-alpha.3")); // unknown pre-release kind + assert!(!is_newer("0.1.0", "banana")); // garbage current version: no update either + } + + #[test] + fn parses_tag_from_curl_headers() { + let ok = "HTTP/2 302\r\ncontent-length: 0\r\nlocation: https://github.com/SusieeTheLinuxUser/rig-hub/releases/tag/v0.1.0-alpha.4\r\n\r\n"; + assert_eq!(latest_tag(ok), Some("0.1.0-alpha.4")); + assert_eq!(latest_tag(""), None); // empty output (offline, curl missing) + assert_eq!(latest_tag("HTTP/2 404\r\ncontent-length: 0\r\n\r\n"), None); + assert_eq!(latest_tag("location: https://github.com/x/y/releases/tag/banana\r\n"), None); + } +} + #[cfg(all(test, unix))] mod tests { #[test] diff --git a/src/workers.rs b/src/workers.rs index 0a9782a..2e64148 100644 --- a/src/workers.rs +++ b/src/workers.rs @@ -112,6 +112,8 @@ pub struct State { pub mic: Dev, pub busy: Option<&'static str>, pub toast: Option, + /// newest release tag, when GitHub has one this build doesn't (checked once at startup) + pub update: Option, } pub type Shared = Arc>; @@ -199,12 +201,41 @@ pub fn spawn(st: Shared, ctx: Context) -> Workers { std::thread::Builder::new().name(name.into()).spawn(move || f(st, ctx, rx)).unwrap(); tx } + let cooler = go("cooler", &st, &ctx, cooler); + #[cfg(target_os = "linux")] + let mouse = go("mouse", &st, &ctx, mouse); + let headset = go("headset", &st, &ctx, headset); + let mic = go("mic", &st, &ctx, mic); + // one shot at startup; silent on any failure (curl missing, offline, odd output) + { + let (st, ctx) = (st.clone(), ctx.clone()); + let _ = std::thread::Builder::new().name("update".into()).spawn(move || check_update(st, ctx)); + } Workers { - cooler: go("cooler", &st, &ctx, cooler), + cooler, #[cfg(target_os = "linux")] - mouse: go("mouse", &st, &ctx, mouse), - headset: go("headset", &st, &ctx, headset), - mic: go("mic", &st, &ctx, mic), + mouse, + headset, + mic, + } +} + +/// Ask GitHub where /releases/latest redirects and compare that tag with this build. No HTTP +/// client here: curl ships with Linux and Windows 10+. Not the API — it allows 60 req/hour. +fn check_update(st: Shared, ctx: Context) { + let mut curl = std::process::Command::new("curl"); + curl.args(["-sSI", "--max-time", "10"]).arg(format!("{}/releases/latest", crate::REPO)); + // Rig Hub has no console on Windows, so a console child would flash a black window. + #[cfg(windows)] + std::os::windows::process::CommandExt::creation_flags(&mut curl, 0x0800_0000); // CREATE_NO_WINDOW + let Ok(out) = curl.output() else { + return; + }; + let headers = String::from_utf8_lossy(&out.stdout); + let Some(tag) = crate::latest_tag(&headers) else { return }; + if crate::is_newer(tag, env!("CARGO_PKG_VERSION")) { + let tag = tag.to_string(); + update(&st, &ctx, |s| s.update = Some(tag)); } }