app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit a75edd7a59b5eb66d261d7c53115b9c434cd31c6
parent 8b477a4f8913e516081d331af4779f107c9ab666
Author: triesap <triesap@radroots.dev>
Date:   Mon, 19 Jan 2026 07:37:26 +0000

app-lib: add browser platform helper

- add browser platform info struct
- add user agent parser helper
- export browser platform helper
- add unit test for user agent parsing

Diffstat:
MCargo.lock | 5+++++
Mcrates/app-lib/Cargo.toml | 3+++
Acrates/app-lib/src/browser.rs | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/app-lib/src/lib.rs | 4++++
4 files changed, 149 insertions(+), 0 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1574,6 +1574,11 @@ dependencies = [ [[package]] name = "radroots-app-lib" version = "0.1.0" +dependencies = [ + "once_cell", + "regex", + "web-sys", +] [[package]] name = "radroots-app-utils" diff --git a/crates/app-lib/Cargo.toml b/crates/app-lib/Cargo.toml @@ -10,3 +10,6 @@ rust-version.workspace = true crate-type = ["rlib"] [dependencies] +once_cell = { workspace = true } +regex = { workspace = true } +web-sys = { workspace = true } diff --git a/crates/app-lib/src/browser.rs b/crates/app-lib/src/browser.rs @@ -0,0 +1,137 @@ +#![forbid(unsafe_code)] + +#[cfg(any(test, target_arch = "wasm32"))] +use once_cell::sync::Lazy; +#[cfg(any(test, target_arch = "wasm32"))] +use regex::Regex; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BrowserPlatformInfo { + pub os: String, + pub browser: String, + pub version: String, +} + +#[cfg(any(test, target_arch = "wasm32"))] +static REMOVE_EXCESS_MOZILLA_AND_VERSION: Lazy<Regex> = + Lazy::new(|| Regex::new(r"^mozilla/\d\.\d\W").expect("regex")); +#[cfg(any(test, target_arch = "wasm32"))] +static BROWSER_PATTERN: Lazy<Regex> = Lazy::new(|| { + Regex::new(r"(\w+)/(\d+\.\d+(?:\.\d+)?(?:\.\d+)?)").expect("regex") +}); +#[cfg(any(test, target_arch = "wasm32"))] +static ENGINE_AND_VERSION_PATTERN: Lazy<Regex> = + Lazy::new(|| Regex::new(r"^(ver|cri|gec)").expect("regex")); +#[cfg(any(test, target_arch = "wasm32"))] +static VERSION_PATTERN: Lazy<Regex> = + Lazy::new(|| Regex::new(r"version/(\d+(\.\d+)*)").expect("regex")); +#[cfg(any(test, target_arch = "wasm32"))] +static MOBILE_OS_IPHONE: Lazy<Regex> = Lazy::new(|| Regex::new("iphone").expect("regex")); +#[cfg(any(test, target_arch = "wasm32"))] +static MOBILE_OS_IPAD: Lazy<Regex> = Lazy::new(|| Regex::new("ipad|macintosh").expect("regex")); +#[cfg(any(test, target_arch = "wasm32"))] +static MOBILE_OS_ANDROID: Lazy<Regex> = Lazy::new(|| Regex::new("android").expect("regex")); +#[cfg(any(test, target_arch = "wasm32"))] +static DESKTOP_OS_WINDOWS: Lazy<Regex> = Lazy::new(|| Regex::new("win").expect("regex")); +#[cfg(any(test, target_arch = "wasm32"))] +static DESKTOP_OS_MAC: Lazy<Regex> = Lazy::new(|| Regex::new("macintosh").expect("regex")); +#[cfg(any(test, target_arch = "wasm32"))] +static DESKTOP_OS_LINUX: Lazy<Regex> = Lazy::new(|| Regex::new("linux").expect("regex")); + +pub fn browser_platform() -> Option<BrowserPlatformInfo> { + #[cfg(target_arch = "wasm32")] + { + let window = web_sys::window()?; + let navigator = window.navigator(); + let ua = navigator.user_agent().ok()?; + let max_touch_points = navigator.max_touch_points(); + return Some(parse_user_agent_string(&ua, max_touch_points)); + } + #[cfg(not(target_arch = "wasm32"))] + { + None + } +} + +#[cfg(any(test, target_arch = "wasm32"))] +fn parse_user_agent_string(ua_string: &str, max_touch_points: i32) -> BrowserPlatformInfo { + let ua = REMOVE_EXCESS_MOZILLA_AND_VERSION + .replace(&ua_string.to_lowercase(), "") + .to_string(); + + let mobile_os = if MOBILE_OS_IPHONE.is_match(&ua) && max_touch_points >= 1 { + Some("iphone") + } else if MOBILE_OS_IPAD.is_match(&ua) && max_touch_points >= 1 { + Some("ipad") + } else if MOBILE_OS_ANDROID.is_match(&ua) && max_touch_points >= 1 { + Some("android") + } else { + None + }; + let desktop_os = if DESKTOP_OS_WINDOWS.is_match(&ua) { + Some("windows") + } else if DESKTOP_OS_MAC.is_match(&ua) { + Some("mac") + } else if DESKTOP_OS_LINUX.is_match(&ua) { + Some("linux") + } else { + None + }; + let os = mobile_os.or(desktop_os).unwrap_or(""); + + let browser_matches = BROWSER_PATTERN + .find_iter(&ua) + .map(|capture| capture.as_str().to_string()) + .collect::<Vec<_>>(); + let safari_version = VERSION_PATTERN + .captures(&ua) + .and_then(|caps| caps.get(1).map(|match_value| match_value.as_str().to_string())); + let browser_offset = if browser_matches.len() > 2 { + browser_matches + .get(1) + .map(|match_value| !ENGINE_AND_VERSION_PATTERN.is_match(match_value)) + .unwrap_or(false) + } else { + false + }; + let browser_index = browser_matches + .len() + .saturating_sub(1 + if browser_offset { 1 } else { 0 }); + let (browser, version) = browser_matches + .get(browser_index) + .and_then(|match_value| { + let mut parts = match_value.split('/'); + let browser = parts.next().unwrap_or("").to_string(); + let version = parts.next().unwrap_or("").to_string(); + Some((browser, version)) + }) + .unwrap_or_else(|| (String::new(), String::new())); + let version = safari_version.unwrap_or(version); + + BrowserPlatformInfo { + os: os.to_string(), + browser, + version, + } +} + +#[cfg(test)] +mod tests { + use super::{parse_user_agent_string, BrowserPlatformInfo}; + + #[test] + fn parse_user_agent_detects_browser() { + let info = parse_user_agent_string( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + 0, + ); + assert_eq!( + info, + BrowserPlatformInfo { + os: "mac".to_string(), + browser: "chrome".to_string(), + version: "120.0.0.0".to_string() + } + ); + } +} diff --git a/crates/app-lib/src/lib.rs b/crates/app-lib/src/lib.rs @@ -1 +1,5 @@ #![forbid(unsafe_code)] + +pub mod browser; + +pub use browser::{browser_platform, BrowserPlatformInfo};