This commit is contained in:
zhom
2025-05-29 10:17:16 +04:00
commit 08678dcacc
154 changed files with 29456 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+778
View File
@@ -0,0 +1,778 @@
use crate::browser_version_service::{BrowserVersionService, BrowserVersionInfo};
use crate::browser_runner::{BrowserProfile, BrowserRunner};
use crate::settings_manager::SettingsManager;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::fs;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct UpdateNotification {
pub id: String,
pub browser: String,
pub current_version: String,
pub new_version: String,
pub affected_profiles: Vec<String>,
pub is_stable_update: bool,
pub timestamp: u64,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AutoUpdateState {
pub pending_updates: Vec<UpdateNotification>,
pub disabled_browsers: HashSet<String>, // browsers disabled during update
#[serde(default)]
pub auto_update_downloads: HashSet<String>, // track auto-update downloads for toast suppression
pub last_check_timestamp: u64,
}
impl Default for AutoUpdateState {
fn default() -> Self {
Self {
pending_updates: Vec::new(),
disabled_browsers: HashSet::new(),
auto_update_downloads: HashSet::new(),
last_check_timestamp: 0,
}
}
}
pub struct AutoUpdater {
version_service: BrowserVersionService,
browser_runner: BrowserRunner,
settings_manager: SettingsManager,
}
impl AutoUpdater {
pub fn new() -> Self {
Self {
version_service: BrowserVersionService::new(),
browser_runner: BrowserRunner::new(),
settings_manager: SettingsManager::new(),
}
}
/// Check for updates for all profiles
pub async fn check_for_updates(&self) -> Result<Vec<UpdateNotification>, Box<dyn std::error::Error + Send + Sync>> {
// Check if auto-updates are enabled
let settings = self.settings_manager.load_settings()
.map_err(|e| format!("Failed to load settings: {}", e))?;
if !settings.auto_updates_enabled {
return Ok(Vec::new());
}
let profiles = self.browser_runner.list_profiles()
.map_err(|e| format!("Failed to list profiles: {}", e))?;
let mut notifications = Vec::new();
let mut browser_versions: HashMap<String, Vec<BrowserVersionInfo>> = HashMap::new();
// Group profiles by browser type
let mut browser_profiles: HashMap<String, Vec<BrowserProfile>> = HashMap::new();
for profile in profiles {
browser_profiles
.entry(profile.browser.clone())
.or_insert_with(Vec::new)
.push(profile);
}
// Check each browser type
for (browser, profiles) in browser_profiles {
// Get cached versions first, then try to fetch if needed
let versions = if let Some(cached) = self.version_service.get_cached_browser_versions_detailed(&browser) {
cached
} else if self.version_service.should_update_cache(&browser) {
// Try to fetch fresh versions
match self.version_service.fetch_browser_versions_detailed(&browser, false).await {
Ok(versions) => versions,
Err(_) => continue, // Skip this browser if fetch fails
}
} else {
continue; // No cached versions and cache doesn't need update
};
browser_versions.insert(browser.clone(), versions.clone());
// Check each profile for updates
for profile in profiles {
if let Some(update) = self.check_profile_update(&profile, &versions)? {
notifications.push(update);
}
}
}
Ok(notifications)
}
/// Check if a specific profile has an available update
fn check_profile_update(
&self,
profile: &BrowserProfile,
available_versions: &[BrowserVersionInfo],
) -> Result<Option<UpdateNotification>, Box<dyn std::error::Error + Send + Sync>> {
let current_version = &profile.version;
let is_current_stable = !self.is_alpha_version(current_version);
// Find the best available update
let best_update = available_versions
.iter()
.filter(|v| {
// Only consider versions newer than current
self.is_version_newer(&v.version, current_version) &&
// Respect version type preference
(is_current_stable == !v.is_prerelease || !is_current_stable)
})
.max_by(|a, b| self.compare_versions(&a.version, &b.version));
if let Some(update_version) = best_update {
let notification = UpdateNotification {
id: format!("{}_{}_to_{}", profile.browser, current_version, update_version.version),
browser: profile.browser.clone(),
current_version: current_version.clone(),
new_version: update_version.version.clone(),
affected_profiles: vec![profile.name.clone()],
is_stable_update: !update_version.is_prerelease,
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
};
Ok(Some(notification))
} else {
Ok(None)
}
}
/// Group update notifications by browser and version
pub fn group_update_notifications(&self, notifications: Vec<UpdateNotification>) -> Vec<UpdateNotification> {
let mut grouped: HashMap<String, UpdateNotification> = HashMap::new();
for notification in notifications {
let key = format!("{}_{}", notification.browser, notification.new_version);
if let Some(existing) = grouped.get_mut(&key) {
// Merge affected profiles
existing.affected_profiles.extend(notification.affected_profiles);
existing.affected_profiles.sort();
existing.affected_profiles.dedup();
} else {
grouped.insert(key, notification);
}
}
let mut result: Vec<UpdateNotification> = grouped.into_values().collect();
// Sort by priority: stable updates first, then by timestamp
result.sort_by(|a, b| {
match (a.is_stable_update, b.is_stable_update) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => b.timestamp.cmp(&a.timestamp),
}
});
result
}
/// Mark download as auto-update
pub fn mark_auto_update_download(
&self,
browser: &str,
version: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut state = self.load_auto_update_state()?;
let download_key = format!("{}-{}", browser, version);
state.auto_update_downloads.insert(download_key);
self.save_auto_update_state(&state)?;
Ok(())
}
/// Remove auto-update download tracking
pub fn remove_auto_update_download(
&self,
browser: &str,
version: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut state = self.load_auto_update_state()?;
let download_key = format!("{}-{}", browser, version);
state.auto_update_downloads.remove(&download_key);
self.save_auto_update_state(&state)?;
Ok(())
}
/// Check if download is marked as auto-update
pub fn is_auto_update_download(
&self,
browser: &str,
version: &str,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let state = self.load_auto_update_state()?;
let download_key = format!("{}-{}", browser, version);
Ok(state.auto_update_downloads.contains(&download_key))
}
/// Start browser update process
pub async fn start_browser_update(
&self,
browser: &str,
new_version: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Add browser to disabled list to prevent conflicts during update
let mut state = self.load_auto_update_state()?;
state.disabled_browsers.insert(browser.to_string());
// Mark this download as auto-update for toast suppression
let download_key = format!("{}-{}", browser, new_version);
state.auto_update_downloads.insert(download_key);
self.save_auto_update_state(&state)?;
// The actual download will be triggered by the frontend
// This function now just marks the browser as updating to prevent conflicts
Ok(())
}
/// Complete browser update process
pub async fn complete_browser_update(
&self,
browser: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Remove browser from disabled list
let mut state = self.load_auto_update_state()?;
state.disabled_browsers.remove(browser);
self.save_auto_update_state(&state)?;
Ok(())
}
/// Automatically update all affected profile versions after browser download
pub async fn auto_update_profile_versions(
&self,
browser: &str,
new_version: &str,
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
let profiles = self.browser_runner.list_profiles()
.map_err(|e| format!("Failed to list profiles: {}", e))?;
let mut updated_profiles = Vec::new();
// Find all profiles for this browser that should be updated
for profile in profiles {
if profile.browser == browser {
// Check if profile is currently running
if profile.process_id.is_some() {
continue; // Skip running profiles
}
// Check if this is an update (newer version)
if self.is_version_newer(new_version, &profile.version) {
// Update the profile version
match self.browser_runner.update_profile_version(&profile.name, new_version) {
Ok(_) => {
updated_profiles.push(profile.name);
}
Err(e) => {
eprintln!("Failed to update profile {}: {}", profile.name, e);
}
}
}
}
}
Ok(updated_profiles)
}
/// Complete browser update process with auto-update of profile versions
pub async fn complete_browser_update_with_auto_update(
&self,
browser: &str,
new_version: &str,
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
// Auto-update profile versions first
let updated_profiles = self.auto_update_profile_versions(browser, new_version).await?;
// Remove browser from disabled list and clean up auto-update tracking
let mut state = self.load_auto_update_state()?;
state.disabled_browsers.remove(browser);
let download_key = format!("{}-{}", browser, new_version);
state.auto_update_downloads.remove(&download_key);
self.save_auto_update_state(&state)?;
Ok(updated_profiles)
}
/// Check if browser is disabled due to ongoing update
pub fn is_browser_disabled(&self, browser: &str) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let state = self.load_auto_update_state()?;
Ok(state.disabled_browsers.contains(browser))
}
/// Dismiss update notification
pub fn dismiss_update_notification(&self, notification_id: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut state = self.load_auto_update_state()?;
state.pending_updates.retain(|n| n.id != notification_id);
self.save_auto_update_state(&state)?;
Ok(())
}
// Helper methods
fn is_alpha_version(&self, version: &str) -> bool {
version.contains("alpha") || version.contains("beta") || version.contains("rc") ||
version.contains("a") || version.contains("b") || version.contains("dev")
}
fn is_version_newer(&self, version1: &str, version2: &str) -> bool {
self.compare_versions(version1, version2) == std::cmp::Ordering::Greater
}
fn compare_versions(&self, version1: &str, version2: &str) -> std::cmp::Ordering {
// Basic semantic version comparison
let v1_parts = self.parse_version(version1);
let v2_parts = self.parse_version(version2);
v1_parts.cmp(&v2_parts)
}
fn parse_version(&self, version: &str) -> Vec<u32> {
version
.split(&['.', 'a', 'b', '-', '_'][..])
.filter_map(|part| part.parse::<u32>().ok())
.collect()
}
fn get_auto_update_state_file(&self) -> PathBuf {
self.settings_manager.get_settings_dir().join("auto_update_state.json")
}
fn load_auto_update_state(&self) -> Result<AutoUpdateState, Box<dyn std::error::Error + Send + Sync>> {
let state_file = self.get_auto_update_state_file();
if !state_file.exists() {
return Ok(AutoUpdateState::default());
}
let content = fs::read_to_string(state_file)?;
let state: AutoUpdateState = serde_json::from_str(&content)?;
Ok(state)
}
fn save_auto_update_state(&self, state: &AutoUpdateState) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let settings_dir = self.settings_manager.get_settings_dir();
std::fs::create_dir_all(&settings_dir)?;
let state_file = self.get_auto_update_state_file();
let json = serde_json::to_string_pretty(state)?;
fs::write(state_file, json)?;
Ok(())
}
}
// Tauri commands
#[tauri::command]
pub async fn check_for_browser_updates() -> Result<Vec<UpdateNotification>, String> {
let updater = AutoUpdater::new();
let notifications = updater.check_for_updates().await
.map_err(|e| format!("Failed to check for updates: {}", e))?;
let grouped = updater.group_update_notifications(notifications);
Ok(grouped)
}
#[tauri::command]
pub async fn start_browser_update(browser: String, new_version: String) -> Result<(), String> {
let updater = AutoUpdater::new();
updater.start_browser_update(&browser, &new_version).await
.map_err(|e| format!("Failed to start browser update: {}", e))
}
#[tauri::command]
pub async fn complete_browser_update(browser: String) -> Result<(), String> {
let updater = AutoUpdater::new();
updater.complete_browser_update(&browser).await
.map_err(|e| format!("Failed to complete browser update: {}", e))
}
#[tauri::command]
pub async fn is_browser_disabled_for_update(browser: String) -> Result<bool, String> {
let updater = AutoUpdater::new();
updater.is_browser_disabled(&browser)
.map_err(|e| format!("Failed to check browser status: {}", e))
}
#[tauri::command]
pub async fn dismiss_update_notification(notification_id: String) -> Result<(), String> {
let updater = AutoUpdater::new();
updater.dismiss_update_notification(&notification_id)
.map_err(|e| format!("Failed to dismiss notification: {}", e))
}
#[tauri::command]
pub async fn complete_browser_update_with_auto_update(browser: String, new_version: String) -> Result<Vec<String>, String> {
let updater = AutoUpdater::new();
updater.complete_browser_update_with_auto_update(&browser, &new_version).await
.map_err(|e| format!("Failed to complete browser update: {}", e))
}
#[tauri::command]
pub async fn mark_auto_update_download(browser: String, version: String) -> Result<(), String> {
let updater = AutoUpdater::new();
updater.mark_auto_update_download(&browser, &version)
.map_err(|e| format!("Failed to mark auto-update download: {}", e))
}
#[tauri::command]
pub async fn remove_auto_update_download(browser: String, version: String) -> Result<(), String> {
let updater = AutoUpdater::new();
updater.remove_auto_update_download(&browser, &version)
.map_err(|e| format!("Failed to remove auto-update download: {}", e))
}
#[tauri::command]
pub async fn is_auto_update_download(browser: String, version: String) -> Result<bool, String> {
let updater = AutoUpdater::new();
updater.is_auto_update_download(&browser, &version)
.map_err(|e| format!("Failed to check auto-update download: {}", e))
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_profile(name: &str, browser: &str, version: &str) -> BrowserProfile {
BrowserProfile {
name: name.to_string(),
browser: browser.to_string(),
version: version.to_string(),
profile_path: format!("/tmp/{}", name),
process_id: None,
proxy: None,
last_launch: None,
}
}
fn create_test_version_info(version: &str, is_prerelease: bool) -> BrowserVersionInfo {
BrowserVersionInfo {
version: version.to_string(),
is_prerelease,
date: "2024-01-01".to_string(),
}
}
#[test]
fn test_is_alpha_version() {
let updater = AutoUpdater::new();
assert!(updater.is_alpha_version("1.0.0-alpha"));
assert!(updater.is_alpha_version("1.0.0-beta"));
assert!(updater.is_alpha_version("1.0.0-rc"));
assert!(updater.is_alpha_version("1.0.0a1"));
assert!(updater.is_alpha_version("1.0.0b1"));
assert!(updater.is_alpha_version("1.0.0-dev"));
assert!(!updater.is_alpha_version("1.0.0"));
assert!(!updater.is_alpha_version("1.2.3"));
}
#[test]
fn test_compare_versions() {
let updater = AutoUpdater::new();
assert_eq!(updater.compare_versions("1.0.0", "1.0.0"), std::cmp::Ordering::Equal);
assert_eq!(updater.compare_versions("1.0.1", "1.0.0"), std::cmp::Ordering::Greater);
assert_eq!(updater.compare_versions("1.0.0", "1.0.1"), std::cmp::Ordering::Less);
assert_eq!(updater.compare_versions("2.0.0", "1.9.9"), std::cmp::Ordering::Greater);
assert_eq!(updater.compare_versions("1.10.0", "1.9.0"), std::cmp::Ordering::Greater);
}
#[test]
fn test_is_version_newer() {
let updater = AutoUpdater::new();
assert!(updater.is_version_newer("1.0.1", "1.0.0"));
assert!(updater.is_version_newer("2.0.0", "1.9.9"));
assert!(!updater.is_version_newer("1.0.0", "1.0.1"));
assert!(!updater.is_version_newer("1.0.0", "1.0.0"));
}
#[test]
fn test_check_profile_update_stable_to_stable() {
let updater = AutoUpdater::new();
let profile = create_test_profile("test", "firefox", "1.0.0");
let versions = vec![
create_test_version_info("1.0.1", false), // stable, newer
create_test_version_info("1.1.0-alpha", true), // alpha, should be ignored
create_test_version_info("0.9.0", false), // stable, older
];
let result = updater.check_profile_update(&profile, &versions).unwrap();
assert!(result.is_some());
let update = result.unwrap();
assert_eq!(update.new_version, "1.0.1");
assert!(update.is_stable_update);
}
#[test]
fn test_check_profile_update_alpha_to_alpha() {
let updater = AutoUpdater::new();
let profile = create_test_profile("test", "firefox", "1.0.0-alpha");
let versions = vec![
create_test_version_info("1.0.1", false), // stable, should be included
create_test_version_info("1.1.0-alpha", true), // alpha, newer
create_test_version_info("0.9.0-alpha", true), // alpha, older
];
let result = updater.check_profile_update(&profile, &versions).unwrap();
assert!(result.is_some());
let update = result.unwrap();
// Should pick the newest version (alpha user can upgrade to stable or newer alpha)
assert_eq!(update.new_version, "1.1.0-alpha");
assert!(!update.is_stable_update);
}
#[test]
fn test_check_profile_update_no_update_available() {
let updater = AutoUpdater::new();
let profile = create_test_profile("test", "firefox", "1.0.0");
let versions = vec![
create_test_version_info("0.9.0", false), // older
create_test_version_info("1.0.0", false), // same version
];
let result = updater.check_profile_update(&profile, &versions).unwrap();
assert!(result.is_none());
}
#[test]
fn test_group_update_notifications() {
let updater = AutoUpdater::new();
let notifications = vec![
UpdateNotification {
id: "firefox_1.0.0_to_1.1.0_profile1".to_string(),
browser: "firefox".to_string(),
current_version: "1.0.0".to_string(),
new_version: "1.1.0".to_string(),
affected_profiles: vec!["profile1".to_string()],
is_stable_update: true,
timestamp: 1000,
},
UpdateNotification {
id: "firefox_1.0.0_to_1.1.0_profile2".to_string(),
browser: "firefox".to_string(),
current_version: "1.0.0".to_string(),
new_version: "1.1.0".to_string(),
affected_profiles: vec!["profile2".to_string()],
is_stable_update: true,
timestamp: 1001,
},
UpdateNotification {
id: "chrome_1.0.0_to_1.1.0-alpha".to_string(),
browser: "chrome".to_string(),
current_version: "1.0.0".to_string(),
new_version: "1.1.0-alpha".to_string(),
affected_profiles: vec!["profile3".to_string()],
is_stable_update: false,
timestamp: 1002,
},
];
let grouped = updater.group_update_notifications(notifications);
assert_eq!(grouped.len(), 2);
// Find the Firefox notification
let firefox_notification = grouped.iter().find(|n| n.browser == "firefox").unwrap();
assert_eq!(firefox_notification.affected_profiles.len(), 2);
assert!(firefox_notification.affected_profiles.contains(&"profile1".to_string()));
assert!(firefox_notification.affected_profiles.contains(&"profile2".to_string()));
// Stable updates should come first
assert!(grouped[0].is_stable_update);
}
#[test]
fn test_auto_update_state_persistence() {
use tempfile::TempDir;
use std::sync::Once;
static INIT: Once = Once::new();
INIT.call_once(|| {
// Initialize any required static data
});
// Create a temporary directory for testing
let temp_dir = TempDir::new().unwrap();
// Create a mock settings manager that uses the temp directory
struct TestSettingsManager {
settings_dir: std::path::PathBuf,
}
impl TestSettingsManager {
fn new(settings_dir: std::path::PathBuf) -> Self {
Self { settings_dir }
}
fn get_settings_dir(&self) -> std::path::PathBuf {
self.settings_dir.clone()
}
}
let test_settings_manager = TestSettingsManager::new(temp_dir.path().to_path_buf());
let mut state = AutoUpdateState::default();
state.disabled_browsers.insert("firefox".to_string());
state.auto_update_downloads.insert("firefox-1.1.0".to_string());
state.pending_updates.push(UpdateNotification {
id: "test".to_string(),
browser: "firefox".to_string(),
current_version: "1.0.0".to_string(),
new_version: "1.1.0".to_string(),
affected_profiles: vec!["profile1".to_string()],
is_stable_update: true,
timestamp: 1000,
});
// Test save and load
let state_file = test_settings_manager.get_settings_dir().join("auto_update_state.json");
std::fs::create_dir_all(test_settings_manager.get_settings_dir()).unwrap();
let json = serde_json::to_string_pretty(&state).unwrap();
std::fs::write(&state_file, json).unwrap();
// Load state
let content = std::fs::read_to_string(&state_file).unwrap();
let loaded_state: AutoUpdateState = serde_json::from_str(&content).unwrap();
assert_eq!(loaded_state.disabled_browsers.len(), 1);
assert!(loaded_state.disabled_browsers.contains("firefox"));
assert_eq!(loaded_state.auto_update_downloads.len(), 1);
assert!(loaded_state.auto_update_downloads.contains("firefox-1.1.0"));
assert_eq!(loaded_state.pending_updates.len(), 1);
assert_eq!(loaded_state.pending_updates[0].id, "test");
}
#[tokio::test]
async fn test_browser_disable_enable_cycle() {
use tempfile::TempDir;
// Create a temporary directory for testing
let temp_dir = TempDir::new().unwrap();
// Create a mock settings manager that uses the temp directory
struct TestSettingsManager {
settings_dir: std::path::PathBuf,
}
impl TestSettingsManager {
fn new(settings_dir: std::path::PathBuf) -> Self {
Self { settings_dir }
}
fn get_settings_dir(&self) -> std::path::PathBuf {
self.settings_dir.clone()
}
}
let test_settings_manager = TestSettingsManager::new(temp_dir.path().to_path_buf());
// Test browser disable/enable cycle with manual state management
let state_file = test_settings_manager.get_settings_dir().join("auto_update_state.json");
std::fs::create_dir_all(test_settings_manager.get_settings_dir()).unwrap();
// Initially not disabled (empty state file means default state)
let state = AutoUpdateState::default();
assert!(!state.disabled_browsers.contains("firefox"));
// Start update (should disable)
let mut state = AutoUpdateState::default();
state.disabled_browsers.insert("firefox".to_string());
state.auto_update_downloads.insert("firefox-1.1.0".to_string());
let json = serde_json::to_string_pretty(&state).unwrap();
std::fs::write(&state_file, json).unwrap();
// Check that it's disabled
let content = std::fs::read_to_string(&state_file).unwrap();
let loaded_state: AutoUpdateState = serde_json::from_str(&content).unwrap();
assert!(loaded_state.disabled_browsers.contains("firefox"));
assert!(loaded_state.auto_update_downloads.contains("firefox-1.1.0"));
// Complete update (should enable)
let mut state = loaded_state;
state.disabled_browsers.remove("firefox");
state.auto_update_downloads.remove("firefox-1.1.0");
let json = serde_json::to_string_pretty(&state).unwrap();
std::fs::write(&state_file, json).unwrap();
// Check that it's enabled again
let content = std::fs::read_to_string(&state_file).unwrap();
let final_state: AutoUpdateState = serde_json::from_str(&content).unwrap();
assert!(!final_state.disabled_browsers.contains("firefox"));
assert!(!final_state.auto_update_downloads.contains("firefox-1.1.0"));
}
#[test]
fn test_dismiss_update_notification() {
use tempfile::TempDir;
// Create a temporary directory for testing
let temp_dir = TempDir::new().unwrap();
// Create a mock settings manager that uses the temp directory
struct TestSettingsManager {
settings_dir: std::path::PathBuf,
}
impl TestSettingsManager {
fn new(settings_dir: std::path::PathBuf) -> Self {
Self { settings_dir }
}
fn get_settings_dir(&self) -> std::path::PathBuf {
self.settings_dir.clone()
}
}
let test_settings_manager = TestSettingsManager::new(temp_dir.path().to_path_buf());
let mut state = AutoUpdateState::default();
state.pending_updates.push(UpdateNotification {
id: "test_notification".to_string(),
browser: "firefox".to_string(),
current_version: "1.0.0".to_string(),
new_version: "1.1.0".to_string(),
affected_profiles: vec!["profile1".to_string()],
is_stable_update: true,
timestamp: 1000,
});
// Save initial state
let state_file = test_settings_manager.get_settings_dir().join("auto_update_state.json");
std::fs::create_dir_all(test_settings_manager.get_settings_dir()).unwrap();
let json = serde_json::to_string_pretty(&state).unwrap();
std::fs::write(&state_file, json).unwrap();
// Dismiss notification (remove from pending updates)
let mut state = state;
state.pending_updates.retain(|n| n.id != "test_notification");
let json = serde_json::to_string_pretty(&state).unwrap();
std::fs::write(&state_file, json).unwrap();
// Check that it's removed
let content = std::fs::read_to_string(&state_file).unwrap();
let loaded_state: AutoUpdateState = serde_json::from_str(&content).unwrap();
assert_eq!(loaded_state.pending_updates.len(), 0);
}
#[test]
fn test_parse_version() {
let updater = AutoUpdater::new();
assert_eq!(updater.parse_version("1.2.3"), vec![1, 2, 3]);
assert_eq!(updater.parse_version("1.2.3-alpha"), vec![1, 2, 3]);
assert_eq!(updater.parse_version("1.2.3a1"), vec![1, 2, 3, 1]);
assert_eq!(updater.parse_version("1.2.3b2"), vec![1, 2, 3, 2]);
assert_eq!(updater.parse_version("10.0.0"), vec![10, 0, 0]);
}
}
+557
View File
@@ -0,0 +1,557 @@
use base64::{engine::general_purpose, Engine as _};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ProxySettings {
pub enabled: bool,
pub proxy_type: String, // "http", "https", "socks4", or "socks5"
pub host: String,
pub port: u16,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum BrowserType {
MullvadBrowser,
Chromium,
Firefox,
FirefoxDeveloper,
Brave,
Zen,
TorBrowser,
}
impl BrowserType {
pub fn as_str(&self) -> &'static str {
match self {
BrowserType::MullvadBrowser => "mullvad-browser",
BrowserType::Chromium => "chromium",
BrowserType::Firefox => "firefox",
BrowserType::FirefoxDeveloper => "firefox-developer",
BrowserType::Brave => "brave",
BrowserType::Zen => "zen",
BrowserType::TorBrowser => "tor-browser",
}
}
pub fn from_str(s: &str) -> Result<Self, String> {
match s {
"mullvad-browser" => Ok(BrowserType::MullvadBrowser),
"chromium" => Ok(BrowserType::Chromium),
"firefox" => Ok(BrowserType::Firefox),
"firefox-developer" => Ok(BrowserType::FirefoxDeveloper),
"brave" => Ok(BrowserType::Brave),
"zen" => Ok(BrowserType::Zen),
"tor-browser" => Ok(BrowserType::TorBrowser),
_ => Err(format!("Unknown browser type: {}", s)),
}
}
}
pub trait Browser: Send + Sync {
fn browser_type(&self) -> BrowserType;
fn get_executable_path(&self, install_dir: &Path) -> Result<PathBuf, Box<dyn std::error::Error>>;
fn create_launch_args(
&self,
profile_path: &str,
_proxy_settings: Option<&ProxySettings>,
url: Option<String>,
) -> Result<Vec<String>, Box<dyn std::error::Error>>;
fn is_version_downloaded(&self, version: &str, binaries_dir: &Path) -> bool;
}
pub struct FirefoxBrowser {
browser_type: BrowserType,
}
impl FirefoxBrowser {
pub fn new(browser_type: BrowserType) -> Self {
Self { browser_type }
}
}
impl Browser for FirefoxBrowser {
fn browser_type(&self) -> BrowserType {
self.browser_type.clone()
}
fn get_executable_path(&self, install_dir: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
// Find the .app directory
let app_path = std::fs::read_dir(install_dir)?
.filter_map(Result::ok)
.find(|entry| entry.path().extension().map_or(false, |ext| ext == "app"))
.ok_or("Browser app not found")?;
// Construct the browser executable path
let mut executable_dir = app_path.path();
executable_dir.push("Contents");
executable_dir.push("MacOS");
// Find the first executable in the MacOS directory
let executable_path = std::fs::read_dir(&executable_dir)?
.filter_map(Result::ok)
.find(|entry| {
let binding = entry.file_name();
let name = binding.to_string_lossy();
name.starts_with("firefox") || name.starts_with("mullvad") || name.starts_with("zen") || name.starts_with("tor") || name.contains("Browser")
})
.map(|entry| entry.path())
.ok_or("No executable found in MacOS directory")?;
Ok(executable_path)
}
fn create_launch_args(
&self,
profile_path: &str,
_proxy_settings: Option<&ProxySettings>,
url: Option<String>,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let mut args = vec![
"-profile".to_string(),
profile_path.to_string(),
];
// Only use -no-remote for browsers that require it for security (Mullvad, Tor)
// Regular Firefox browsers can use remote commands for better URL handling
match self.browser_type {
BrowserType::MullvadBrowser | BrowserType::TorBrowser => {
args.push("-no-remote".to_string());
}
BrowserType::Firefox | BrowserType::FirefoxDeveloper | BrowserType::Zen => {
// Don't use -no-remote so we can communicate with existing instances
}
_ => {}
}
// Firefox-based browsers use profile directory and user.js for proxy configuration
if let Some(url) = url {
args.push(url);
}
Ok(args)
}
fn is_version_downloaded(&self, version: &str, binaries_dir: &Path) -> bool {
let browser_dir = binaries_dir
.join(self.browser_type().as_str())
.join(version);
println!("Firefox browser checking version {} in directory: {:?}", version, browser_dir);
// Only check if directory exists and contains a .app file
if browser_dir.exists() {
println!("Directory exists, checking for .app files...");
if let Ok(entries) = std::fs::read_dir(&browser_dir) {
for entry in entries {
if let Ok(entry) = entry {
println!(" Found entry: {:?}", entry.path());
if entry.path().extension().map_or(false, |ext| ext == "app") {
println!(" Found .app file: {:?}", entry.path());
return true;
}
}
}
}
println!("No .app files found in directory");
} else {
println!("Directory does not exist: {:?}", browser_dir);
}
false
}
}
// Chromium-based browsers (Chromium, Brave)
pub struct ChromiumBrowser {
browser_type: BrowserType,
}
impl ChromiumBrowser {
pub fn new(browser_type: BrowserType) -> Self {
Self { browser_type }
}
}
impl Browser for ChromiumBrowser {
fn browser_type(&self) -> BrowserType {
self.browser_type.clone()
}
fn get_executable_path(&self, install_dir: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
// Find the .app directory
let app_path = std::fs::read_dir(install_dir)?
.filter_map(Result::ok)
.find(|entry| entry.path().extension().map_or(false, |ext| ext == "app"))
.ok_or("Browser app not found")?;
// Construct the browser executable path
let mut executable_dir = app_path.path();
executable_dir.push("Contents");
executable_dir.push("MacOS");
// Find the first executable in the MacOS directory
let executable_path = std::fs::read_dir(&executable_dir)?
.filter_map(Result::ok)
.find(|entry| {
let binding = entry.file_name();
let name = binding.to_string_lossy();
name.contains("Chromium") || name.contains("Brave") || name.contains("Google Chrome")
})
.map(|entry| entry.path())
.ok_or("No executable found in MacOS directory")?;
Ok(executable_path)
}
fn create_launch_args(
&self,
profile_path: &str,
proxy_settings: Option<&ProxySettings>,
url: Option<String>,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let mut args = vec![
format!("--user-data-dir={}", profile_path),
"--no-default-browser-check".to_string(),
"--disable-background-mode".to_string(),
"--disable-component-update".to_string(),
"--disable-background-timer-throttling".to_string(),
"--crash-server-url=".to_string(),
];
// Add proxy configuration if provided
if let Some(proxy) = proxy_settings {
if proxy.enabled {
// Read PAC file and encode it as base64
let pac_path = Path::new(profile_path).join("proxy.pac");
if pac_path.exists() {
let pac_content = fs::read(&pac_path)?;
let pac_base64 = general_purpose::STANDARD.encode(&pac_content);
args.push(format!(
"--proxy-pac-url=data:application/x-javascript-config;base64,{}",
pac_base64
));
}
}
}
if let Some(url) = url {
args.push(url);
}
Ok(args)
}
fn is_version_downloaded(&self, version: &str, binaries_dir: &Path) -> bool {
let browser_dir = binaries_dir
.join(self.browser_type().as_str())
.join(version);
println!("Chromium browser checking version {} in directory: {:?}", version, browser_dir);
// Check if directory exists and contains at least one .app file
if browser_dir.exists() {
println!("Directory exists, checking for .app files...");
if let Ok(entries) = std::fs::read_dir(&browser_dir) {
for entry in entries {
if let Ok(entry) = entry {
println!(" Found entry: {:?}", entry.path());
if entry.path().extension().map_or(false, |ext| ext == "app") {
println!(" Found .app file: {:?}", entry.path());
// Try to get the executable path as a final verification
if self.get_executable_path(&browser_dir).is_ok() {
println!(" Executable path verification successful");
return true;
} else {
println!(" Executable path verification failed");
}
}
}
}
}
println!("No valid .app files found in directory");
} else {
println!("Directory does not exist: {:?}", browser_dir);
}
false
}
}
// Factory function to create browser instances
pub fn create_browser(browser_type: BrowserType) -> Box<dyn Browser> {
match browser_type {
BrowserType::MullvadBrowser | BrowserType::Firefox | BrowserType::FirefoxDeveloper | BrowserType::Zen | BrowserType::TorBrowser => {
Box::new(FirefoxBrowser::new(browser_type))
}
BrowserType::Chromium | BrowserType::Brave => Box::new(ChromiumBrowser::new(browser_type)),
}
}
// Add GithubRelease and GithubAsset structs to browser.rs if they don't already exist
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct GithubRelease {
pub tag_name: String,
#[serde(default)]
pub name: String,
pub assets: Vec<GithubAsset>,
#[serde(default)]
pub published_at: String,
#[serde(default)]
pub is_alpha: bool,
#[serde(default)]
pub prerelease: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct GithubAsset {
pub name: String,
pub browser_download_url: String,
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_browser_type_conversions() {
// Test as_str
assert_eq!(BrowserType::MullvadBrowser.as_str(), "mullvad-browser");
assert_eq!(BrowserType::Firefox.as_str(), "firefox");
assert_eq!(BrowserType::FirefoxDeveloper.as_str(), "firefox-developer");
assert_eq!(BrowserType::Chromium.as_str(), "chromium");
assert_eq!(BrowserType::Brave.as_str(), "brave");
assert_eq!(BrowserType::Zen.as_str(), "zen");
assert_eq!(BrowserType::TorBrowser.as_str(), "tor-browser");
// Test from_str
assert_eq!(BrowserType::from_str("mullvad-browser").unwrap(), BrowserType::MullvadBrowser);
assert_eq!(BrowserType::from_str("firefox").unwrap(), BrowserType::Firefox);
assert_eq!(BrowserType::from_str("firefox-developer").unwrap(), BrowserType::FirefoxDeveloper);
assert_eq!(BrowserType::from_str("chromium").unwrap(), BrowserType::Chromium);
assert_eq!(BrowserType::from_str("brave").unwrap(), BrowserType::Brave);
assert_eq!(BrowserType::from_str("zen").unwrap(), BrowserType::Zen);
assert_eq!(BrowserType::from_str("tor-browser").unwrap(), BrowserType::TorBrowser);
// Test invalid browser type
assert!(BrowserType::from_str("invalid").is_err());
assert!(BrowserType::from_str("").is_err());
assert!(BrowserType::from_str("Firefox").is_err()); // Case sensitive
}
#[test]
fn test_firefox_browser_creation() {
let browser = FirefoxBrowser::new(BrowserType::Firefox);
assert_eq!(browser.browser_type(), BrowserType::Firefox);
let browser = FirefoxBrowser::new(BrowserType::MullvadBrowser);
assert_eq!(browser.browser_type(), BrowserType::MullvadBrowser);
let browser = FirefoxBrowser::new(BrowserType::TorBrowser);
assert_eq!(browser.browser_type(), BrowserType::TorBrowser);
let browser = FirefoxBrowser::new(BrowserType::Zen);
assert_eq!(browser.browser_type(), BrowserType::Zen);
}
#[test]
fn test_chromium_browser_creation() {
let browser = ChromiumBrowser::new(BrowserType::Chromium);
assert_eq!(browser.browser_type(), BrowserType::Chromium);
let browser = ChromiumBrowser::new(BrowserType::Brave);
assert_eq!(browser.browser_type(), BrowserType::Brave);
}
#[test]
fn test_browser_factory() {
// Test Firefox-based browsers
let browser = create_browser(BrowserType::Firefox);
assert_eq!(browser.browser_type(), BrowserType::Firefox);
let browser = create_browser(BrowserType::MullvadBrowser);
assert_eq!(browser.browser_type(), BrowserType::MullvadBrowser);
let browser = create_browser(BrowserType::Zen);
assert_eq!(browser.browser_type(), BrowserType::Zen);
let browser = create_browser(BrowserType::TorBrowser);
assert_eq!(browser.browser_type(), BrowserType::TorBrowser);
let browser = create_browser(BrowserType::FirefoxDeveloper);
assert_eq!(browser.browser_type(), BrowserType::FirefoxDeveloper);
// Test Chromium-based browsers
let browser = create_browser(BrowserType::Chromium);
assert_eq!(browser.browser_type(), BrowserType::Chromium);
let browser = create_browser(BrowserType::Brave);
assert_eq!(browser.browser_type(), BrowserType::Brave);
}
#[test]
fn test_firefox_launch_args() {
// Test regular Firefox (should not use -no-remote)
let browser = FirefoxBrowser::new(BrowserType::Firefox);
let args = browser.create_launch_args("/path/to/profile", None, None).unwrap();
assert_eq!(args, vec!["-profile", "/path/to/profile"]);
assert!(!args.contains(&"-no-remote".to_string()));
let args = browser.create_launch_args("/path/to/profile", None, Some("https://example.com".to_string())).unwrap();
assert_eq!(args, vec!["-profile", "/path/to/profile", "https://example.com"]);
// Test Mullvad Browser (should use -no-remote)
let browser = FirefoxBrowser::new(BrowserType::MullvadBrowser);
let args = browser.create_launch_args("/path/to/profile", None, None).unwrap();
assert_eq!(args, vec!["-profile", "/path/to/profile", "-no-remote"]);
// Test Tor Browser (should use -no-remote)
let browser = FirefoxBrowser::new(BrowserType::TorBrowser);
let args = browser.create_launch_args("/path/to/profile", None, None).unwrap();
assert_eq!(args, vec!["-profile", "/path/to/profile", "-no-remote"]);
// Test Zen Browser (should not use -no-remote)
let browser = FirefoxBrowser::new(BrowserType::Zen);
let args = browser.create_launch_args("/path/to/profile", None, None).unwrap();
assert_eq!(args, vec!["-profile", "/path/to/profile"]);
assert!(!args.contains(&"-no-remote".to_string()));
}
#[test]
fn test_chromium_launch_args() {
let browser = ChromiumBrowser::new(BrowserType::Chromium);
let args = browser.create_launch_args("/path/to/profile", None, None).unwrap();
// Test that basic required arguments are present
assert!(args.contains(&"--user-data-dir=/path/to/profile".to_string()));
assert!(args.contains(&"--no-default-browser-check".to_string()));
// Test that automatic update disabling arguments are present
assert!(args.contains(&"--disable-background-mode".to_string()));
assert!(args.contains(&"--disable-component-update".to_string()));
let args_with_url = browser.create_launch_args("/path/to/profile", None, Some("https://example.com".to_string())).unwrap();
assert!(args_with_url.contains(&"https://example.com".to_string()));
// Verify URL is at the end
assert_eq!(args_with_url.last().unwrap(), "https://example.com");
}
#[test]
fn test_proxy_settings_creation() {
let proxy = ProxySettings {
enabled: true,
proxy_type: "http".to_string(),
host: "127.0.0.1".to_string(),
port: 8080,
};
assert!(proxy.enabled);
assert_eq!(proxy.proxy_type, "http");
assert_eq!(proxy.host, "127.0.0.1");
assert_eq!(proxy.port, 8080);
// Test different proxy types
let socks_proxy = ProxySettings {
enabled: true,
proxy_type: "socks5".to_string(),
host: "proxy.example.com".to_string(),
port: 1080,
};
assert_eq!(socks_proxy.proxy_type, "socks5");
assert_eq!(socks_proxy.host, "proxy.example.com");
assert_eq!(socks_proxy.port, 1080);
}
#[test]
fn test_version_downloaded_check() {
let temp_dir = TempDir::new().unwrap();
let binaries_dir = temp_dir.path();
// Create a mock Firefox browser installation
let browser_dir = binaries_dir.join("firefox").join("139.0");
fs::create_dir_all(&browser_dir).unwrap();
// Create a mock .app directory
let app_dir = browser_dir.join("Firefox.app");
fs::create_dir_all(&app_dir).unwrap();
let browser = FirefoxBrowser::new(BrowserType::Firefox);
assert!(browser.is_version_downloaded("139.0", binaries_dir));
assert!(!browser.is_version_downloaded("140.0", binaries_dir));
// Test with Chromium browser
let chromium_dir = binaries_dir.join("chromium").join("1465660");
fs::create_dir_all(&chromium_dir).unwrap();
let chromium_app_dir = chromium_dir.join("Chromium.app");
fs::create_dir_all(&chromium_app_dir.join("Contents").join("MacOS")).unwrap();
// Create a mock executable
let executable_path = chromium_app_dir.join("Contents").join("MacOS").join("Chromium");
fs::write(&executable_path, "mock executable").unwrap();
let chromium_browser = ChromiumBrowser::new(BrowserType::Chromium);
assert!(chromium_browser.is_version_downloaded("1465660", binaries_dir));
assert!(!chromium_browser.is_version_downloaded("1465661", binaries_dir));
}
#[test]
fn test_version_downloaded_no_app_directory() {
let temp_dir = TempDir::new().unwrap();
let binaries_dir = temp_dir.path();
// Create browser directory but no .app directory
let browser_dir = binaries_dir.join("firefox").join("139.0");
fs::create_dir_all(&browser_dir).unwrap();
// Create some other files but no .app
fs::write(browser_dir.join("readme.txt"), "Some content").unwrap();
let browser = FirefoxBrowser::new(BrowserType::Firefox);
assert!(!browser.is_version_downloaded("139.0", binaries_dir));
}
#[test]
fn test_browser_type_clone_and_debug() {
let browser_type = BrowserType::Firefox;
let cloned = browser_type.clone();
assert_eq!(browser_type, cloned);
// Test Debug trait
let debug_str = format!("{:?}", browser_type);
assert!(debug_str.contains("Firefox"));
}
#[test]
fn test_proxy_settings_serialization() {
let proxy = ProxySettings {
enabled: true,
proxy_type: "http".to_string(),
host: "127.0.0.1".to_string(),
port: 8080,
};
// Test that it can be serialized (implements Serialize)
let json = serde_json::to_string(&proxy).unwrap();
assert!(json.contains("127.0.0.1"));
assert!(json.contains("8080"));
assert!(json.contains("http"));
// Test that it can be deserialized (implements Deserialize)
let deserialized: ProxySettings = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.enabled, proxy.enabled);
assert_eq!(deserialized.proxy_type, proxy.proxy_type);
assert_eq!(deserialized.host, proxy.host);
assert_eq!(deserialized.port, proxy.port);
}
}
File diff suppressed because it is too large Load Diff
+669
View File
@@ -0,0 +1,669 @@
use crate::api_client::{ApiClient, BrowserRelease, sort_versions};
use crate::browser::GithubRelease;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BrowserVersionInfo {
pub version: String,
pub is_prerelease: bool,
pub date: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BrowserVersionsResult {
pub versions: Vec<String>,
pub new_versions_count: Option<usize>,
pub total_versions_count: usize,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DownloadInfo {
pub url: String,
pub filename: String,
pub is_archive: bool, // true for .dmg, .zip, etc.
}
pub struct BrowserVersionService {
api_client: ApiClient,
}
impl BrowserVersionService {
pub fn new() -> Self {
Self {
api_client: ApiClient::new(),
}
}
/// Get cached browser versions immediately (returns None if no cache exists)
pub fn get_cached_browser_versions(&self, browser: &str) -> Option<Vec<String>> {
self.api_client.load_cached_versions(browser)
}
/// Get cached detailed browser version information immediately
pub fn get_cached_browser_versions_detailed(&self, browser: &str) -> Option<Vec<BrowserVersionInfo>> {
let cached_versions = self.api_client.load_cached_versions(browser)?;
// Convert cached versions to detailed info (without dates since cache doesn't store them)
let detailed_info: Vec<BrowserVersionInfo> = cached_versions.into_iter().map(|version| {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: crate::api_client::is_alpha_version(&version),
date: "".to_string(), // Cache doesn't store dates
}
}).collect();
Some(detailed_info)
}
/// Check if cache should be updated (expired or doesn't exist)
pub fn should_update_cache(&self, browser: &str) -> bool {
self.api_client.is_cache_expired(browser)
}
/// Fetch browser versions with optional caching
pub async fn fetch_browser_versions(
&self,
browser: &str,
no_caching: bool,
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
let result = self.fetch_browser_versions_with_count(browser, no_caching).await?;
Ok(result.versions)
}
/// Fetch browser versions with new count information and optional caching
pub async fn fetch_browser_versions_with_count(
&self,
browser: &str,
no_caching: bool,
) -> Result<BrowserVersionsResult, Box<dyn std::error::Error + Send + Sync>> {
// Get existing cached versions to compare and merge
let existing_versions = self.api_client.load_cached_versions(browser).unwrap_or_default();
let existing_set: HashSet<String> = existing_versions.into_iter().collect();
// Fetch fresh versions from API
let fresh_versions = match browser {
"firefox" => self.fetch_firefox_versions(true).await?, // Always fetch fresh for merging
"firefox-developer" => self.fetch_firefox_developer_versions(true).await?,
"mullvad-browser" => self.fetch_mullvad_versions(true).await?,
"zen" => self.fetch_zen_versions(true).await?,
"brave" => self.fetch_brave_versions(true).await?,
"chromium" => self.fetch_chromium_versions(true).await?,
"tor-browser" => self.fetch_tor_versions(true).await?,
_ => return Err(format!("Unsupported browser: {}", browser).into()),
};
let fresh_set: HashSet<String> = fresh_versions.into_iter().collect();
// Find new versions (in fresh but not in existing cache)
let new_versions: Vec<String> = fresh_set.difference(&existing_set).cloned().collect();
let new_versions_count = if existing_set.is_empty() { None } else { Some(new_versions.len()) };
// Merge existing and fresh versions
let mut merged_versions: Vec<String> = existing_set.union(&fresh_set).cloned().collect();
// Sort versions using the existing sorting logic
crate::api_client::sort_versions(&mut merged_versions);
// Save the merged cache (unless explicitly bypassing cache)
if !no_caching {
if let Err(e) = self.api_client.save_cached_versions(browser, &merged_versions) {
eprintln!("Failed to save merged cache for {}: {}", browser, e);
}
}
let total_versions_count = merged_versions.len();
Ok(BrowserVersionsResult {
versions: merged_versions,
new_versions_count,
total_versions_count,
})
}
/// Fetch detailed browser version information with optional caching
pub async fn fetch_browser_versions_detailed(
&self,
browser: &str,
no_caching: bool,
) -> Result<Vec<BrowserVersionInfo>, Box<dyn std::error::Error + Send + Sync>> {
// For detailed versions, we'll use the merged versions from fetch_browser_versions_with_count
// to ensure consistency with the version list
let versions_result = self.fetch_browser_versions_with_count(browser, no_caching).await?;
let merged_versions = versions_result.versions;
// Convert the version strings to BrowserVersionInfo
// Since we don't have detailed date/prerelease info for cached versions,
// we'll fetch fresh detailed info and map it to our merged versions
let detailed_info: Vec<BrowserVersionInfo> = match browser {
"firefox" => {
let releases = self.fetch_firefox_releases_detailed(true).await?;
merged_versions.into_iter().map(|version| {
// Try to find matching release info, otherwise create basic info
if let Some(release) = releases.iter().find(|r| r.version == version) {
BrowserVersionInfo {
version: release.version.clone(),
is_prerelease: release.is_prerelease,
date: release.date.clone(),
}
} else {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: crate::api_client::is_alpha_version(&version),
date: "".to_string(),
}
}
}).collect()
}
"firefox-developer" => {
let releases = self.fetch_firefox_developer_releases_detailed(true).await?;
merged_versions.into_iter().map(|version| {
if let Some(release) = releases.iter().find(|r| r.version == version) {
BrowserVersionInfo {
version: release.version.clone(),
is_prerelease: release.is_prerelease,
date: release.date.clone(),
}
} else {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: crate::api_client::is_alpha_version(&version),
date: "".to_string(),
}
}
}).collect()
}
"mullvad-browser" => {
let releases = self.fetch_mullvad_releases_detailed(true).await?;
merged_versions.into_iter().map(|version| {
if let Some(release) = releases.iter().find(|r| r.tag_name == version) {
BrowserVersionInfo {
version: release.tag_name.clone(),
is_prerelease: release.is_alpha,
date: release.published_at.clone(),
}
} else {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: false, // Mullvad usually stable releases
date: "".to_string(),
}
}
}).collect()
}
"zen" => {
let releases = self.fetch_zen_releases_detailed(true).await?;
merged_versions.into_iter().map(|version| {
if let Some(release) = releases.iter().find(|r| r.tag_name == version) {
BrowserVersionInfo {
version: release.tag_name.clone(),
is_prerelease: release.prerelease,
date: release.published_at.clone(),
}
} else {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: version.contains("alpha") || version.contains("beta"),
date: "".to_string(),
}
}
}).collect()
}
"brave" => {
let releases = self.fetch_brave_releases_detailed(true).await?;
merged_versions.into_iter().map(|version| {
if let Some(release) = releases.iter().find(|r| r.tag_name == version) {
BrowserVersionInfo {
version: release.tag_name.clone(),
is_prerelease: release.prerelease,
date: release.published_at.clone(),
}
} else {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: version.contains("beta") || version.contains("dev"),
date: "".to_string(),
}
}
}).collect()
}
"chromium" => {
let releases = self.fetch_chromium_releases_detailed(true).await?;
merged_versions.into_iter().map(|version| {
if let Some(release) = releases.iter().find(|r| r.version == version) {
BrowserVersionInfo {
version: release.version.clone(),
is_prerelease: release.is_prerelease,
date: release.date.clone(),
}
} else {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: false, // Chromium versions are usually stable
date: "".to_string(),
}
}
}).collect()
}
"tor-browser" => {
let releases = self.fetch_tor_releases_detailed(true).await?;
merged_versions.into_iter().map(|version| {
if let Some(release) = releases.iter().find(|r| r.version == version) {
BrowserVersionInfo {
version: release.version.clone(),
is_prerelease: release.is_prerelease,
date: release.date.clone(),
}
} else {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: version.contains("alpha") || version.contains("rc"),
date: "".to_string(),
}
}
}).collect()
}
_ => return Err(format!("Unsupported browser: {}", browser).into()),
};
Ok(detailed_info)
}
/// Update browser versions incrementally (for background updates)
pub async fn update_browser_versions_incrementally(
&self,
browser: &str,
) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
// Get existing cached versions
let existing_versions = self.api_client.load_cached_versions(browser)
.unwrap_or_default();
let existing_set: HashSet<String> = existing_versions.into_iter().collect();
// Fetch new versions (always bypass cache for background updates)
let new_versions = self.fetch_browser_versions(browser, true).await?;
let new_set: HashSet<String> = new_versions.into_iter().collect();
// Find truly new versions (not in existing cache)
let really_new_versions: Vec<String> = new_set.difference(&existing_set).cloned().collect();
let new_versions_count = really_new_versions.len();
// Merge existing and new versions
let mut all_versions: Vec<String> = existing_set.union(&new_set).cloned().collect();
// Sort versions using the existing sorting logic
sort_versions(&mut all_versions);
// Save the updated cache
if let Err(e) = self.api_client.save_cached_versions(browser, &all_versions) {
eprintln!("Failed to save updated cache for {}: {}", browser, e);
}
Ok(new_versions_count)
}
/// Get download information for a specific browser and version
pub fn get_download_info(&self, browser: &str, version: &str) -> Result<DownloadInfo, Box<dyn std::error::Error + Send + Sync>> {
match browser {
"firefox" => Ok(DownloadInfo {
url: format!("https://download.mozilla.org/?product=firefox-{}&os=osx&lang=en-US", version),
filename: format!("firefox-{}.dmg", version),
is_archive: true,
}),
"firefox-developer" => Ok(DownloadInfo {
url: format!("https://download.mozilla.org/?product=devedition-{}&os=osx&lang=en-US", version),
filename: format!("firefox-developer-{}.dmg", version),
is_archive: true,
}),
"mullvad-browser" => Ok(DownloadInfo {
url: format!(
"https://github.com/mullvad/mullvad-browser/releases/download/{}/mullvad-browser-macos-{}.dmg",
version, version
),
filename: format!("mullvad-browser-{}.dmg", version),
is_archive: true,
}),
"zen" => Ok(DownloadInfo {
url: format!(
"https://github.com/zen-browser/desktop/releases/download/{}/zen.macos-universal.dmg",
version
),
filename: format!("zen-{}.dmg", version),
is_archive: true,
}),
"brave" => {
// For Brave, we use a placeholder URL since we need to resolve the actual asset URL dynamically
// The actual URL will be resolved in the download service using the GitHub API
Ok(DownloadInfo {
url: format!(
"https://github.com/brave/brave-browser/releases/download/{}/Brave-Browser-universal.dmg",
version
),
filename: format!("brave-{}.dmg", version),
is_archive: true,
})
}
"chromium" => {
let arch = if cfg!(target_arch = "aarch64") { "Mac_Arm" } else { "Mac" };
Ok(DownloadInfo {
url: format!(
"https://commondatastorage.googleapis.com/chromium-browser-snapshots/{}/{}/chrome-mac.zip",
arch, version
),
filename: format!("chromium-{}.zip", version),
is_archive: true,
})
}
"tor-browser" => Ok(DownloadInfo {
url: format!(
"https://archive.torproject.org/tor-package-archive/torbrowser/{}/tor-browser-macos-{}.dmg",
version, version
),
filename: format!("tor-browser-{}.dmg", version),
is_archive: true,
}),
_ => Err(format!("Unsupported browser: {}", browser).into()),
}
}
// Private helper methods for each browser type
async fn fetch_firefox_versions(&self, no_caching: bool) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
let releases = self.fetch_firefox_releases_detailed(no_caching).await?;
Ok(releases.into_iter().map(|r| r.version).collect())
}
async fn fetch_firefox_releases_detailed(&self, no_caching: bool) -> Result<Vec<BrowserRelease>, Box<dyn std::error::Error + Send + Sync>> {
self.api_client.fetch_firefox_releases_with_caching(no_caching).await
}
async fn fetch_firefox_developer_versions(&self, no_caching: bool) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
let releases = self.fetch_firefox_developer_releases_detailed(no_caching).await?;
Ok(releases.into_iter().map(|r| r.version).collect())
}
async fn fetch_firefox_developer_releases_detailed(&self, no_caching: bool) -> Result<Vec<BrowserRelease>, Box<dyn std::error::Error + Send + Sync>> {
self.api_client.fetch_firefox_developer_releases_with_caching(no_caching).await
}
async fn fetch_mullvad_versions(&self, no_caching: bool) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
let releases = self.fetch_mullvad_releases_detailed(no_caching).await?;
Ok(releases.into_iter().map(|r| r.tag_name).collect())
}
async fn fetch_mullvad_releases_detailed(&self, no_caching: bool) -> Result<Vec<GithubRelease>, Box<dyn std::error::Error + Send + Sync>> {
self.api_client.fetch_mullvad_releases_with_caching(no_caching).await
}
async fn fetch_zen_versions(&self, no_caching: bool) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
let releases = self.fetch_zen_releases_detailed(no_caching).await?;
Ok(releases.into_iter().map(|r| r.tag_name).collect())
}
async fn fetch_zen_releases_detailed(&self, no_caching: bool) -> Result<Vec<GithubRelease>, Box<dyn std::error::Error + Send + Sync>> {
self.api_client.fetch_zen_releases_with_caching(no_caching).await
}
async fn fetch_brave_versions(&self, no_caching: bool) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
let releases = self.fetch_brave_releases_detailed(no_caching).await?;
Ok(releases.into_iter().map(|r| r.tag_name).collect())
}
async fn fetch_brave_releases_detailed(&self, no_caching: bool) -> Result<Vec<GithubRelease>, Box<dyn std::error::Error + Send + Sync>> {
self.api_client.fetch_brave_releases_with_caching(no_caching).await
}
async fn fetch_chromium_versions(&self, no_caching: bool) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
let releases = self.fetch_chromium_releases_detailed(no_caching).await?;
Ok(releases.into_iter().map(|r| r.version).collect())
}
async fn fetch_chromium_releases_detailed(&self, no_caching: bool) -> Result<Vec<BrowserRelease>, Box<dyn std::error::Error + Send + Sync>> {
self.api_client.fetch_chromium_releases_with_caching(no_caching).await
}
async fn fetch_tor_versions(&self, no_caching: bool) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
let releases = self.fetch_tor_releases_detailed(no_caching).await?;
Ok(releases.into_iter().map(|r| r.version).collect())
}
async fn fetch_tor_releases_detailed(&self, no_caching: bool) -> Result<Vec<BrowserRelease>, Box<dyn std::error::Error + Send + Sync>> {
self.api_client.fetch_tor_releases_with_caching(no_caching).await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_browser_version_service_creation() {
let _service = BrowserVersionService::new();
// Test passes if we can create the service without panicking
assert!(true);
}
#[tokio::test]
async fn test_fetch_firefox_versions() {
let service = BrowserVersionService::new();
// Test with caching
let result_cached = service.fetch_browser_versions("firefox", false).await;
assert!(result_cached.is_ok(), "Should fetch Firefox versions with caching");
if let Ok(versions) = result_cached {
assert!(!versions.is_empty(), "Should have Firefox versions");
println!("Firefox cached test passed. Found {} versions", versions.len());
}
// Small delay to avoid rate limiting
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
// Test without caching
let result_no_cache = service.fetch_browser_versions("firefox", true).await;
assert!(result_no_cache.is_ok(), "Should fetch Firefox versions without caching");
if let Ok(versions) = result_no_cache {
assert!(!versions.is_empty(), "Should have Firefox versions without caching");
println!("Firefox no-cache test passed. Found {} versions", versions.len());
}
}
#[tokio::test]
async fn test_fetch_browser_versions_with_count() {
let service = BrowserVersionService::new();
let result = service.fetch_browser_versions_with_count("firefox", false).await;
assert!(result.is_ok(), "Should fetch Firefox versions with count");
if let Ok(result) = result {
assert!(!result.versions.is_empty(), "Should have versions");
assert_eq!(result.total_versions_count, result.versions.len(), "Total count should match versions length");
println!("Firefox count test passed. Found {} versions, new: {:?}",
result.total_versions_count, result.new_versions_count);
}
}
#[tokio::test]
async fn test_fetch_detailed_versions() {
let service = BrowserVersionService::new();
let result = service.fetch_browser_versions_detailed("firefox", false).await;
assert!(result.is_ok(), "Should fetch detailed Firefox versions");
if let Ok(versions) = result {
assert!(!versions.is_empty(), "Should have detailed versions");
// Check that the first version has all required fields
let first_version = &versions[0];
assert!(!first_version.version.is_empty(), "Version should not be empty");
println!("Firefox detailed test passed. Found {} detailed versions", versions.len());
}
}
#[tokio::test]
async fn test_unsupported_browser() {
let service = BrowserVersionService::new();
let result = service.fetch_browser_versions("unsupported", false).await;
assert!(result.is_err(), "Should return error for unsupported browser");
if let Err(e) = result {
assert!(e.to_string().contains("Unsupported browser"), "Error should mention unsupported browser");
}
}
#[tokio::test]
async fn test_incremental_update() {
let service = BrowserVersionService::new();
// This test might fail if there are no cached versions yet, which is fine
let result = service.update_browser_versions_incrementally("firefox").await;
// The test should complete without panicking
match result {
Ok(count) => {
println!("Incremental update test passed. Found {} new versions", count);
}
Err(e) => {
println!("Incremental update test failed (expected for first run): {}", e);
// Don't fail the test, as this is expected behavior for first run
}
}
}
#[tokio::test]
async fn test_all_supported_browsers() {
let service = BrowserVersionService::new();
let browsers = vec![
"firefox", "firefox-developer", "mullvad-browser",
"zen", "brave", "chromium", "tor-browser"
];
for browser in browsers {
// Test that we can at least call the function without panicking
let result = service.fetch_browser_versions(browser, false).await;
match result {
Ok(versions) => {
println!("{} test passed. Found {} versions", browser, versions.len());
}
Err(e) => {
// Some browsers might fail due to network issues, but shouldn't panic
println!("{} test failed (network issue): {}", browser, e);
}
}
// Small delay between requests to avoid rate limiting
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
}
}
#[tokio::test]
async fn test_no_caching_parameter() {
let service = BrowserVersionService::new();
// Test with caching enabled (default)
let result_cached = service.fetch_browser_versions("firefox", false).await;
assert!(result_cached.is_ok(), "Should fetch Firefox versions with caching");
// Small delay to avoid rate limiting
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
// Test with caching disabled (no_caching = true)
let result_no_cache = service.fetch_browser_versions("firefox", true).await;
assert!(result_no_cache.is_ok(), "Should fetch Firefox versions without caching");
// Both should return versions
if let (Ok(cached_versions), Ok(no_cache_versions)) = (result_cached, result_no_cache) {
assert!(!cached_versions.is_empty(), "Cached versions should not be empty");
assert!(!no_cache_versions.is_empty(), "No-cache versions should not be empty");
println!("No-caching test passed. Cached: {} versions, No-cache: {} versions",
cached_versions.len(), no_cache_versions.len());
}
}
#[tokio::test]
async fn test_detailed_versions_with_no_caching() {
let service = BrowserVersionService::new();
// Test detailed versions with caching
let result_cached = service.fetch_browser_versions_detailed("firefox", false).await;
assert!(result_cached.is_ok(), "Should fetch detailed Firefox versions with caching");
// Small delay to avoid rate limiting
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
// Test detailed versions without caching
let result_no_cache = service.fetch_browser_versions_detailed("firefox", true).await;
assert!(result_no_cache.is_ok(), "Should fetch detailed Firefox versions without caching");
// Both should return detailed version info
if let (Ok(cached_versions), Ok(no_cache_versions)) = (result_cached, result_no_cache) {
assert!(!cached_versions.is_empty(), "Cached detailed versions should not be empty");
assert!(!no_cache_versions.is_empty(), "No-cache detailed versions should not be empty");
// Check that detailed versions have all required fields
let first_cached = &cached_versions[0];
let first_no_cache = &no_cache_versions[0];
assert!(!first_cached.version.is_empty(), "Cached version should not be empty");
assert!(!first_no_cache.version.is_empty(), "No-cache version should not be empty");
println!("Detailed no-caching test passed. Cached: {} versions, No-cache: {} versions",
cached_versions.len(), no_cache_versions.len());
}
}
#[test]
fn test_get_download_info() {
let service = BrowserVersionService::new();
// Test Firefox
let firefox_info = service.get_download_info("firefox", "139.0").unwrap();
assert_eq!(firefox_info.filename, "firefox-139.0.dmg");
assert!(firefox_info.url.contains("firefox-139.0"));
assert!(firefox_info.is_archive);
// Test Firefox Developer
let firefox_dev_info = service.get_download_info("firefox-developer", "139.0b1").unwrap();
assert_eq!(firefox_dev_info.filename, "firefox-developer-139.0b1.dmg");
assert!(firefox_dev_info.url.contains("devedition-139.0b1"));
assert!(firefox_dev_info.is_archive);
// Test Mullvad Browser
let mullvad_info = service.get_download_info("mullvad-browser", "14.5a6").unwrap();
assert_eq!(mullvad_info.filename, "mullvad-browser-14.5a6.dmg");
assert!(mullvad_info.url.contains("mullvad-browser-macos-14.5a6"));
assert!(mullvad_info.is_archive);
// Test Zen Browser
let zen_info = service.get_download_info("zen", "1.11b").unwrap();
assert_eq!(zen_info.filename, "zen-1.11b.dmg");
assert!(zen_info.url.contains("zen.macos-universal.dmg"));
assert!(zen_info.is_archive);
// Test Tor Browser
let tor_info = service.get_download_info("tor-browser", "14.0.4").unwrap();
assert_eq!(tor_info.filename, "tor-browser-14.0.4.dmg");
assert!(tor_info.url.contains("tor-browser-macos-14.0.4"));
assert!(tor_info.is_archive);
// Test Chromium
let chromium_info = service.get_download_info("chromium", "1465660").unwrap();
assert_eq!(chromium_info.filename, "chromium-1465660.zip");
assert!(chromium_info.url.contains("chrome-mac.zip"));
assert!(chromium_info.is_archive);
// Test Brave
let brave_info = service.get_download_info("brave", "v1.81.9").unwrap();
assert_eq!(brave_info.filename, "brave-v1.81.9.dmg");
assert!(brave_info.url.contains("Brave-Browser"));
assert!(brave_info.is_archive);
// Test unsupported browser
let unsupported_result = service.get_download_info("unsupported", "1.0.0");
assert!(unsupported_result.is_err());
println!("Download info test passed for all browsers");
}
}
+169
View File
@@ -0,0 +1,169 @@
use tauri::command;
#[cfg(target_os = "macos")]
mod macos {
use core_foundation::base::OSStatus;
use core_foundation::string::CFStringRef;
use core_foundation::{
base::TCFType,
string::CFString,
};
#[link(name = "CoreServices", kind = "framework")]
extern "C" {
fn LSSetDefaultHandlerForURLScheme(scheme: CFStringRef, bundle_id: CFStringRef) -> OSStatus;
fn LSCopyDefaultHandlerForURLScheme(scheme: CFStringRef) -> CFStringRef;
}
pub fn is_default_browser() -> Result<bool, String> {
let schemes = ["http", "https"];
let bundle_id = "com.donutbrowser";
for scheme in schemes {
let scheme_str = CFString::new(scheme);
unsafe {
let current_handler = LSCopyDefaultHandlerForURLScheme(scheme_str.as_concrete_TypeRef());
if current_handler.is_null() {
return Ok(false);
}
let current_handler_cf = CFString::wrap_under_create_rule(current_handler);
let current_handler_str = current_handler_cf.to_string();
if current_handler_str != bundle_id {
return Ok(false);
}
}
}
Ok(true)
}
pub fn set_as_default_browser() -> Result<(), String> {
let bundle_id = CFString::new("com.donutbrowser");
let schemes = ["http", "https"];
for scheme in schemes {
let scheme_str = CFString::new(scheme);
unsafe {
let status = LSSetDefaultHandlerForURLScheme(
scheme_str.as_concrete_TypeRef(),
bundle_id.as_concrete_TypeRef(),
);
if status != 0 {
let error_msg = match status {
-54 => format!(
"Failed to set as default browser for scheme '{}'. The app is not properly registered as a browser. Please:\n1. Build and install the app properly\n2. Manually set Donut Browser as default in System Settings > General > Default web browser\n3. Make sure the app is in your Applications folder",
scheme
),
_ => format!(
"Failed to set as default browser for scheme '{}'. Status code: {}. Please manually set Donut Browser as default in System Settings > General > Default web browser.",
scheme, status
)
};
return Err(error_msg);
}
}
}
Ok(())
}
}
#[cfg(target_os = "windows")]
mod windows {
pub fn is_default_browser() -> Result<bool, String> {
// Windows implementation would go here
Err("Windows support not implemented yet".to_string())
}
pub fn set_as_default_browser() -> Result<(), String> {
Err("Windows support not implemented yet".to_string())
}
}
#[cfg(target_os = "linux")]
mod linux {
pub fn is_default_browser() -> Result<bool, String> {
// Linux implementation would go here
Err("Linux support not implemented yet".to_string())
}
pub fn set_as_default_browser() -> Result<(), String> {
Err("Linux support not implemented yet".to_string())
}
}
#[command]
pub async fn is_default_browser() -> Result<bool, String> {
#[cfg(target_os = "macos")]
return macos::is_default_browser();
#[cfg(target_os = "windows")]
return windows::is_default_browser();
#[cfg(target_os = "linux")]
return linux::is_default_browser();
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
Err("Unsupported platform".to_string())
}
#[command]
pub async fn set_as_default_browser() -> Result<(), String> {
#[cfg(target_os = "macos")]
return macos::set_as_default_browser();
#[cfg(target_os = "windows")]
return windows::set_as_default_browser();
#[cfg(target_os = "linux")]
return linux::set_as_default_browser();
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
Err("Unsupported platform".to_string())
}
#[tauri::command]
pub async fn open_url_with_profile(app_handle: tauri::AppHandle, profile_name: String, url: String) -> Result<(), String> {
use crate::browser_runner::BrowserRunner;
let runner = BrowserRunner::new();
// Get the profile by name
let profiles = runner.list_profiles().map_err(|e| format!("Failed to list profiles: {}", e))?;
let profile = profiles.into_iter()
.find(|p| p.name == profile_name)
.ok_or_else(|| format!("Profile '{}' not found", profile_name))?;
println!("Opening URL '{}' with profile '{}'", url, profile_name);
// Use launch_or_open_url which handles both launching new instances and opening in existing ones
runner
.launch_or_open_url(app_handle, &profile, Some(url.clone()))
.await
.map_err(|e| {
println!("Failed to open URL with profile '{}': {}", profile_name, e);
format!("Failed to open URL with profile: {}", e)
})?;
println!("Successfully opened URL '{}' with profile '{}'", url, profile_name);
Ok(())
}
#[tauri::command]
pub async fn smart_open_url(_app_handle: tauri::AppHandle, _url: String, _is_startup: Option<bool>) -> Result<String, String> {
use crate::browser_runner::BrowserRunner;
let runner = BrowserRunner::new();
// Get all profiles
let profiles = runner.list_profiles().map_err(|e| format!("Failed to list profiles: {}", e))?;
if profiles.is_empty() {
return Err("no_profiles".to_string());
}
println!("URL opening - Total profiles: {}, showing profile selector", profiles.len());
// Always show the profile selector so the user can choose
Err("show_selector".to_string())
}
+368
View File
@@ -0,0 +1,368 @@
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::fs::File;
use std::io;
use std::path::{Path, PathBuf};
use tauri::Emitter;
use crate::api_client::ApiClient;
use crate::browser::BrowserType;
use crate::browser_version_service::DownloadInfo;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DownloadProgress {
pub browser: String,
pub version: String,
pub downloaded_bytes: u64,
pub total_bytes: Option<u64>,
pub percentage: f64,
pub speed_bytes_per_sec: f64,
pub eta_seconds: Option<f64>,
pub stage: String, // "downloading", "extracting", "verifying"
}
pub struct Downloader {
client: Client,
api_client: ApiClient,
}
impl Downloader {
pub fn new() -> Self {
Self {
client: Client::new(),
api_client: ApiClient::new(),
}
}
/// Resolve the actual download URL for browsers that need dynamic asset resolution
pub async fn resolve_download_url(
&self,
browser_type: BrowserType,
version: &str,
download_info: &DownloadInfo,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
match browser_type {
BrowserType::Brave => {
// For Brave, we need to find the actual macOS asset
let releases = self.api_client.fetch_brave_releases().await?;
// Find the release with the matching version
let release = releases
.iter()
.find(|r| r.tag_name == version || r.tag_name == format!("v{}", version.trim_start_matches('v')))
.ok_or(format!("Brave version {} not found", version))?;
// Find the universal macOS DMG asset
let asset = release
.assets
.iter()
.find(|asset| {
asset.name.contains(".dmg") && asset.name.contains("universal")
})
.ok_or(format!("No universal macOS DMG asset found for Brave version {}", version))?;
Ok(asset.browser_download_url.clone())
}
BrowserType::Zen => {
// For Zen, verify the asset exists
let releases = self.api_client.fetch_zen_releases().await?;
let release = releases
.iter()
.find(|r| r.tag_name == version)
.ok_or(format!("Zen version {} not found", version))?;
// Find the macOS universal DMG asset
let asset = release
.assets
.iter()
.find(|asset| asset.name == "zen.macos-universal.dmg")
.ok_or(format!("No macOS universal asset found for Zen version {}", version))?;
Ok(asset.browser_download_url.clone())
}
BrowserType::MullvadBrowser => {
// For Mullvad, verify the asset exists
let releases = self.api_client.fetch_mullvad_releases().await?;
let release = releases
.iter()
.find(|r| r.tag_name == version)
.ok_or(format!("Mullvad version {} not found", version))?;
// Find the macOS DMG asset
let asset = release
.assets
.iter()
.find(|asset| {
asset.name.contains(".dmg") && asset.name.contains("mac")
})
.ok_or(format!("No macOS asset found for Mullvad version {}", version))?;
Ok(asset.browser_download_url.clone())
}
_ => {
// For other browsers, use the provided URL
Ok(download_info.url.clone())
}
}
}
pub async fn download_browser(
&self,
app_handle: &tauri::AppHandle,
browser_type: BrowserType,
version: &str,
download_info: &DownloadInfo,
dest_path: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
let file_path = dest_path.join(&download_info.filename);
// Resolve the actual download URL
let download_url = self.resolve_download_url(browser_type.clone(), version, download_info).await?;
// Emit initial progress
let progress = DownloadProgress {
browser: browser_type.as_str().to_string(),
version: version.to_string(),
downloaded_bytes: 0,
total_bytes: None,
percentage: 0.0,
speed_bytes_per_sec: 0.0,
eta_seconds: None,
stage: "downloading".to_string(),
};
let _ = app_handle.emit("download-progress", &progress);
// Start download
let response = self
.client
.get(&download_url)
.header("User-Agent", "donutbrowser")
.send()
.await?;
let total_size = response.content_length();
let mut downloaded = 0u64;
let start_time = std::time::Instant::now();
let mut last_update = start_time;
let mut file = File::create(&file_path)?;
let mut stream = response.bytes_stream();
use futures_util::StreamExt;
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
io::copy(&mut chunk.as_ref(), &mut file)?;
downloaded += chunk.len() as u64;
let now = std::time::Instant::now();
// Update progress every 100ms to avoid too many events
if now.duration_since(last_update).as_millis() >= 100 {
let elapsed = start_time.elapsed().as_secs_f64();
let speed = if elapsed > 0.0 { downloaded as f64 / elapsed } else { 0.0 };
let percentage = if let Some(total) = total_size {
(downloaded as f64 / total as f64) * 100.0
} else {
0.0
};
let eta = if speed > 0.0 && total_size.is_some() {
Some((total_size.unwrap() - downloaded) as f64 / speed)
} else {
None
};
let progress = DownloadProgress {
browser: browser_type.as_str().to_string(),
version: version.to_string(),
downloaded_bytes: downloaded,
total_bytes: total_size,
percentage,
speed_bytes_per_sec: speed,
eta_seconds: eta,
stage: "downloading".to_string(),
};
let _ = app_handle.emit("download-progress", &progress);
last_update = now;
}
}
Ok(file_path)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio;
#[tokio::test]
async fn test_resolve_brave_download_url() {
let downloader = Downloader::new();
// Test with a known Brave version
let download_info = DownloadInfo {
url: "placeholder".to_string(),
filename: "brave-test.dmg".to_string(),
is_archive: true,
};
let result = downloader.resolve_download_url(
BrowserType::Brave,
"v1.81.9",
&download_info
).await;
match result {
Ok(url) => {
assert!(url.contains("github.com/brave/brave-browser"));
assert!(url.contains(".dmg"));
assert!(url.contains("universal"));
println!("Brave download URL resolved: {}", url);
}
Err(e) => {
println!("Brave URL resolution failed (expected if version doesn't exist): {}", e);
// This might fail if the version doesn't exist, which is okay for testing
}
}
}
#[tokio::test]
async fn test_resolve_zen_download_url() {
let downloader = Downloader::new();
let download_info = DownloadInfo {
url: "placeholder".to_string(),
filename: "zen-test.dmg".to_string(),
is_archive: true,
};
let result = downloader.resolve_download_url(
BrowserType::Zen,
"1.11b",
&download_info
).await;
match result {
Ok(url) => {
assert!(url.contains("github.com/zen-browser/desktop"));
assert!(url.contains("zen.macos-universal.dmg"));
println!("Zen download URL resolved: {}", url);
}
Err(e) => {
println!("Zen URL resolution failed (expected if version doesn't exist): {}", e);
}
}
}
#[tokio::test]
async fn test_resolve_mullvad_download_url() {
let downloader = Downloader::new();
let download_info = DownloadInfo {
url: "placeholder".to_string(),
filename: "mullvad-test.dmg".to_string(),
is_archive: true,
};
let result = downloader.resolve_download_url(
BrowserType::MullvadBrowser,
"14.5a6",
&download_info
).await;
match result {
Ok(url) => {
assert!(url.contains("github.com/mullvad/mullvad-browser"));
assert!(url.contains(".dmg"));
println!("Mullvad download URL resolved: {}", url);
}
Err(e) => {
println!("Mullvad URL resolution failed (expected if version doesn't exist): {}", e);
}
}
}
#[tokio::test]
async fn test_resolve_firefox_download_url() {
let downloader = Downloader::new();
let download_info = DownloadInfo {
url: "https://download.mozilla.org/?product=firefox-139.0&os=osx&lang=en-US".to_string(),
filename: "firefox-test.dmg".to_string(),
is_archive: true,
};
let result = downloader.resolve_download_url(
BrowserType::Firefox,
"139.0",
&download_info
).await;
match result {
Ok(url) => {
assert_eq!(url, download_info.url);
println!("Firefox download URL (passthrough): {}", url);
}
Err(e) => {
panic!("Firefox URL resolution should not fail: {}", e);
}
}
}
#[tokio::test]
async fn test_resolve_chromium_download_url() {
let downloader = Downloader::new();
let download_info = DownloadInfo {
url: "https://commondatastorage.googleapis.com/chromium-browser-snapshots/Mac/1465660/chrome-mac.zip".to_string(),
filename: "chromium-test.zip".to_string(),
is_archive: true,
};
let result = downloader.resolve_download_url(
BrowserType::Chromium,
"1465660",
&download_info
).await;
match result {
Ok(url) => {
assert_eq!(url, download_info.url);
println!("Chromium download URL (passthrough): {}", url);
}
Err(e) => {
panic!("Chromium URL resolution should not fail: {}", e);
}
}
}
#[tokio::test]
async fn test_resolve_tor_download_url() {
let downloader = Downloader::new();
let download_info = DownloadInfo {
url: "https://archive.torproject.org/tor-package-archive/torbrowser/14.0.4/tor-browser-macos-14.0.4.dmg".to_string(),
filename: "tor-test.dmg".to_string(),
is_archive: true,
};
let result = downloader.resolve_download_url(
BrowserType::TorBrowser,
"14.0.4",
&download_info
).await;
match result {
Ok(url) => {
assert_eq!(url, download_info.url);
println!("TOR download URL (passthrough): {}", url);
}
Err(e) => {
panic!("TOR URL resolution should not fail: {}", e);
}
}
}
}
+258
View File
@@ -0,0 +1,258 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use directories::BaseDirs;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DownloadedBrowserInfo {
pub browser: String,
pub version: String,
pub download_date: u64,
pub file_path: PathBuf,
pub verified: bool,
pub actual_version: Option<String>, // For browsers like Chromium where we track the actual version
}
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct DownloadedBrowsersRegistry {
pub browsers: HashMap<String, HashMap<String, DownloadedBrowserInfo>>, // browser -> version -> info
}
impl DownloadedBrowsersRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
let registry_path = Self::get_registry_path()?;
if !registry_path.exists() {
return Ok(Self::new());
}
let content = fs::read_to_string(&registry_path)?;
let registry: DownloadedBrowsersRegistry = serde_json::from_str(&content)?;
Ok(registry)
}
pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
let registry_path = Self::get_registry_path()?;
// Ensure parent directory exists
if let Some(parent) = registry_path.parent() {
fs::create_dir_all(parent)?;
}
let content = serde_json::to_string_pretty(self)?;
fs::write(&registry_path, content)?;
Ok(())
}
fn get_registry_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
let base_dirs = BaseDirs::new().ok_or("Failed to get base directories")?;
let mut path = base_dirs.data_local_dir().to_path_buf();
path.push(if cfg!(debug_assertions) { "DonutBrowserDev" } else { "DonutBrowser" });
path.push("data");
path.push("downloaded_browsers.json");
Ok(path)
}
pub fn add_browser(&mut self, info: DownloadedBrowserInfo) {
self.browsers
.entry(info.browser.clone())
.or_insert_with(HashMap::new)
.insert(info.version.clone(), info);
}
pub fn remove_browser(&mut self, browser: &str, version: &str) -> Option<DownloadedBrowserInfo> {
self.browsers
.get_mut(browser)?
.remove(version)
}
pub fn is_browser_downloaded(&self, browser: &str, version: &str) -> bool {
self.browsers
.get(browser)
.and_then(|versions| versions.get(version))
.map(|info| info.verified)
.unwrap_or(false)
}
pub fn get_downloaded_versions(&self, browser: &str) -> Vec<String> {
self.browsers
.get(browser)
.map(|versions| {
versions
.iter()
.filter(|(_, info)| info.verified)
.map(|(version, _)| version.clone())
.collect()
})
.unwrap_or_default()
}
pub fn mark_download_started(&mut self, browser: &str, version: &str, file_path: PathBuf) {
let info = DownloadedBrowserInfo {
browser: browser.to_string(),
version: version.to_string(),
download_date: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
file_path,
verified: false,
actual_version: None,
};
self.add_browser(info);
}
pub fn mark_download_completed_with_actual_version(&mut self, browser: &str, version: &str, actual_version: Option<String>) -> Result<(), String> {
if let Some(info) = self.browsers
.get_mut(browser)
.and_then(|versions| versions.get_mut(version))
{
info.verified = true;
info.actual_version = actual_version;
Ok(())
} else {
Err(format!("Browser {}:{} not found in registry", browser, version))
}
}
pub fn cleanup_failed_download(&mut self, browser: &str, version: &str) -> Result<(), Box<dyn std::error::Error>> {
if let Some(info) = self.remove_browser(browser, version) {
// Clean up any files that might have been left behind
if info.file_path.exists() {
if info.file_path.is_dir() {
fs::remove_dir_all(&info.file_path)?;
} else {
fs::remove_file(&info.file_path)?;
}
}
// Also clean up the browser directory if it exists
let base_dirs = BaseDirs::new().ok_or("Failed to get base directories")?;
let mut browser_dir = base_dirs.data_local_dir().to_path_buf();
browser_dir.push(if cfg!(debug_assertions) { "DonutBrowserDev" } else { "DonutBrowser" });
browser_dir.push("binaries");
browser_dir.push(browser);
browser_dir.push(version);
if browser_dir.exists() {
fs::remove_dir_all(&browser_dir)?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_registry_creation() {
let registry = DownloadedBrowsersRegistry::new();
assert!(registry.browsers.is_empty());
}
#[test]
fn test_add_and_get_browser() {
let mut registry = DownloadedBrowsersRegistry::new();
let info = DownloadedBrowserInfo {
browser: "firefox".to_string(),
version: "139.0".to_string(),
download_date: 1234567890,
file_path: PathBuf::from("/test/path"),
verified: true,
actual_version: None,
};
registry.add_browser(info.clone());
assert!(registry.is_browser_downloaded("firefox", "139.0"));
assert!(!registry.is_browser_downloaded("firefox", "140.0"));
assert!(!registry.is_browser_downloaded("chrome", "139.0"));
}
#[test]
fn test_get_downloaded_versions() {
let mut registry = DownloadedBrowsersRegistry::new();
let info1 = DownloadedBrowserInfo {
browser: "firefox".to_string(),
version: "139.0".to_string(),
download_date: 1234567890,
file_path: PathBuf::from("/test/path1"),
verified: true,
actual_version: None,
};
let info2 = DownloadedBrowserInfo {
browser: "firefox".to_string(),
version: "140.0".to_string(),
download_date: 1234567891,
file_path: PathBuf::from("/test/path2"),
verified: false, // Not verified, should not be included
actual_version: None,
};
let info3 = DownloadedBrowserInfo {
browser: "firefox".to_string(),
version: "141.0".to_string(),
download_date: 1234567892,
file_path: PathBuf::from("/test/path3"),
verified: true,
actual_version: None,
};
registry.add_browser(info1);
registry.add_browser(info2);
registry.add_browser(info3);
let versions = registry.get_downloaded_versions("firefox");
assert_eq!(versions.len(), 2);
assert!(versions.contains(&"139.0".to_string()));
assert!(versions.contains(&"141.0".to_string()));
assert!(!versions.contains(&"140.0".to_string()));
}
#[test]
fn test_mark_download_lifecycle() {
let mut registry = DownloadedBrowsersRegistry::new();
// Mark download started
registry.mark_download_started("firefox", "139.0", PathBuf::from("/test/path"));
// Should not be considered downloaded yet
assert!(!registry.is_browser_downloaded("firefox", "139.0"));
// Mark as completed
registry.mark_download_completed_with_actual_version("firefox", "139.0", Some("139.0".to_string())).unwrap();
// Now should be considered downloaded
assert!(registry.is_browser_downloaded("firefox", "139.0"));
}
#[test]
fn test_remove_browser() {
let mut registry = DownloadedBrowsersRegistry::new();
let info = DownloadedBrowserInfo {
browser: "firefox".to_string(),
version: "139.0".to_string(),
download_date: 1234567890,
file_path: PathBuf::from("/test/path"),
verified: true,
actual_version: None,
};
registry.add_browser(info);
assert!(registry.is_browser_downloaded("firefox", "139.0"));
let removed = registry.remove_browser("firefox", "139.0");
assert!(removed.is_some());
assert!(!registry.is_browser_downloaded("firefox", "139.0"));
}
}
+374
View File
@@ -0,0 +1,374 @@
use std::fs::{self, create_dir_all};
use std::path::{Path, PathBuf};
use std::process::Command;
use tauri::Emitter;
use crate::download::DownloadProgress;
use crate::browser::BrowserType;
pub struct Extractor;
impl Extractor {
pub fn new() -> Self {
Self
}
pub async fn extract_browser(
&self,
app_handle: &tauri::AppHandle,
browser_type: BrowserType,
version: &str,
archive_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// Emit extraction start progress
let progress = DownloadProgress {
browser: browser_type.as_str().to_string(),
version: version.to_string(),
downloaded_bytes: 0,
total_bytes: None,
percentage: 0.0,
speed_bytes_per_sec: 0.0,
eta_seconds: None,
stage: "extracting".to_string(),
};
let _ = app_handle.emit("download-progress", &progress);
let extension = archive_path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("");
match extension {
"dmg" => self.extract_dmg(archive_path, dest_dir).await,
"zip" => self.extract_zip(archive_path, dest_dir).await,
_ => Err(format!("Unsupported archive format: {}", extension).into()),
}
}
async fn extract_dmg(
&self,
dmg_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// Create a temporary mount point
let mount_point = std::env::temp_dir().join(format!(
"donut_mount_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
));
create_dir_all(&mount_point)?;
// Mount the DMG
let output = Command::new("hdiutil")
.args([
"attach",
"-nobrowse",
"-mountpoint",
mount_point.to_str().unwrap(),
dmg_path.to_str().unwrap(),
])
.output()?;
if !output.status.success() {
return Err(
format!(
"Failed to mount DMG: {}",
String::from_utf8_lossy(&output.stderr)
)
.into(),
);
}
// Find the .app directory in the mount point
let app_entry = fs::read_dir(&mount_point)?
.filter_map(Result::ok)
.find(|entry| entry.path().extension().map_or(false, |ext| ext == "app"))
.ok_or("No .app found in DMG")?;
// Copy the .app to the destination
let app_path = dest_dir.join(app_entry.file_name());
let output = Command::new("cp")
.args([
"-R",
app_entry.path().to_str().unwrap(),
app_path.to_str().unwrap(),
])
.output()?;
if !output.status.success() {
return Err(
format!(
"Failed to copy app: {}",
String::from_utf8_lossy(&output.stderr)
)
.into(),
);
}
// Remove quarantine attributes
let _ = Command::new("xattr")
.args(["-dr", "com.apple.quarantine", app_path.to_str().unwrap()])
.output();
let _ = Command::new("xattr")
.args(["-cr", app_path.to_str().unwrap()])
.output();
// Try to unmount the DMG with retries
let mut retry_count = 0;
let max_retries = 3;
let mut unmounted = false;
while retry_count < max_retries && !unmounted {
// Wait a bit before trying to unmount
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
let output = Command::new("hdiutil")
.args(["detach", mount_point.to_str().unwrap()])
.output()?;
if output.status.success() {
unmounted = true;
} else if retry_count == max_retries - 1 {
// Force unmount on last retry
let _ = Command::new("hdiutil")
.args(["detach", "-force", mount_point.to_str().unwrap()])
.output();
unmounted = true; // Consider it unmounted even if force fails
}
retry_count += 1;
}
// Clean up mount point directory
let _ = fs::remove_dir_all(&mount_point);
Ok(app_path)
}
async fn extract_zip(
&self,
zip_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// Use unzip command to extract
let output = Command::new("unzip")
.args([
"-q", // quiet
zip_path.to_str().unwrap(),
"-d",
dest_dir.to_str().unwrap(),
])
.output()?;
if !output.status.success() {
return Err(
format!(
"Failed to extract zip: {}",
String::from_utf8_lossy(&output.stderr)
)
.into(),
);
}
// Find the extracted .app directory or Chromium.app specifically
let mut app_path: Option<PathBuf> = None;
// First, try to find any .app file in the destination directory
if let Ok(entries) = fs::read_dir(dest_dir) {
for entry in entries {
if let Ok(entry) = entry {
let path = entry.path();
if path.extension().map_or(false, |ext| ext == "app") {
app_path = Some(path);
break;
}
// For Chromium, check subdirectories (chrome-mac folder)
if path.is_dir() {
if let Ok(sub_entries) = fs::read_dir(&path) {
for sub_entry in sub_entries {
if let Ok(sub_entry) = sub_entry {
let sub_path = sub_entry.path();
if sub_path.extension().map_or(false, |ext| ext == "app") {
// Move the app to the root destination directory
let target_path = dest_dir.join(sub_path.file_name().unwrap());
fs::rename(&sub_path, &target_path)?;
app_path = Some(target_path);
// Clean up the now-empty subdirectory
let _ = fs::remove_dir_all(&path);
break;
}
}
}
if app_path.is_some() {
break;
}
}
}
}
}
}
let app_path = app_path.ok_or("No .app found after extraction")?;
// Remove quarantine attributes
let _ = Command::new("xattr")
.args(["-dr", "com.apple.quarantine", app_path.to_str().unwrap()])
.output();
let _ = Command::new("xattr")
.args(["-cr", app_path.to_str().unwrap()])
.output();
Ok(app_path)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use tempfile::TempDir;
#[test]
fn test_extractor_creation() {
let _extractor = Extractor::new();
// Just verify we can create an extractor instance
assert!(true);
}
#[test]
fn test_unsupported_archive_format() {
let _extractor = Extractor::new();
let temp_dir = TempDir::new().unwrap();
let fake_archive = temp_dir.path().join("test.rar");
File::create(&fake_archive).unwrap();
// Create a mock app handle (this won't work in real tests without Tauri runtime)
// For now, we'll just test the logic without the actual extraction
// Test that unsupported formats return an error
let extension = fake_archive
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("");
assert_eq!(extension, "rar");
// We know this would fail with "Unsupported archive format: rar"
}
#[test]
fn test_dmg_path_validation() {
let temp_dir = TempDir::new().unwrap();
let dmg_path = temp_dir.path().join("test.dmg");
// Test that we can identify DMG files correctly
let extension = dmg_path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("");
assert_eq!(extension, "dmg");
}
#[test]
fn test_zip_path_validation() {
let temp_dir = TempDir::new().unwrap();
let zip_path = temp_dir.path().join("test.zip");
// Test that we can identify ZIP files correctly
let extension = zip_path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("");
assert_eq!(extension, "zip");
}
#[test]
fn test_mount_point_generation() {
// Test that mount point generation creates unique paths
let mount_point1 = std::env::temp_dir().join(format!(
"donut_mount_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
));
std::thread::sleep(std::time::Duration::from_millis(10));
let mount_point2 = std::env::temp_dir().join(format!(
"donut_mount_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
));
// They should be different (or at least have the potential to be)
assert!(mount_point1.to_string_lossy().contains("donut_mount_"));
assert!(mount_point2.to_string_lossy().contains("donut_mount_"));
}
#[test]
fn test_app_path_detection() {
let temp_dir = TempDir::new().unwrap();
// Create a fake .app directory
let app_dir = temp_dir.path().join("TestApp.app");
std::fs::create_dir_all(&app_dir).unwrap();
// Test finding .app directories
let entries: Vec<_> = fs::read_dir(temp_dir.path())
.unwrap()
.filter_map(Result::ok)
.filter(|entry| entry.path().extension().map_or(false, |ext| ext == "app"))
.collect();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].file_name(), "TestApp.app");
}
#[test]
fn test_nested_app_detection() {
let temp_dir = TempDir::new().unwrap();
// Create a nested structure like Chromium
let chrome_dir = temp_dir.path().join("chrome-mac");
std::fs::create_dir_all(&chrome_dir).unwrap();
let app_dir = chrome_dir.join("Chromium.app");
std::fs::create_dir_all(&app_dir).unwrap();
// Test finding nested .app directories
let mut found_app = false;
if let Ok(entries) = fs::read_dir(temp_dir.path()) {
for entry in entries {
if let Ok(entry) = entry {
let path = entry.path();
if path.is_dir() {
if let Ok(sub_entries) = fs::read_dir(&path) {
for sub_entry in sub_entries {
if let Ok(sub_entry) = sub_entry {
let sub_path = sub_entry.path();
if sub_path.extension().map_or(false, |ext| ext == "app") {
found_app = true;
break;
}
}
}
}
}
}
}
}
assert!(found_app);
}
}
+225
View File
@@ -0,0 +1,225 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::{Manager, Emitter};
use tauri_plugin_deep_link::DeepLinkExt;
// Store pending URLs that need to be handled when the window is ready
static PENDING_URLS: Mutex<Vec<String>> = Mutex::new(Vec::new());
mod api_client;
mod auto_updater;
mod browser;
mod browser_runner;
mod browser_version_service;
mod default_browser;
mod download;
mod downloaded_browsers;
mod extraction;
mod proxy_manager;
mod settings_manager;
mod version_updater;
extern crate lazy_static;
use browser_runner::{
check_browser_status, create_browser_profile, create_browser_profile_new, delete_profile,
download_browser, fetch_browser_versions, fetch_browser_versions_cached_first,
fetch_browser_versions_detailed, fetch_browser_versions_with_count, fetch_browser_versions_with_count_cached_first,
get_cached_browser_versions_detailed, get_downloaded_browser_versions, get_saved_mullvad_releases, get_supported_browsers, is_browser_downloaded, check_browser_exists,
kill_browser_profile, launch_browser_profile, list_browser_profiles, rename_profile, should_update_browser_cache, update_profile_proxy,
update_profile_version,
};
use settings_manager::{
disable_default_browser_prompt, get_app_settings, save_app_settings,
should_show_settings_on_startup, get_table_sorting_settings, save_table_sorting_settings,
};
use default_browser::{is_default_browser, open_url_with_profile, set_as_default_browser, smart_open_url};
use version_updater::{trigger_manual_version_update, get_version_update_status, get_version_updater, check_version_update_needed, force_version_update_check};
use auto_updater::{
check_for_browser_updates, start_browser_update, complete_browser_update,
is_browser_disabled_for_update, dismiss_update_notification,
complete_browser_update_with_auto_update,
mark_auto_update_download, remove_auto_update_download, is_auto_update_download,
};
#[tauri::command]
fn greet() -> String {
let now = SystemTime::now();
let epoch_ms = now.duration_since(UNIX_EPOCH).unwrap().as_millis();
format!("Hello world from Rust! Current epoch: {}", epoch_ms)
}
#[tauri::command]
async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), String> {
println!("handle_url_open called with URL: {}", url);
// Check if the main window exists and is ready
if let Some(window) = app.get_webview_window("main") {
if window.is_visible().unwrap_or(false) {
// Window is visible, emit event directly
println!("Main window is visible, emitting show-profile-selector event");
app.emit("show-profile-selector", url.clone())
.map_err(|e| format!("Failed to emit URL open event: {}", e))?;
let _ = window.show();
let _ = window.set_focus();
} else {
// Window not visible yet - add to pending URLs
println!("Main window not visible, adding URL to pending list");
let mut pending = PENDING_URLS.lock().unwrap();
pending.push(url);
}
} else {
// Window doesn't exist yet - add to pending URLs
println!("Main window doesn't exist, adding URL to pending list");
let mut pending = PENDING_URLS.lock().unwrap();
pending.push(url);
}
Ok(())
}
#[tauri::command]
async fn check_and_handle_startup_url(app_handle: tauri::AppHandle) -> Result<bool, String> {
let pending_urls = {
let mut pending = PENDING_URLS.lock().unwrap();
let urls = pending.clone();
pending.clear(); // Clear after getting them
urls
};
if !pending_urls.is_empty() {
println!("Handling {} pending URLs from frontend request", pending_urls.len());
for url in pending_urls {
println!("Emitting show-profile-selector event for URL: {}", url);
if let Err(e) = app_handle.emit("show-profile-selector", url.clone()) {
eprintln!("Failed to emit URL event: {}", e);
return Err(format!("Failed to emit URL event: {}", e));
}
}
return Ok(true);
}
Ok(false)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_deep_link::init())
.setup(|app| {
// Set up deep link handler
let handle = app.handle().clone();
#[cfg(any(windows, target_os = "linux"))]
{
// For Windows and Linux, register all deep links at runtime for development
app.deep_link().register_all()?;
}
// Handle deep links - this works for both scenarios:
// 1. App is running and URL is opened
// 2. App is not running and URL causes app to launch
app.deep_link().on_open_url({
let handle = handle.clone();
move |event| {
let urls = event.urls();
for url in urls {
let url_string = url.to_string();
println!("Deep link received: {}", url_string);
// Clone the handle for each async task
let handle_clone = handle.clone();
// Handle the URL asynchronously
tauri::async_runtime::spawn(async move {
if let Err(e) = handle_url_open(handle_clone, url_string.clone()).await {
eprintln!("Failed to handle deep link URL: {}", e);
}
});
}
}
});
// Initialize and start background version updater
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
let version_updater = get_version_updater();
let mut updater_guard = version_updater.lock().await;
// Set the app handle
updater_guard.set_app_handle(app_handle).await;
// Start the background updates
updater_guard.start_background_updates().await;
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
greet,
get_supported_browsers,
download_browser,
delete_profile,
is_browser_downloaded,
check_browser_exists,
create_browser_profile_new,
create_browser_profile, // Keep for backward compatibility
list_browser_profiles,
launch_browser_profile,
fetch_browser_versions,
fetch_browser_versions_detailed,
fetch_browser_versions_with_count,
fetch_browser_versions_cached_first,
fetch_browser_versions_with_count_cached_first,
get_cached_browser_versions_detailed,
should_update_browser_cache,
get_downloaded_browser_versions,
get_saved_mullvad_releases,
update_profile_proxy,
update_profile_version,
check_browser_status,
kill_browser_profile,
rename_profile,
// Settings commands
get_app_settings,
save_app_settings,
should_show_settings_on_startup,
disable_default_browser_prompt,
get_table_sorting_settings,
save_table_sorting_settings,
// Default browser commands
is_default_browser,
open_url_with_profile,
set_as_default_browser,
smart_open_url,
handle_url_open,
check_and_handle_startup_url,
// Version update commands
trigger_manual_version_update,
get_version_update_status,
check_version_update_needed,
force_version_update_check,
// Auto-update commands
check_for_browser_updates,
start_browser_update,
complete_browser_update,
is_browser_disabled_for_update,
dismiss_update_notification,
complete_browser_update_with_auto_update,
mark_auto_update_download,
remove_auto_update_download,
is_auto_update_download,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
donutbrowser::run()
}
+189
View File
@@ -0,0 +1,189 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Mutex;
use tauri_plugin_shell::ShellExt;
use crate::browser::ProxySettings;
// Store active proxy information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProxyInfo {
pub id: String,
pub local_url: String,
pub upstream_url: String,
pub local_port: u16,
}
// Global proxy manager to track active proxies
pub struct ProxyManager {
active_proxies: Mutex<HashMap<u32, ProxyInfo>>, // Maps browser process ID to proxy info
// Store proxy info by profile name for persistence across browser restarts
profile_proxies: Mutex<HashMap<String, (String, u16)>>, // Maps profile name to (upstream_url, port)
}
impl ProxyManager {
pub fn new() -> Self {
Self {
active_proxies: Mutex::new(HashMap::new()),
profile_proxies: Mutex::new(HashMap::new()),
}
}
// Start a proxy for a given upstream URL and associate it with a browser process ID
pub async fn start_proxy(
&self,
app_handle: tauri::AppHandle,
upstream_url: &str,
browser_pid: u32,
profile_name: Option<&str>,
) -> Result<ProxySettings, String> {
// Check if we already have a proxy for this browser
{
let proxies = self.active_proxies.lock().unwrap();
if let Some(proxy) = proxies.get(&browser_pid) {
return Ok(ProxySettings {
enabled: true,
proxy_type: "http".to_string(),
host: "localhost".to_string(),
port: proxy.local_port,
});
}
}
// Check if we have a preferred port for this profile
let preferred_port = if let Some(name) = profile_name {
let profile_proxies = self.profile_proxies.lock().unwrap();
profile_proxies.get(name).map(|(_, port)| *port)
} else {
None
};
// Start a new proxy using the nodecar binary
let mut nodecar = app_handle
.shell()
.sidecar("nodecar")
.unwrap()
.arg("proxy")
.arg("start")
.arg("-u")
.arg(upstream_url);
// If we have a preferred port, use it
if let Some(port) = preferred_port {
nodecar = nodecar.arg("-p").arg(port.to_string());
}
let output = nodecar.output().await.unwrap();
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Proxy start failed: {}", stderr));
}
let json_string = String::from_utf8(output.stdout)
.map_err(|e| format!("Failed to parse proxy output: {}", e))?;
// Parse the JSON output
let json: Value =
serde_json::from_str(&json_string).map_err(|e| format!("Failed to parse JSON: {}", e))?;
// Extract proxy information
let id = json["id"].as_str().ok_or("Missing proxy ID")?;
let local_port = json["localPort"].as_u64().ok_or("Missing local port")? as u16;
let local_url = json["localUrl"]
.as_str()
.ok_or("Missing local URL")?
.to_string();
let upstream_url_str = json["upstreamUrl"]
.as_str()
.ok_or("Missing upstream URL")?
.to_string();
let proxy_info = ProxyInfo {
id: id.to_string(),
local_url,
upstream_url: upstream_url_str.clone(),
local_port,
};
// Store the proxy info
{
let mut proxies = self.active_proxies.lock().unwrap();
proxies.insert(browser_pid, proxy_info.clone());
}
// Store the profile proxy info for persistence
if let Some(name) = profile_name {
let mut profile_proxies = self.profile_proxies.lock().unwrap();
profile_proxies.insert(name.to_string(), (upstream_url_str, local_port));
}
// Return proxy settings for the browser
Ok(ProxySettings {
enabled: true,
proxy_type: "http".to_string(),
host: "localhost".to_string(),
port: proxy_info.local_port,
})
}
// Stop the proxy associated with a browser process ID
pub async fn stop_proxy(
&self,
app_handle: tauri::AppHandle,
browser_pid: u32,
) -> Result<(), String> {
let proxy_id = {
let mut proxies = self.active_proxies.lock().unwrap();
match proxies.remove(&browser_pid) {
Some(proxy) => proxy.id,
None => return Ok(()), // No proxy to stop
}
};
// Stop the proxy using the nodecar binary
let nodecar = app_handle
.shell()
.sidecar("nodecar")
.map_err(|e| format!("Failed to create sidecar: {}", e))?
.arg("proxy")
.arg("stop")
.arg("--id")
.arg(proxy_id);
let output = nodecar.output().await.unwrap();
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
eprintln!("Proxy stop error: {}", stderr);
// We still return Ok since we've already removed the proxy from our tracking
}
Ok(())
}
// Get proxy settings for a browser process ID
pub fn get_proxy_settings(&self, browser_pid: u32) -> Option<ProxySettings> {
let proxies = self.active_proxies.lock().unwrap();
proxies.get(&browser_pid).map(|proxy| ProxySettings {
enabled: true,
proxy_type: "http".to_string(),
host: "localhost".to_string(),
port: proxy.local_port,
})
}
// Get stored proxy info for a profile
pub fn get_profile_proxy_info(&self, profile_name: &str) -> Option<(String, u16)> {
let profile_proxies = self.profile_proxies.lock().unwrap();
profile_proxies.get(profile_name).cloned()
}
}
// Create a singleton instance of the proxy manager
lazy_static::lazy_static! {
pub static ref PROXY_MANAGER: ProxyManager = ProxyManager::new();
}
+214
View File
@@ -0,0 +1,214 @@
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use std::fs::{self, create_dir_all};
use std::path::PathBuf;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TableSortingSettings {
pub column: String, // Column to sort by: "name", "browser", "status"
pub direction: String, // "asc" or "desc"
}
impl Default for TableSortingSettings {
fn default() -> Self {
Self {
column: "name".to_string(),
direction: "asc".to_string(),
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AppSettings {
#[serde(default)]
pub set_as_default_browser: bool,
#[serde(default = "default_show_settings_on_startup")]
pub show_settings_on_startup: bool,
#[serde(default = "default_theme")]
pub theme: String, // "light", "dark", or "system"
#[serde(default = "default_auto_updates_enabled")]
pub auto_updates_enabled: bool,
}
fn default_show_settings_on_startup() -> bool {
true
}
fn default_theme() -> String {
"system".to_string()
}
fn default_auto_updates_enabled() -> bool {
true
}
impl Default for AppSettings {
fn default() -> Self {
Self {
set_as_default_browser: false,
show_settings_on_startup: default_show_settings_on_startup(),
theme: default_theme(),
auto_updates_enabled: default_auto_updates_enabled(),
}
}
}
pub struct SettingsManager {
base_dirs: BaseDirs,
}
impl SettingsManager {
pub fn new() -> Self {
Self {
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
}
}
pub fn get_settings_dir(&self) -> PathBuf {
let mut path = self.base_dirs.data_local_dir().to_path_buf();
path.push(if cfg!(debug_assertions) { "DonutBrowserDev" } else { "DonutBrowser" });
path.push("settings");
path
}
pub fn get_settings_file(&self) -> PathBuf {
self.get_settings_dir().join("app_settings.json")
}
pub fn get_table_sorting_file(&self) -> PathBuf {
self.get_settings_dir().join("table_sorting.json")
}
pub fn load_settings(&self) -> Result<AppSettings, Box<dyn std::error::Error>> {
let settings_file = self.get_settings_file();
if !settings_file.exists() {
// Return default settings if file doesn't exist
return Ok(AppSettings::default());
}
let content = fs::read_to_string(&settings_file)?;
// Parse the settings file - serde will use default values for missing fields
match serde_json::from_str::<AppSettings>(&content) {
Ok(settings) => {
// Save the settings back to ensure any missing fields are written with defaults
if let Err(e) = self.save_settings(&settings) {
eprintln!("Warning: Failed to update settings file with defaults: {}", e);
}
Ok(settings)
}
Err(e) => {
eprintln!("Warning: Failed to parse settings file, using defaults: {}", e);
let default_settings = AppSettings::default();
// Try to save default settings to fix the corrupted file
if let Err(save_error) = self.save_settings(&default_settings) {
eprintln!("Warning: Failed to save default settings: {}", save_error);
}
Ok(default_settings)
}
}
}
pub fn save_settings(&self, settings: &AppSettings) -> Result<(), Box<dyn std::error::Error>> {
let settings_dir = self.get_settings_dir();
create_dir_all(&settings_dir)?;
let settings_file = self.get_settings_file();
let json = serde_json::to_string_pretty(settings)?;
fs::write(settings_file, json)?;
Ok(())
}
pub fn load_table_sorting(&self) -> Result<TableSortingSettings, Box<dyn std::error::Error>> {
let sorting_file = self.get_table_sorting_file();
if !sorting_file.exists() {
// Return default sorting if file doesn't exist
return Ok(TableSortingSettings::default());
}
let content = fs::read_to_string(sorting_file)?;
let sorting: TableSortingSettings = serde_json::from_str(&content)?;
Ok(sorting)
}
pub fn save_table_sorting(&self, sorting: &TableSortingSettings) -> Result<(), Box<dyn std::error::Error>> {
let settings_dir = self.get_settings_dir();
create_dir_all(&settings_dir)?;
let sorting_file = self.get_table_sorting_file();
let json = serde_json::to_string_pretty(sorting)?;
fs::write(sorting_file, json)?;
Ok(())
}
pub fn should_show_settings_on_startup(&self) -> Result<bool, Box<dyn std::error::Error>> {
let settings = self.load_settings()?;
// Show prompt if:
// 1. User wants to see the prompt
// 2. Donut Browser is not set as default
// 3. User hasn't explicitly disabled the default browser setting
Ok(settings.show_settings_on_startup && !settings.set_as_default_browser)
}
pub fn disable_default_browser_prompt(&self) -> Result<(), Box<dyn std::error::Error>> {
let mut settings = self.load_settings()?;
settings.show_settings_on_startup = false;
self.save_settings(&settings)?;
Ok(())
}
}
#[tauri::command]
pub async fn get_app_settings() -> Result<AppSettings, String> {
let manager = SettingsManager::new();
manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {}", e))
}
#[tauri::command]
pub async fn save_app_settings(settings: AppSettings) -> Result<(), String> {
let manager = SettingsManager::new();
manager
.save_settings(&settings)
.map_err(|e| format!("Failed to save settings: {}", e))
}
#[tauri::command]
pub async fn should_show_settings_on_startup() -> Result<bool, String> {
let manager = SettingsManager::new();
manager
.should_show_settings_on_startup()
.map_err(|e| format!("Failed to check prompt setting: {}", e))
}
#[tauri::command]
pub async fn disable_default_browser_prompt() -> Result<(), String> {
let manager = SettingsManager::new();
manager
.disable_default_browser_prompt()
.map_err(|e| format!("Failed to disable prompt: {}", e))
}
#[tauri::command]
pub async fn get_table_sorting_settings() -> Result<TableSortingSettings, String> {
let manager = SettingsManager::new();
manager
.load_table_sorting()
.map_err(|e| format!("Failed to load table sorting settings: {}", e))
}
#[tauri::command]
pub async fn save_table_sorting_settings(sorting: TableSortingSettings) -> Result<(), String> {
let manager = SettingsManager::new();
manager
.save_table_sorting(&sorting)
.map_err(|e| format!("Failed to save table sorting settings: {}", e))
}
+535
View File
@@ -0,0 +1,535 @@
use crate::browser_version_service::BrowserVersionService;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tauri::Emitter;
use tokio::sync::Mutex;
use tokio::time::{interval, Interval};
use std::path::PathBuf;
use std::fs;
use directories::BaseDirs;
use std::sync::OnceLock;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct VersionUpdateProgress {
pub current_browser: String,
pub total_browsers: usize,
pub completed_browsers: usize,
pub new_versions_found: usize,
pub browser_new_versions: usize, // New versions found for current browser
pub status: String, // "updating", "completed", "error"
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BackgroundUpdateResult {
pub browser: String,
pub new_versions_count: usize,
pub total_versions_count: usize,
pub updated_successfully: bool,
pub error: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct BackgroundUpdateState {
last_update_time: u64,
update_interval_hours: u64,
}
impl Default for BackgroundUpdateState {
fn default() -> Self {
Self {
last_update_time: 0,
update_interval_hours: 3,
}
}
}
pub struct VersionUpdater {
version_service: BrowserVersionService,
app_handle: Arc<Mutex<Option<tauri::AppHandle>>>,
update_interval: Interval,
}
impl VersionUpdater {
pub fn new() -> Self {
let mut update_interval = interval(Duration::from_secs(5 * 60)); // Check every 5 minutes
update_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
Self {
version_service: BrowserVersionService::new(),
app_handle: Arc::new(Mutex::new(None)),
update_interval,
}
}
fn get_cache_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
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_background_update_state_file() -> Result<PathBuf, Box<dyn std::error::Error>> {
let cache_dir = Self::get_cache_dir()?;
Ok(cache_dir.join("background_update_state.json"))
}
fn load_background_update_state() -> BackgroundUpdateState {
let state_file = match Self::get_background_update_state_file() {
Ok(file) => file,
Err(_) => return BackgroundUpdateState::default(),
};
if !state_file.exists() {
return BackgroundUpdateState::default();
}
let content = match fs::read_to_string(&state_file) {
Ok(content) => content,
Err(_) => return BackgroundUpdateState::default(),
};
serde_json::from_str(&content).unwrap_or_default()
}
fn save_background_update_state(state: &BackgroundUpdateState) -> Result<(), Box<dyn std::error::Error>> {
let state_file = Self::get_background_update_state_file()?;
let content = serde_json::to_string_pretty(state)?;
fs::write(&state_file, content)?;
Ok(())
}
fn get_current_timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn should_run_background_update() -> bool {
let state = Self::load_background_update_state();
let current_time = Self::get_current_timestamp();
let elapsed_secs = current_time.saturating_sub(state.last_update_time);
let update_interval_secs = state.update_interval_hours * 60 * 60;
// Run update if:
// 1. Never updated before (last_update_time == 0)
// 2. More than 3 hours have passed since last update
let should_update = state.last_update_time == 0 || elapsed_secs >= update_interval_secs;
if should_update {
println!(
"Background update needed: last_update={}, elapsed={}h, required={}h",
state.last_update_time,
elapsed_secs / 3600,
state.update_interval_hours
);
} else {
println!(
"Background update not needed: last_update={}, elapsed={}h, required={}h",
state.last_update_time,
elapsed_secs / 3600,
state.update_interval_hours
);
}
should_update
}
pub async fn set_app_handle(&self, app_handle: tauri::AppHandle) {
let mut handle = self.app_handle.lock().await;
*handle = Some(app_handle);
}
pub async fn check_and_run_startup_update(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Only run if an update is actually needed
if !Self::should_run_background_update() {
println!("No startup version update needed");
return Ok(());
}
let app_handle = {
let handle_guard = self.app_handle.lock().await;
handle_guard.clone()
};
if let Some(handle) = app_handle {
println!("Running startup version update...");
match self.update_all_browser_versions(&handle).await {
Ok(_) => {
// Update the persistent state after successful update
let state = BackgroundUpdateState {
last_update_time: Self::get_current_timestamp(),
update_interval_hours: 3,
};
if let Err(e) = Self::save_background_update_state(&state) {
eprintln!("Failed to save background update state: {}", e);
} else {
println!("Startup version update completed successfully");
}
}
Err(e) => {
eprintln!("Startup version update failed: {}", e);
return Err(e);
}
}
} else {
return Err("App handle not available for startup update".into());
}
Ok(())
}
pub async fn start_background_updates(&mut self) {
println!("Starting background version update service (checking every 5 minutes for 3-hour intervals)");
// Run initial startup check
if let Err(e) = self.check_and_run_startup_update().await {
eprintln!("Startup version update failed: {}", e);
}
loop {
self.update_interval.tick().await;
// Check if we should run an update based on persistent state
if !Self::should_run_background_update() {
continue;
}
// Check if we have an app handle
let app_handle = {
let handle_guard = self.app_handle.lock().await;
handle_guard.clone()
};
if let Some(handle) = app_handle {
println!("Starting background version update...");
match self.update_all_browser_versions(&handle).await {
Ok(_) => {
// Update the persistent state after successful update
let state = BackgroundUpdateState {
last_update_time: Self::get_current_timestamp(),
update_interval_hours: 3,
};
if let Err(e) = Self::save_background_update_state(&state) {
eprintln!("Failed to save background update state: {}", e);
} else {
println!("Background version update completed successfully");
}
}
Err(e) => {
eprintln!("Background version update failed: {}", e);
// Emit error event
let progress = VersionUpdateProgress {
current_browser: "".to_string(),
total_browsers: 0,
completed_browsers: 0,
new_versions_found: 0,
browser_new_versions: 0,
status: "error".to_string(),
};
let _ = handle.emit("version-update-progress", &progress);
}
}
} else {
println!("App handle not available, skipping background update");
}
}
}
async fn update_all_browser_versions(&self, app_handle: &tauri::AppHandle) -> Result<Vec<BackgroundUpdateResult>, Box<dyn std::error::Error + Send + Sync>> {
println!("Starting background version update for all browsers");
let browsers = vec![
"firefox",
"firefox-developer",
"mullvad-browser",
"zen",
"brave",
"chromium",
"tor-browser",
];
let total_browsers = browsers.len();
let mut results = Vec::new();
let mut total_new_versions = 0;
// Emit start event
let progress = VersionUpdateProgress {
current_browser: "".to_string(),
total_browsers,
completed_browsers: 0,
new_versions_found: 0,
browser_new_versions: 0,
status: "updating".to_string(),
};
let _ = app_handle.emit("version-update-progress", &progress);
for (index, browser) in browsers.iter().enumerate() {
// Check if individual browser cache is expired before updating
if !self.version_service.should_update_cache(browser) {
println!("Skipping {} - cache is still fresh", browser);
let browser_result = BackgroundUpdateResult {
browser: browser.to_string(),
new_versions_count: 0,
total_versions_count: 0,
updated_successfully: true,
error: None,
};
results.push(browser_result);
continue;
}
println!("Updating versions for browser: {}", browser);
// Emit progress for current browser
let progress = VersionUpdateProgress {
current_browser: browser.to_string(),
total_browsers,
completed_browsers: index,
new_versions_found: total_new_versions,
browser_new_versions: 0,
status: "updating".to_string(),
};
let _ = app_handle.emit("version-update-progress", &progress);
let result = self.update_browser_versions(browser).await;
match result {
Ok(new_count) => {
total_new_versions += new_count;
let browser_result = BackgroundUpdateResult {
browser: browser.to_string(),
new_versions_count: new_count,
total_versions_count: 0, // We'll update this if needed
updated_successfully: true,
error: None,
};
results.push(browser_result);
println!("Found {} new versions for {}", new_count, browser);
}
Err(e) => {
eprintln!("Failed to update versions for {}: {}", browser, e);
let browser_result = BackgroundUpdateResult {
browser: browser.to_string(),
new_versions_count: 0,
total_versions_count: 0,
updated_successfully: false,
error: Some(e.to_string()),
};
results.push(browser_result);
}
}
// Small delay between browsers to avoid overwhelming APIs
tokio::time::sleep(Duration::from_millis(500)).await;
}
// Emit completion event
let progress = VersionUpdateProgress {
current_browser: "".to_string(),
total_browsers,
completed_browsers: total_browsers,
new_versions_found: total_new_versions,
browser_new_versions: 0,
status: "completed".to_string(),
};
let _ = app_handle.emit("version-update-progress", &progress);
println!("Background version update completed. Found {} new versions total", total_new_versions);
Ok(results)
}
async fn update_browser_versions(&self, browser: &str) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
self.version_service.update_browser_versions_incrementally(browser).await
}
pub async fn trigger_manual_update(&self, app_handle: &tauri::AppHandle) -> Result<Vec<BackgroundUpdateResult>, Box<dyn std::error::Error + Send + Sync>> {
let results = self.update_all_browser_versions(app_handle).await?;
// Update the persistent state after successful manual update
let state = BackgroundUpdateState {
last_update_time: Self::get_current_timestamp(),
update_interval_hours: 3,
};
if let Err(e) = Self::save_background_update_state(&state) {
eprintln!("Failed to save background update state after manual update: {}", e);
}
Ok(results)
}
pub async fn get_last_update_time(&self) -> Option<u64> {
let state = Self::load_background_update_state();
if state.last_update_time == 0 {
None
} else {
Some(state.last_update_time)
}
}
pub async fn get_time_until_next_update(&self) -> u64 {
let state = Self::load_background_update_state();
let current_time = Self::get_current_timestamp();
if state.last_update_time == 0 {
0 // No previous update, should update now
} else {
let elapsed = current_time.saturating_sub(state.last_update_time);
let update_interval_secs = state.update_interval_hours * 60 * 60;
if elapsed >= update_interval_secs {
0 // Update overdue
} else {
update_interval_secs - elapsed
}
}
}
}
// Global instance
static VERSION_UPDATER: OnceLock<Arc<Mutex<VersionUpdater>>> = OnceLock::new();
pub fn get_version_updater() -> Arc<Mutex<VersionUpdater>> {
VERSION_UPDATER.get_or_init(|| {
Arc::new(Mutex::new(VersionUpdater::new()))
}).clone()
}
#[tauri::command]
pub async fn trigger_manual_version_update(app_handle: tauri::AppHandle) -> Result<Vec<BackgroundUpdateResult>, String> {
let updater = get_version_updater();
let updater_guard = updater.lock().await;
updater_guard.trigger_manual_update(&app_handle)
.await
.map_err(|e| format!("Failed to trigger manual update: {}", e))
}
#[tauri::command]
pub async fn get_version_update_status() -> Result<(Option<u64>, u64), String> {
let updater = get_version_updater();
let updater_guard = updater.lock().await;
let last_update = updater_guard.get_last_update_time().await;
let time_until_next = updater_guard.get_time_until_next_update().await;
Ok((last_update, time_until_next))
}
#[tauri::command]
pub async fn check_version_update_needed() -> Result<bool, String> {
Ok(VersionUpdater::should_run_background_update())
}
#[tauri::command]
pub async fn force_version_update_check(_app_handle: tauri::AppHandle) -> Result<bool, String> {
let updater = get_version_updater();
let updater_guard = updater.lock().await;
match updater_guard.check_and_run_startup_update().await {
Ok(_) => Ok(true),
Err(e) => Err(format!("Failed to run version update check: {}", e))
}
}
#[cfg(test)]
mod tests {
use super::*;
// Helper function to create a unique test state file
fn get_test_state_file(test_name: &str) -> PathBuf {
let cache_dir = VersionUpdater::get_cache_dir().unwrap();
cache_dir.join(format!("test_{}_state.json", test_name))
}
fn save_test_state(test_name: &str, state: &BackgroundUpdateState) -> Result<(), Box<dyn std::error::Error>> {
let state_file = get_test_state_file(test_name);
let content = serde_json::to_string_pretty(state)?;
fs::write(&state_file, content)?;
Ok(())
}
fn load_test_state(test_name: &str) -> BackgroundUpdateState {
let state_file = get_test_state_file(test_name);
if !state_file.exists() {
return BackgroundUpdateState::default();
}
let content = match fs::read_to_string(&state_file) {
Ok(content) => content,
Err(_) => return BackgroundUpdateState::default(),
};
serde_json::from_str(&content).unwrap_or_default()
}
#[test]
fn test_background_update_state_persistence() {
let test_name = "persistence";
// Create a test state
let test_state = BackgroundUpdateState {
last_update_time: 1609459200, // 2021-01-01 00:00:00 UTC
update_interval_hours: 3,
};
// Save the state
save_test_state(test_name, &test_state).unwrap();
// Load the state back
let loaded_state = load_test_state(test_name);
// Verify the values match
assert_eq!(loaded_state.last_update_time, test_state.last_update_time);
assert_eq!(loaded_state.update_interval_hours, test_state.update_interval_hours);
// Clean up
let _ = fs::remove_file(get_test_state_file(test_name));
}
#[test]
fn test_should_run_background_update_logic() {
// Note: This test uses the shared state file, so results may vary
// depending on previous test runs. This is expected behavior.
// Test with recent update (should not update)
let recent_state = BackgroundUpdateState {
last_update_time: VersionUpdater::get_current_timestamp() - 60, // 1 minute ago
update_interval_hours: 3,
};
VersionUpdater::save_background_update_state(&recent_state).unwrap();
assert!(!VersionUpdater::should_run_background_update());
// Test with old update (should update)
let old_state = BackgroundUpdateState {
last_update_time: VersionUpdater::get_current_timestamp() - (4 * 60 * 60), // 4 hours ago
update_interval_hours: 3,
};
VersionUpdater::save_background_update_state(&old_state).unwrap();
assert!(VersionUpdater::should_run_background_update());
}
#[test]
fn test_cache_dir_creation() {
// This should not panic and should create the directory if it doesn't exist
let cache_dir = VersionUpdater::get_cache_dir().unwrap();
assert!(cache_dir.exists());
assert!(cache_dir.is_dir());
}
}