use directories::BaseDirs; use reqwest::Client; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; use crate::browser::GithubRelease; #[derive(Debug, Clone, PartialEq, Eq)] struct VersionComponent { major: u32, minor: u32, patch: u32, pre_release: Option, } #[derive(Debug, Clone, PartialEq, Eq)] struct PreRelease { kind: PreReleaseKind, number: Option, } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] enum PreReleaseKind { Alpha, Beta, RC, Dev, Pre, } impl VersionComponent { fn parse(version: &str) -> Self { let version = version.trim(); // Handle special case for Zen Browser twilight releases if version.to_lowercase().contains("twilight") { return VersionComponent { major: u32::MAX, minor: u32::MAX, patch: u32::MAX, pre_release: None, }; } // Split version into numeric and pre-release parts let (numeric_part, pre_release_part) = Self::split_version(version); // Parse numeric parts (major.minor.patch) let parts: Vec = numeric_part .split('.') .filter_map(|part| part.parse().ok()) .collect(); let major = parts.get(0).copied().unwrap_or(0); let minor = parts.get(1).copied().unwrap_or(0); let patch = parts.get(2).copied().unwrap_or(0); // Parse pre-release part let pre_release = pre_release_part .as_deref() .and_then(Self::parse_pre_release); VersionComponent { major, minor, patch, pre_release, } } fn split_version(version: &str) -> (String, Option) { let version = version.to_lowercase(); // Look for pre-release indicators for (i, ch) in version.char_indices() { if ch.is_alphabetic() && i > 0 { // Check if this is a pre-release indicator let remaining = &version[i..]; if remaining.starts_with('a') || remaining.starts_with('b') || remaining.starts_with("alpha") || remaining.starts_with("beta") || remaining.starts_with("rc") || remaining.starts_with("dev") || remaining.starts_with("pre") { return (version[..i].to_string(), Some(remaining.to_string())); } } } (version, None) } fn parse_pre_release(pre_release: &str) -> Option { let pre_release = pre_release.trim().to_lowercase(); if pre_release.is_empty() { return None; } // Extract kind and number let (kind, number) = if pre_release.starts_with("alpha") { ( PreReleaseKind::Alpha, Self::extract_number(&pre_release[5..]), ) } else if pre_release.starts_with("beta") { ( PreReleaseKind::Beta, Self::extract_number(&pre_release[4..]), ) } else if pre_release.starts_with("rc") { (PreReleaseKind::RC, Self::extract_number(&pre_release[2..])) } else if pre_release.starts_with("dev") { (PreReleaseKind::Dev, Self::extract_number(&pre_release[3..])) } else if pre_release.starts_with("pre") { (PreReleaseKind::Pre, Self::extract_number(&pre_release[3..])) } else if pre_release.starts_with('a') { ( PreReleaseKind::Alpha, Self::extract_number(&pre_release[1..]), ) } else if pre_release.starts_with('b') { ( PreReleaseKind::Beta, Self::extract_number(&pre_release[1..]), ) } else { return None; }; Some(PreRelease { kind, number }) } fn extract_number(s: &str) -> Option { let numeric_part: String = s.chars().filter(|c| c.is_ascii_digit()).collect(); numeric_part.parse().ok() } } impl PartialOrd for VersionComponent { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl Ord for VersionComponent { fn cmp(&self, other: &Self) -> std::cmp::Ordering { use std::cmp::Ordering; // Compare major.minor.patch first match (self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch)) { Ordering::Equal => { // If numeric parts are equal, compare pre-release match (&self.pre_release, &other.pre_release) { (None, None) => Ordering::Equal, (None, Some(_)) => Ordering::Greater, // Stable > pre-release (Some(_), None) => Ordering::Less, // Pre-release < stable (Some(a), Some(b)) => { // Compare pre-release kinds first match a.kind.cmp(&b.kind) { Ordering::Equal => { // Same kind, compare numbers match (&a.number, &b.number) { (None, None) => Ordering::Equal, (None, Some(_)) => Ordering::Less, (Some(_), None) => Ordering::Greater, (Some(a_num), Some(b_num)) => a_num.cmp(b_num), } } other => other, } } } } other => other, } } } // Helper function to sort versions properly pub fn sort_versions(versions: &mut [String]) { versions.sort_by(|a, b| { let version_a = VersionComponent::parse(a); let version_b = VersionComponent::parse(b); version_b.cmp(&version_a) // Descending order (newest first) }); } // Helper function to sort GitHub releases pub fn sort_github_releases(releases: &mut [GithubRelease]) { releases.sort_by(|a, b| { let version_a = VersionComponent::parse(&a.tag_name); let version_b = VersionComponent::parse(&b.tag_name); version_b.cmp(&version_a) // Descending order (newest first) }); } pub fn is_alpha_version(version: &str) -> bool { let version_comp = VersionComponent::parse(version); version_comp.pre_release.is_some() } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct FirefoxRelease { pub build_number: u32, pub category: String, pub date: String, pub description: Option, pub is_security_driven: bool, pub product: String, pub version: String, } #[derive(Debug, Serialize, Deserialize)] pub struct FirefoxApiResponse { pub releases: HashMap, } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct BrowserRelease { pub version: String, pub date: String, pub is_prerelease: bool, pub download_url: Option, } #[derive(Debug, Serialize, Deserialize)] struct CachedVersionData { versions: Vec, timestamp: u64, } #[derive(Debug, Serialize, Deserialize)] struct CachedGithubData { releases: Vec, timestamp: u64, } pub struct ApiClient { client: Client, } impl ApiClient { pub fn new() -> Self { Self { client: Client::new(), } } fn get_cache_dir() -> Result> { let base_dirs = BaseDirs::new().ok_or("Failed to get base directories")?; let app_name = if cfg!(debug_assertions) { "DonutBrowserDev" } else { "DonutBrowser" }; let cache_dir = base_dirs.cache_dir().join(app_name).join("version_cache"); fs::create_dir_all(&cache_dir)?; Ok(cache_dir) } fn get_current_timestamp() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs() } fn is_cache_valid(timestamp: u64) -> bool { let current_time = Self::get_current_timestamp(); let cache_duration = 10 * 60; // 10 minutes in seconds current_time - timestamp < cache_duration } pub fn load_cached_versions(&self, browser: &str) -> Option> { let cache_dir = Self::get_cache_dir().ok()?; let cache_file = cache_dir.join(format!("{}_versions.json", browser)); if !cache_file.exists() { return None; } let content = fs::read_to_string(&cache_file).ok()?; let cached_data: CachedVersionData = serde_json::from_str(&content).ok()?; // Always return cached versions regardless of age - they're always valid println!("Using cached versions for {}", browser); Some(cached_data.versions) } pub fn is_cache_expired(&self, browser: &str) -> bool { let cache_dir = match Self::get_cache_dir() { Ok(dir) => dir, Err(_) => return true, // If we can't get cache dir, consider expired }; let cache_file = cache_dir.join(format!("{}_versions.json", browser)); if !cache_file.exists() { return true; // No cache file means expired } let content = match fs::read_to_string(&cache_file) { Ok(content) => content, Err(_) => return true, // Can't read cache, consider expired }; let cached_data: CachedVersionData = match serde_json::from_str(&content) { Ok(data) => data, Err(_) => return true, // Can't parse cache, consider expired }; // Check if cache is older than 10 minutes !Self::is_cache_valid(cached_data.timestamp) } pub fn save_cached_versions( &self, browser: &str, versions: &[String], ) -> Result<(), Box> { let cache_dir = Self::get_cache_dir()?; let cache_file = cache_dir.join(format!("{}_versions.json", browser)); let cached_data = CachedVersionData { versions: versions.to_vec(), timestamp: Self::get_current_timestamp(), }; let content = serde_json::to_string_pretty(&cached_data)?; fs::write(&cache_file, content)?; println!("Cached {} versions for {}", versions.len(), browser); Ok(()) } fn load_cached_github_releases(&self, browser: &str) -> Option> { let cache_dir = Self::get_cache_dir().ok()?; let cache_file = cache_dir.join(format!("{}_github.json", browser)); if !cache_file.exists() { return None; } let content = fs::read_to_string(&cache_file).ok()?; let cached_data: CachedGithubData = serde_json::from_str(&content).ok()?; // Always use cached GitHub releases - cache never expires, only gets updated with new versions println!("Using cached GitHub releases for {}", browser); Some(cached_data.releases) } fn save_cached_github_releases( &self, browser: &str, releases: &[GithubRelease], ) -> Result<(), Box> { let cache_dir = Self::get_cache_dir()?; let cache_file = cache_dir.join(format!("{}_github.json", browser)); let cached_data = CachedGithubData { releases: releases.to_vec(), timestamp: Self::get_current_timestamp(), }; let content = serde_json::to_string_pretty(&cached_data)?; fs::write(&cache_file, content)?; println!("Cached {} GitHub releases for {}", releases.len(), browser); Ok(()) } pub async fn fetch_firefox_releases_with_caching( &self, no_caching: bool, ) -> Result, Box> { // Check cache first (unless bypassing) if !no_caching { if let Some(cached_versions) = self.load_cached_versions("firefox") { return Ok( cached_versions .into_iter() .map(|version| { BrowserRelease { version: version.clone(), date: "".to_string(), // Cache doesn't store dates is_prerelease: is_alpha_version(&version), download_url: Some(format!( "https://download.mozilla.org/?product=firefox-{}&os=osx&lang=en-US", version )), } }) .collect(), ); } } println!("Fetching Firefox releases from Mozilla API..."); let url = "https://product-details.mozilla.org/1.0/firefox.json"; let response = self .client .get(url) .header("User-Agent", "donutbrowser") .send() .await?; if !response.status().is_success() { return Err(format!("Failed to fetch Firefox versions: {}", response.status()).into()); } let firefox_response: FirefoxApiResponse = response.json().await?; // Extract releases and filter for stable versions let mut releases: Vec = firefox_response .releases .into_iter() .filter_map(|(key, release)| { // Only include releases that start with "firefox-" and have proper version format if key.starts_with("firefox-") && !release.version.is_empty() { let is_stable = matches!(release.category.as_str(), "major" | "stability"); Some(BrowserRelease { version: release.version.clone(), date: release.date, is_prerelease: !is_stable, download_url: Some(format!( "https://download.mozilla.org/?product=firefox-{}&os=osx&lang=en-US", release.version )), }) } else { None } }) .collect(); // Sort by version number in descending order (newest first) releases.sort_by(|a, b| { let version_a = VersionComponent::parse(&a.version); let version_b = VersionComponent::parse(&b.version); version_b.cmp(&version_a) }); // Extract versions for caching let versions: Vec = releases.iter().map(|r| r.version.clone()).collect(); // Cache the results (unless bypassing cache) if !no_caching { if let Err(e) = self.save_cached_versions("firefox", &versions) { eprintln!("Failed to cache Firefox versions: {}", e); } } Ok(releases) } pub async fn fetch_firefox_developer_releases_with_caching( &self, no_caching: bool, ) -> Result, Box> { // Check cache first (unless bypassing) if !no_caching { if let Some(cached_versions) = self.load_cached_versions("firefox-developer") { return Ok( cached_versions .into_iter() .map(|version| { BrowserRelease { version: version.clone(), date: "".to_string(), // Cache doesn't store dates is_prerelease: is_alpha_version(&version), download_url: Some(format!( "https://download.mozilla.org/?product=devedition-{}&os=osx&lang=en-US", version )), } }) .collect(), ); } } println!("Fetching Firefox Developer Edition releases from Mozilla API..."); let url = "https://product-details.mozilla.org/1.0/devedition.json"; let response = self .client .get(url) .header("User-Agent", "donutbrowser") .send() .await?; if !response.status().is_success() { return Err( format!( "Failed to fetch Firefox Developer Edition versions: {}", response.status() ) .into(), ); } let firefox_response: FirefoxApiResponse = response.json().await?; // Extract releases and filter for developer edition versions let mut releases: Vec = firefox_response .releases .into_iter() .filter_map(|(key, release)| { // Only include releases that start with "devedition-" and have proper version format if key.starts_with("devedition-") && !release.version.is_empty() { let is_stable = matches!(release.category.as_str(), "major" | "stability"); Some(BrowserRelease { version: release.version.clone(), date: release.date, is_prerelease: !is_stable, download_url: Some(format!( "https://download.mozilla.org/?product=devedition-{}&os=osx&lang=en-US", release.version )), }) } else { None } }) .collect(); // Sort by version number in descending order (newest first) releases.sort_by(|a, b| { let version_a = VersionComponent::parse(&a.version); let version_b = VersionComponent::parse(&b.version); version_b.cmp(&version_a) }); // Extract versions for caching let versions: Vec = releases.iter().map(|r| r.version.clone()).collect(); // Cache the results (unless bypassing cache) if !no_caching { if let Err(e) = self.save_cached_versions("firefox-developer", &versions) { eprintln!("Failed to cache Firefox Developer versions: {}", e); } } Ok(releases) } pub async fn fetch_mullvad_releases( &self, ) -> Result, Box> { self.fetch_mullvad_releases_with_caching(false).await } pub async fn fetch_mullvad_releases_with_caching( &self, no_caching: bool, ) -> Result, Box> { // Check cache first (unless bypassing) if !no_caching { if let Some(cached_releases) = self.load_cached_github_releases("mullvad") { return Ok(cached_releases); } } println!("Fetching Mullvad releases from GitHub API..."); let url = "https://api.github.com/repos/mullvad/mullvad-browser/releases"; let releases = self .client .get(url) .header("User-Agent", "donutbrowser") .send() .await? .json::>() .await?; let mut releases: Vec = releases .into_iter() .map(|mut release| { release.is_alpha = release.prerelease; release }) .collect(); // Sort releases using the new version sorting system sort_github_releases(&mut releases); // Cache the results (unless bypassing cache) if !no_caching { if let Err(e) = self.save_cached_github_releases("mullvad", &releases) { eprintln!("Failed to cache Mullvad releases: {}", e); } } Ok(releases) } pub async fn fetch_zen_releases( &self, ) -> Result, Box> { self.fetch_zen_releases_with_caching(false).await } pub async fn fetch_zen_releases_with_caching( &self, no_caching: bool, ) -> Result, Box> { // Check cache first (unless bypassing) if !no_caching { if let Some(cached_releases) = self.load_cached_github_releases("zen") { return Ok(cached_releases); } } println!("Fetching Zen releases from GitHub API..."); let url = "https://api.github.com/repos/zen-browser/desktop/releases"; let mut releases = self .client .get(url) .header("User-Agent", "donutbrowser") .send() .await? .json::>() .await?; // Sort releases using the new version sorting system (twilight releases will be at top) sort_github_releases(&mut releases); // Cache the results (unless bypassing cache) if !no_caching { if let Err(e) = self.save_cached_github_releases("zen", &releases) { eprintln!("Failed to cache Zen releases: {}", e); } } Ok(releases) } pub async fn fetch_brave_releases( &self, ) -> Result, Box> { self.fetch_brave_releases_with_caching(false).await } pub async fn fetch_brave_releases_with_caching( &self, no_caching: bool, ) -> Result, Box> { // Check cache first (unless bypassing) if !no_caching { if let Some(cached_releases) = self.load_cached_github_releases("brave") { return Ok(cached_releases); } } println!("Fetching Brave releases from GitHub API..."); let url = "https://api.github.com/repos/brave/brave-browser/releases"; let releases = self .client .get(url) .header("User-Agent", "donutbrowser") .send() .await? .json::>() .await?; // Filter releases that have universal macOS DMG assets let mut filtered_releases: Vec = releases .into_iter() .filter_map(|mut release| { // Check if this release has a universal DMG asset let has_universal_dmg = release .assets .iter() .any(|asset| asset.name.contains(".dmg") && asset.name.contains("universal")); if has_universal_dmg { // Set is_alpha based on the release name // Nightly releases contain "Nightly", stable contain "Release" release.is_alpha = release.name.to_lowercase().contains("nightly"); Some(release) } else { None } }) .collect(); // Sort releases using the new version sorting system sort_github_releases(&mut filtered_releases); // Cache the results (unless bypassing cache) if !no_caching { if let Err(e) = self.save_cached_github_releases("brave", &filtered_releases) { eprintln!("Failed to cache Brave releases: {}", e); } } Ok(filtered_releases) } pub async fn fetch_chromium_latest_version( &self, ) -> Result> { // Use architecture-aware URL for Chromium let arch = if cfg!(target_arch = "aarch64") { "Mac_Arm" } else { "Mac" }; let url = format!( "https://commondatastorage.googleapis.com/chromium-browser-snapshots/{}/LAST_CHANGE", arch ); let version = self .client .get(&url) .header("User-Agent", "donutbrowser") .send() .await? .text() .await? .trim() .to_string(); Ok(version) } pub async fn fetch_chromium_releases_with_caching( &self, no_caching: bool, ) -> Result, Box> { // Check cache first (unless bypassing) if !no_caching { if let Some(cached_versions) = self.load_cached_versions("chromium") { return Ok( cached_versions .into_iter() .map(|version| { BrowserRelease { version: version.clone(), date: "".to_string(), // Cache doesn't store dates is_prerelease: false, // Chromium versions are generally stable builds download_url: None, } }) .collect(), ); } } println!("Fetching Chromium releases..."); // Get the latest version first let latest_version = self.fetch_chromium_latest_version().await?; let latest_num: u32 = latest_version.parse().unwrap_or(0); // Generate a list of recent versions (last 20 builds, going back by 1000 each time) let mut versions = Vec::new(); for i in 0..20 { let version_num = latest_num.saturating_sub(i * 1000); if version_num > 0 { versions.push(version_num.to_string()); } } // Cache the results (unless bypassing cache) if !no_caching { if let Err(e) = self.save_cached_versions("chromium", &versions) { eprintln!("Failed to cache Chromium versions: {}", e); } } Ok( versions .into_iter() .map(|version| BrowserRelease { version: version.clone(), date: "".to_string(), is_prerelease: false, download_url: None, }) .collect(), ) } pub async fn fetch_tor_releases_with_caching( &self, no_caching: bool, ) -> Result, Box> { // Check cache first (unless bypassing) if !no_caching { if let Some(cached_versions) = self.load_cached_versions("tor-browser") { return Ok(cached_versions.into_iter().map(|version| { BrowserRelease { version: version.clone(), date: "".to_string(), // Cache doesn't store dates is_prerelease: false, // Assume all archived versions are stable download_url: Some(format!( "https://archive.torproject.org/tor-package-archive/torbrowser/{}/tor-browser-macos-{}.dmg", version, version )), } }).collect()); } } println!("Fetching TOR releases from archive..."); let url = "https://archive.torproject.org/tor-package-archive/torbrowser/"; let html = self .client .get(url) .header("User-Agent", "donutbrowser") .send() .await? .text() .await?; // Parse HTML to extract version directories let mut version_candidates = Vec::new(); // Look for directory links in the HTML for line in html.lines() { if line.contains("") { // Extract the directory name from the href attribute if let Some(start) = line.find("") { let version = &line[start..start + end]; // Skip parent directory and non-version entries if version != ".." && !version.is_empty() && version.chars().next().unwrap_or('a').is_ascii_digit() { version_candidates.push(version.to_string()); } } } } } // Sort version candidates using the new version sorting system sort_versions(&mut version_candidates); // Only check the first 10 versions to avoid being too slow let mut version_strings = Vec::new(); for version in version_candidates.into_iter().take(10) { // Check if this version has a macOS DMG file if let Ok(has_macos) = self.check_tor_version_has_macos(&version).await { if has_macos { version_strings.push(version); } } // Add a small delay to avoid overwhelming the server tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; } // Cache the results (unless bypassing cache) if !no_caching { if let Err(e) = self.save_cached_versions("tor-browser", &version_strings) { eprintln!("Failed to cache TOR versions: {}", e); } } Ok(version_strings.into_iter().map(|version| { BrowserRelease { version: version.clone(), date: "".to_string(), // TOR archive doesn't provide structured dates is_prerelease: false, // Assume all archived versions are stable download_url: Some(format!( "https://archive.torproject.org/tor-package-archive/torbrowser/{}/tor-browser-macos-{}.dmg", version, version )), } }).collect()) } async fn check_tor_version_has_macos( &self, version: &str, ) -> Result> { let url = format!( "https://archive.torproject.org/tor-package-archive/torbrowser/{}/", version ); let html = self .client .get(&url) .header("User-Agent", "donutbrowser") .send() .await? .text() .await?; // Check if there's a macOS DMG file in this version directory Ok(html.contains("tor-browser-macos-") && html.contains(".dmg")) } } #[cfg(test)] mod tests { use super::*; use tokio; #[test] fn test_version_parsing() { // Test basic version parsing let v1 = VersionComponent::parse("1.2.3"); assert_eq!(v1.major, 1); assert_eq!(v1.minor, 2); assert_eq!(v1.patch, 3); assert!(v1.pre_release.is_none()); // Test alpha version let v2 = VersionComponent::parse("1.2.3a1"); assert_eq!(v2.major, 1); assert_eq!(v2.minor, 2); assert_eq!(v2.patch, 3); assert!(v2.pre_release.is_some()); let pre = v2.pre_release.unwrap(); assert_eq!(pre.kind, PreReleaseKind::Alpha); assert_eq!(pre.number, Some(1)); // Test beta version let v3 = VersionComponent::parse("137.0b5"); assert_eq!(v3.major, 137); assert_eq!(v3.minor, 0); assert_eq!(v3.patch, 0); assert!(v3.pre_release.is_some()); let pre = v3.pre_release.unwrap(); assert_eq!(pre.kind, PreReleaseKind::Beta); assert_eq!(pre.number, Some(5)); // Test twilight version (Zen Browser) let v4 = VersionComponent::parse("1.0.0-twilight"); assert_eq!(v4.major, u32::MAX); assert_eq!(v4.minor, u32::MAX); assert_eq!(v4.patch, u32::MAX); } #[test] fn test_version_comparison() { // Test basic version comparison let v1 = VersionComponent::parse("1.2.3"); let v2 = VersionComponent::parse("1.2.4"); assert!(v2 > v1); // Test major version difference let v3 = VersionComponent::parse("2.0.0"); let v4 = VersionComponent::parse("1.9.9"); assert!(v3 > v4); // Test stable vs pre-release let v5 = VersionComponent::parse("1.2.3"); let v6 = VersionComponent::parse("1.2.3b1"); assert!(v5 > v6); // Stable > beta // Test different pre-release types let v7 = VersionComponent::parse("1.2.3a1"); let v8 = VersionComponent::parse("1.2.3b1"); assert!(v8 > v7); // Beta > alpha // Test pre-release numbers let v9 = VersionComponent::parse("137.0b4"); let v10 = VersionComponent::parse("137.0b5"); assert!(v10 > v9); // b5 > b4 // Test twilight version (should be highest) let v11 = VersionComponent::parse("1.0.0-twilight"); let v12 = VersionComponent::parse("999.999.999"); assert!(v11 > v12); } #[test] fn test_version_sorting() { let mut versions = vec![ "1.9.9b".to_string(), "1.12.6b".to_string(), "1.10.0".to_string(), "137.0b4".to_string(), "137.0b5".to_string(), "137.0".to_string(), "1.0.0-twilight".to_string(), "2.0.0a1".to_string(), ]; sort_versions(&mut versions); // Expected order: twilight, 137.0, 137.0b5, 137.0b4, 2.0.0a1, 1.12.6b, 1.10.0, 1.9.9b assert_eq!(versions[0], "1.0.0-twilight"); assert_eq!(versions[1], "137.0"); assert_eq!(versions[2], "137.0b5"); assert_eq!(versions[3], "137.0b4"); assert_eq!(versions[4], "2.0.0a1"); assert_eq!(versions[5], "1.12.6b"); assert_eq!(versions[6], "1.10.0"); assert_eq!(versions[7], "1.9.9b"); } #[tokio::test] async fn test_firefox_api() { let client = ApiClient::new(); let result = client.fetch_firefox_releases_with_caching(false).await; match result { Ok(releases) => { assert!(!releases.is_empty(), "Should have Firefox releases"); // Check that releases have required fields let first_release = &releases[0]; assert!( !first_release.version.is_empty(), "Version should not be empty" ); assert!( first_release.download_url.is_some(), "Should have download URL" ); println!("Firefox API test passed. Found {} releases", releases.len()); println!("Latest version: {}", first_release.version); } Err(e) => { println!("Firefox API test failed: {}", e); panic!("Firefox API should work"); } } } #[tokio::test] async fn test_firefox_developer_api() { tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; // Rate limiting let client = ApiClient::new(); let result = client .fetch_firefox_developer_releases_with_caching(false) .await; match result { Ok(releases) => { assert!( !releases.is_empty(), "Should have Firefox Developer releases" ); let first_release = &releases[0]; assert!( !first_release.version.is_empty(), "Version should not be empty" ); assert!( first_release.download_url.is_some(), "Should have download URL" ); println!( "Firefox Developer API test passed. Found {} releases", releases.len() ); println!("Latest version: {}", first_release.version); } Err(e) => { println!("Firefox Developer API test failed: {}", e); panic!("Firefox Developer API should work"); } } } #[tokio::test] async fn test_mullvad_api() { tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await; // Rate limiting let client = ApiClient::new(); let result = client.fetch_mullvad_releases().await; match result { Ok(releases) => { assert!(!releases.is_empty(), "Should have Mullvad releases"); let first_release = &releases[0]; assert!( !first_release.tag_name.is_empty(), "Tag name should not be empty" ); println!("Mullvad API test passed. Found {} releases", releases.len()); println!("Latest version: {}", first_release.tag_name); } Err(e) => { println!("Mullvad API test failed: {}", e); panic!("Mullvad API should work"); } } } #[tokio::test] async fn test_zen_api() { tokio::time::sleep(tokio::time::Duration::from_millis(1500)).await; // Rate limiting let client = ApiClient::new(); let result = client.fetch_zen_releases().await; match result { Ok(releases) => { assert!(!releases.is_empty(), "Should have Zen releases"); let first_release = &releases[0]; assert!( !first_release.tag_name.is_empty(), "Tag name should not be empty" ); println!("Zen API test passed. Found {} releases", releases.len()); println!("Latest version: {}", first_release.tag_name); } Err(e) => { println!("Zen API test failed: {}", e); panic!("Zen API should work"); } } } #[tokio::test] async fn test_brave_api() { tokio::time::sleep(tokio::time::Duration::from_millis(2000)).await; // Rate limiting let client = ApiClient::new(); let result = client.fetch_brave_releases().await; match result { Ok(releases) => { // Note: Brave might not always have macOS releases, so we don't assert non-empty println!( "Brave API test passed. Found {} releases with macOS assets", releases.len() ); if !releases.is_empty() { println!("Latest version: {}", releases[0].tag_name); } } Err(e) => { println!("Brave API test failed: {}", e); panic!("Brave API should work"); } } } #[tokio::test] async fn test_chromium_api() { tokio::time::sleep(tokio::time::Duration::from_millis(2500)).await; // Rate limiting let client = ApiClient::new(); let result = client.fetch_chromium_latest_version().await; match result { Ok(version) => { assert!(!version.is_empty(), "Version should not be empty"); assert!( version.chars().all(|c| c.is_ascii_digit()), "Version should be numeric" ); println!("Chromium API test passed. Latest version: {}", version); } Err(e) => { println!("Chromium API test failed: {}", e); panic!("Chromium API should work"); } } } #[tokio::test] async fn test_tor_api() { tokio::time::sleep(tokio::time::Duration::from_millis(3000)).await; // Rate limiting let client = ApiClient::new(); // Use a timeout for this test since TOR API can be slow let timeout_duration = tokio::time::Duration::from_secs(30); let result = tokio::time::timeout( timeout_duration, client.fetch_tor_releases_with_caching(false), ) .await; match result { Ok(Ok(releases)) => { assert!(!releases.is_empty(), "Should have TOR releases"); let first_release = &releases[0]; assert!( !first_release.version.is_empty(), "Version should not be empty" ); assert!( first_release.download_url.is_some(), "Should have download URL" ); println!("TOR API test passed. Found {} releases", releases.len()); println!("Latest version: {}", first_release.version); } Ok(Err(e)) => { println!("TOR API test failed: {}", e); // Don't panic for TOR API since it can be unreliable println!("TOR API test skipped due to network issues"); } Err(_) => { println!("TOR API test timed out after 30 seconds"); // Don't panic for timeout, just skip println!("TOR API test skipped due to timeout"); } } } #[tokio::test] async fn test_tor_version_check() { tokio::time::sleep(tokio::time::Duration::from_millis(3500)).await; // Rate limiting let client = ApiClient::new(); let result = client.check_tor_version_has_macos("14.0.4").await; match result { Ok(has_macos) => { assert!(has_macos, "Version 14.0.4 should have macOS support"); println!( "TOR version check test passed. Version 14.0.4 has macOS: {}", has_macos ); } Err(e) => { println!("TOR version check test failed: {}", e); panic!("TOR version check should work"); } } } }