mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-02 21:31:35 +02:00
init
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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(¬ification_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]);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(®istry_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(®istry_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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user