diff --git a/package.json b/package.json index e7e7d9e..ac9f890 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@radix-ui/react-tooltip": "^1.2.7", "@tanstack/react-table": "^8.21.3", "@tauri-apps/api": "^2.5.0", + "@tauri-apps/plugin-dialog": "^2.2.2", "@tauri-apps/plugin-fs": "~2.3.0", "@tauri-apps/plugin-opener": "^2.2.7", "class-variance-authority": "^0.7.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f07ca38..de0f76b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: '@tauri-apps/api': specifier: ^2.5.0 version: 2.5.0 + '@tauri-apps/plugin-dialog': + specifier: ^2.2.2 + version: 2.2.2 '@tauri-apps/plugin-fs': specifier: ~2.3.0 version: 2.3.0 @@ -1450,6 +1453,9 @@ packages: engines: {node: '>= 10'} hasBin: true + '@tauri-apps/plugin-dialog@2.2.2': + resolution: {integrity: sha512-Pm9qnXQq8ZVhAMFSEPwxvh+nWb2mk7LASVlNEHYaksHvcz8P6+ElR5U5dNL9Ofrm+uwhh1/gYKWswK8JJJAh6A==} + '@tauri-apps/plugin-fs@2.3.0': resolution: {integrity: sha512-G9gEyYVUaaxhdRJBgQTTLmzAe0vtHYxYyN1oTQzU3zwvb8T+tVLcAqCdFMWHq0qGeGbmynI5whvYpcXo5LvZ1w==} @@ -4657,6 +4663,10 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.5.0 '@tauri-apps/cli-win32-x64-msvc': 2.5.0 + '@tauri-apps/plugin-dialog@2.2.2': + dependencies: + '@tauri-apps/api': 2.5.0 + '@tauri-apps/plugin-fs@2.3.0': dependencies: '@tauri-apps/api': 2.5.0 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3b4bf07..1dc551c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -82,6 +82,24 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "ashpd" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df" +dependencies = [ + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.1", + "raw-window-handle", + "serde", + "serde_repr", + "tokio", + "url", + "zbus", +] + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -908,6 +926,18 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" +[[package]] +name = "dispatch2" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a0d569e003ff27784e0e14e4a594048698e0c0f0b66cabcb51511be55a7caa0" +dependencies = [ + "bitflags 2.9.1", + "block2 0.6.1", + "libc", + "objc2 0.6.1", +] + [[package]] name = "dispatch2" version = "0.3.0" @@ -980,6 +1010,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-deep-link", + "tauri-plugin-dialog", "tauri-plugin-fs", "tauri-plugin-opener", "tauri-plugin-shell", @@ -2599,7 +2630,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" dependencies = [ "bitflags 2.9.1", - "dispatch2", + "dispatch2 0.3.0", "objc2 0.6.1", ] @@ -2610,7 +2641,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4" dependencies = [ "bitflags 2.9.1", - "dispatch2", + "dispatch2 0.3.0", "objc2 0.6.1", "objc2-core-foundation", "objc2-io-surface", @@ -3282,6 +3313,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -3302,6 +3343,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -3320,6 +3371,15 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.3", +] + [[package]] name = "rand_hc" version = "0.2.0" @@ -3438,6 +3498,31 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80c844748fdc82aae252ee4594a89b6e7ebef1063de7951545564cbc4e57075d" +dependencies = [ + "ashpd", + "block2 0.6.1", + "dispatch2 0.2.0", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2 0.6.1", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.1", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + [[package]] name = "ring" version = "0.17.14" @@ -4282,6 +4367,24 @@ dependencies = [ "windows-result", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a33318fe222fc2a612961de8b0419e2982767f213f54a4d3a21b0d7b85c41df8" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.12", + "url", +] + [[package]] name = "tauri-plugin-fs" version = "2.3.0" @@ -4580,6 +4683,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.52.0", ] @@ -5795,6 +5899,7 @@ dependencies = [ "ordered-stream", "serde", "serde_repr", + "tokio", "tracing", "uds_windows", "windows-sys 0.59.0", @@ -6006,6 +6111,7 @@ dependencies = [ "endi", "enumflags2", "serde", + "url", "winnow 0.7.10", "zvariant_derive", "zvariant_utils", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c97374a..c602b08 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,6 +26,7 @@ tauri-plugin-opener = "2" tauri-plugin-fs = "2" tauri-plugin-shell = "2" tauri-plugin-deep-link = "2" +tauri-plugin-dialog = "2" directories = "6" reqwest = { version = "0.12", features = ["json", "stream"] } tokio = { version = "1", features = ["full"] } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index c46d7b4..d58ce48 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -18,6 +18,8 @@ "shell:allow-open", "shell:allow-spawn", "shell:allow-stdin-write", - "deep-link:default" + "deep-link:default", + "dialog:default", + "dialog:allow-open" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 38907e4..135ea05 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -16,6 +16,7 @@ mod default_browser; mod download; mod downloaded_browsers; mod extraction; +mod profile_importer; mod proxy_manager; mod settings_manager; mod theme_detector; @@ -55,6 +56,8 @@ use app_auto_updater::{ check_for_app_updates, check_for_app_updates_manual, download_and_install_app_update, }; +use profile_importer::{detect_existing_profiles, import_browser_profile}; + use theme_detector::get_system_theme; // Trait to extend WebviewWindow with transparent titlebar functionality @@ -168,6 +171,7 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_deep_link::init()) + .plugin(tauri_plugin_dialog::init()) .setup(|app| { // Create the main window programmatically #[allow(unused_variables)] @@ -309,6 +313,8 @@ pub fn run() { check_for_app_updates_manual, download_and_install_app_update, get_system_theme, + detect_existing_profiles, + import_browser_profile, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/profile_importer.rs b/src-tauri/src/profile_importer.rs new file mode 100644 index 0000000..98b91bb --- /dev/null +++ b/src-tauri/src/profile_importer.rs @@ -0,0 +1,661 @@ +use directories::BaseDirs; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::fs::{self, create_dir_all}; +use std::path::{Path, PathBuf}; + +use crate::browser::BrowserType; +use crate::browser_runner::BrowserRunner; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DetectedProfile { + pub browser: String, + pub name: String, + pub path: String, + pub description: String, +} + +pub struct ProfileImporter { + base_dirs: BaseDirs, + browser_runner: BrowserRunner, +} + +impl ProfileImporter { + pub fn new() -> Self { + Self { + base_dirs: BaseDirs::new().expect("Failed to get base directories"), + browser_runner: BrowserRunner::new(), + } + } + + /// Detect existing browser profiles on the system + pub fn detect_existing_profiles( + &self, + ) -> Result, Box> { + let mut detected_profiles = Vec::new(); + + // Detect Firefox profiles + detected_profiles.extend(self.detect_firefox_profiles()?); + + // Detect Chrome profiles + detected_profiles.extend(self.detect_chrome_profiles()?); + + // Detect Brave profiles + detected_profiles.extend(self.detect_brave_profiles()?); + + // Detect Firefox Developer Edition profiles + detected_profiles.extend(self.detect_firefox_developer_profiles()?); + + // Detect Chromium profiles + detected_profiles.extend(self.detect_chromium_profiles()?); + + // Detect Mullvad Browser profiles + detected_profiles.extend(self.detect_mullvad_browser_profiles()?); + + // Detect Zen Browser profiles + detected_profiles.extend(self.detect_zen_browser_profiles()?); + + // Remove duplicates based on path + let mut seen_paths = HashSet::new(); + let unique_profiles: Vec = detected_profiles + .into_iter() + .filter(|profile| seen_paths.insert(profile.path.clone())) + .collect(); + + Ok(unique_profiles) + } + + /// Detect Firefox profiles + fn detect_firefox_profiles(&self) -> Result, Box> { + let mut profiles = Vec::new(); + + #[cfg(target_os = "macos")] + { + let firefox_dir = self + .base_dirs + .home_dir() + .join("Library/Application Support/Firefox/Profiles"); + profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?); + } + + #[cfg(target_os = "windows")] + { + if let Some(app_data) = self.base_dirs.data_dir() { + let firefox_dir = app_data.join("Mozilla/Firefox/Profiles"); + profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?); + } + } + + #[cfg(target_os = "linux")] + { + let firefox_dir = self.base_dirs.home_dir().join(".mozilla/firefox"); + profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?); + } + + Ok(profiles) + } + + /// Detect Firefox Developer Edition profiles + fn detect_firefox_developer_profiles( + &self, + ) -> Result, Box> { + let mut profiles = Vec::new(); + + #[cfg(target_os = "macos")] + { + // Firefox Developer Edition on macOS uses separate profile directories + let firefox_dev_alt_dir = self + .base_dirs + .home_dir() + .join("Library/Application Support/Firefox Developer Edition/Profiles"); + + // Only scan the dedicated dev edition directory if it exists, otherwise skip to avoid duplicates + if firefox_dev_alt_dir.exists() { + profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_alt_dir, "firefox-developer")?); + } + } + + #[cfg(target_os = "windows")] + { + if let Some(app_data) = self.base_dirs.data_dir() { + // Firefox Developer Edition on Windows typically uses separate directories + let firefox_dev_dir = app_data.join("Mozilla/Firefox Developer Edition/Profiles"); + if firefox_dev_dir.exists() { + profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_dir, "firefox-developer")?); + } + } + } + + #[cfg(target_os = "linux")] + { + // Firefox Developer Edition on Linux uses separate directories + let firefox_dev_dir = self + .base_dirs + .home_dir() + .join(".mozilla/firefox-dev-edition"); + if firefox_dev_dir.exists() { + profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_dir, "firefox-developer")?); + } + } + + Ok(profiles) + } + + /// Detect Chrome profiles + fn detect_chrome_profiles(&self) -> Result, Box> { + let mut profiles = Vec::new(); + + #[cfg(target_os = "macos")] + { + let chrome_dir = self + .base_dirs + .home_dir() + .join("Library/Application Support/Google/Chrome"); + profiles.extend(self.scan_chrome_profiles_dir(&chrome_dir, "chromium")?); + } + + #[cfg(target_os = "windows")] + { + if let Some(local_app_data) = self.base_dirs.data_local_dir() { + let chrome_dir = local_app_data.join("Google/Chrome/User Data"); + profiles.extend(self.scan_chrome_profiles_dir(&chrome_dir, "chromium")?); + } + } + + #[cfg(target_os = "linux")] + { + let chrome_dir = self.base_dirs.home_dir().join(".config/google-chrome"); + profiles.extend(self.scan_chrome_profiles_dir(&chrome_dir, "chromium")?); + } + + Ok(profiles) + } + + /// Detect Chromium profiles + fn detect_chromium_profiles(&self) -> Result, Box> { + let mut profiles = Vec::new(); + + #[cfg(target_os = "macos")] + { + let chromium_dir = self + .base_dirs + .home_dir() + .join("Library/Application Support/Chromium"); + profiles.extend(self.scan_chrome_profiles_dir(&chromium_dir, "chromium")?); + } + + #[cfg(target_os = "windows")] + { + if let Some(local_app_data) = self.base_dirs.data_local_dir() { + let chromium_dir = local_app_data.join("Chromium/User Data"); + profiles.extend(self.scan_chrome_profiles_dir(&chromium_dir, "chromium")?); + } + } + + #[cfg(target_os = "linux")] + { + let chromium_dir = self.base_dirs.home_dir().join(".config/chromium"); + profiles.extend(self.scan_chrome_profiles_dir(&chromium_dir, "chromium")?); + } + + Ok(profiles) + } + + /// Detect Brave profiles + fn detect_brave_profiles(&self) -> Result, Box> { + let mut profiles = Vec::new(); + + #[cfg(target_os = "macos")] + { + let brave_dir = self + .base_dirs + .home_dir() + .join("Library/Application Support/BraveSoftware/Brave-Browser"); + profiles.extend(self.scan_chrome_profiles_dir(&brave_dir, "brave")?); + } + + #[cfg(target_os = "windows")] + { + if let Some(local_app_data) = self.base_dirs.data_local_dir() { + let brave_dir = local_app_data.join("BraveSoftware/Brave-Browser/User Data"); + profiles.extend(self.scan_chrome_profiles_dir(&brave_dir, "brave")?); + } + } + + #[cfg(target_os = "linux")] + { + let brave_dir = self + .base_dirs + .home_dir() + .join(".config/BraveSoftware/Brave-Browser"); + profiles.extend(self.scan_chrome_profiles_dir(&brave_dir, "brave")?); + } + + Ok(profiles) + } + + /// Detect Mullvad Browser profiles + fn detect_mullvad_browser_profiles( + &self, + ) -> Result, Box> { + let mut profiles = Vec::new(); + + #[cfg(target_os = "macos")] + { + let mullvad_dir = self + .base_dirs + .home_dir() + .join("Library/Application Support/MullvadBrowser/Profiles"); + profiles.extend(self.scan_firefox_profiles_dir(&mullvad_dir, "mullvad-browser")?); + } + + #[cfg(target_os = "windows")] + { + if let Some(app_data) = self.base_dirs.data_dir() { + let mullvad_dir = app_data.join("MullvadBrowser/Profiles"); + profiles.extend(self.scan_firefox_profiles_dir(&mullvad_dir, "mullvad-browser")?); + } + } + + #[cfg(target_os = "linux")] + { + let mullvad_dir = self.base_dirs.home_dir().join(".mullvad-browser"); + profiles.extend(self.scan_firefox_profiles_dir(&mullvad_dir, "mullvad-browser")?); + } + + Ok(profiles) + } + + /// Detect Zen Browser profiles + fn detect_zen_browser_profiles( + &self, + ) -> Result, Box> { + let mut profiles = Vec::new(); + + #[cfg(target_os = "macos")] + { + let zen_dir = self + .base_dirs + .home_dir() + .join("Library/Application Support/Zen/Profiles"); + profiles.extend(self.scan_firefox_profiles_dir(&zen_dir, "zen")?); + } + + #[cfg(target_os = "windows")] + { + if let Some(app_data) = self.base_dirs.data_dir() { + let zen_dir = app_data.join("Zen/Profiles"); + profiles.extend(self.scan_firefox_profiles_dir(&zen_dir, "zen")?); + } + } + + #[cfg(target_os = "linux")] + { + let zen_dir = self.base_dirs.home_dir().join(".zen"); + profiles.extend(self.scan_firefox_profiles_dir(&zen_dir, "zen")?); + } + + Ok(profiles) + } + + /// Scan Firefox-style profiles directory + fn scan_firefox_profiles_dir( + &self, + profiles_dir: &Path, + browser_type: &str, + ) -> Result, Box> { + let mut profiles = Vec::new(); + + if !profiles_dir.exists() { + return Ok(profiles); + } + + // Read profiles.ini file if it exists + let profiles_ini = profiles_dir + .parent() + .unwrap_or(profiles_dir) + .join("profiles.ini"); + if profiles_ini.exists() { + if let Ok(content) = fs::read_to_string(&profiles_ini) { + profiles.extend(self.parse_firefox_profiles_ini(&content, profiles_dir, browser_type)?); + } + } + + // Also scan directory for any profile folders not in profiles.ini + if let Ok(entries) = fs::read_dir(profiles_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + let prefs_file = path.join("prefs.js"); + if prefs_file.exists() { + let profile_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("Unknown Profile"); + + // Check if this profile was already found in profiles.ini + let already_added = profiles.iter().any(|p| p.path == path.to_string_lossy()); + if !already_added { + profiles.push(DetectedProfile { + browser: browser_type.to_string(), + name: format!( + "{} Profile - {}", + self.get_browser_display_name(browser_type), + profile_name + ), + path: path.to_string_lossy().to_string(), + description: format!("Profile folder: {profile_name}"), + }); + } + } + } + } + } + + Ok(profiles) + } + + /// Parse Firefox profiles.ini file + fn parse_firefox_profiles_ini( + &self, + content: &str, + profiles_dir: &Path, + browser_type: &str, + ) -> Result, Box> { + let mut profiles = Vec::new(); + let mut current_section = String::new(); + let mut profile_name = String::new(); + let mut profile_path = String::new(); + let mut is_relative = true; + + for line in content.lines() { + let line = line.trim(); + + if line.starts_with('[') && line.ends_with(']') { + // Save previous profile if complete + if !current_section.is_empty() + && current_section.starts_with("Profile") + && !profile_path.is_empty() + { + let full_path = if is_relative { + profiles_dir.join(&profile_path) + } else { + PathBuf::from(&profile_path) + }; + + if full_path.exists() { + let display_name = if profile_name.is_empty() { + format!("{} Profile", self.get_browser_display_name(browser_type)) + } else { + format!( + "{} - {}", + self.get_browser_display_name(browser_type), + profile_name + ) + }; + + profiles.push(DetectedProfile { + browser: browser_type.to_string(), + name: display_name, + path: full_path.to_string_lossy().to_string(), + description: format!("Profile: {profile_name}"), + }); + } + } + + // Start new section + current_section = line[1..line.len() - 1].to_string(); + profile_name.clear(); + profile_path.clear(); + is_relative = true; + } else if line.contains('=') { + let parts: Vec<&str> = line.splitn(2, '=').collect(); + if parts.len() == 2 { + let key = parts[0].trim(); + let value = parts[1].trim(); + + match key { + "Name" => profile_name = value.to_string(), + "Path" => profile_path = value.to_string(), + "IsRelative" => is_relative = value == "1", + _ => {} + } + } + } + } + + // Handle last profile + if !current_section.is_empty() + && current_section.starts_with("Profile") + && !profile_path.is_empty() + { + let full_path = if is_relative { + profiles_dir.join(&profile_path) + } else { + PathBuf::from(&profile_path) + }; + + if full_path.exists() { + let display_name = if profile_name.is_empty() { + format!("{} Profile", self.get_browser_display_name(browser_type)) + } else { + format!( + "{} - {}", + self.get_browser_display_name(browser_type), + profile_name + ) + }; + + profiles.push(DetectedProfile { + browser: browser_type.to_string(), + name: display_name, + path: full_path.to_string_lossy().to_string(), + description: format!("Profile: {profile_name}"), + }); + } + } + + Ok(profiles) + } + + /// Scan Chrome-style profiles directory + fn scan_chrome_profiles_dir( + &self, + browser_dir: &Path, + browser_type: &str, + ) -> Result, Box> { + let mut profiles = Vec::new(); + + if !browser_dir.exists() { + return Ok(profiles); + } + + // Check for Default profile + let default_profile = browser_dir.join("Default"); + if default_profile.exists() && default_profile.join("Preferences").exists() { + profiles.push(DetectedProfile { + browser: browser_type.to_string(), + name: format!( + "{} - Default Profile", + self.get_browser_display_name(browser_type) + ), + path: default_profile.to_string_lossy().to_string(), + description: "Default profile".to_string(), + }); + } + + // Check for Profile X directories + if let Ok(entries) = fs::read_dir(browser_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + + if dir_name.starts_with("Profile ") && path.join("Preferences").exists() { + let profile_number = &dir_name[8..]; // Remove "Profile " prefix + profiles.push(DetectedProfile { + browser: browser_type.to_string(), + name: format!( + "{} - Profile {}", + self.get_browser_display_name(browser_type), + profile_number + ), + path: path.to_string_lossy().to_string(), + description: format!("Profile {profile_number}"), + }); + } + } + } + } + + Ok(profiles) + } + + /// Get browser display name + fn get_browser_display_name(&self, browser_type: &str) -> &str { + match browser_type { + "firefox" => "Firefox", + "firefox-developer" => "Firefox Developer", + "chromium" => "Chrome/Chromium", + "brave" => "Brave", + "mullvad-browser" => "Mullvad Browser", + "zen" => "Zen Browser", + "tor-browser" => "Tor Browser", + _ => "Unknown Browser", + } + } + + /// Import a profile from an existing browser profile + pub fn import_profile( + &self, + source_path: &str, + browser_type: &str, + new_profile_name: &str, + ) -> Result<(), Box> { + // Validate that source path exists + let source_path = Path::new(source_path); + if !source_path.exists() { + return Err("Source profile path does not exist".into()); + } + + // Validate browser type + let _browser_type = BrowserType::from_str(browser_type) + .map_err(|_| format!("Invalid browser type: {browser_type}"))?; + + // Check if a profile with this name already exists + let existing_profiles = self.browser_runner.list_profiles()?; + if existing_profiles + .iter() + .any(|p| p.name.to_lowercase() == new_profile_name.to_lowercase()) + { + return Err(format!("Profile with name '{new_profile_name}' already exists").into()); + } + + // Create the new profile directory + let snake_case_name = new_profile_name.to_lowercase().replace(' ', "_"); + let profiles_dir = self.browser_runner.get_profiles_dir(); + let new_profile_path = profiles_dir.join(&snake_case_name); + + create_dir_all(&new_profile_path)?; + + // Copy all files from source to destination + Self::copy_directory_recursive(source_path, &new_profile_path)?; + + // Create the profile metadata without overwriting the imported data + // We need to find a suitable version for this browser type + let available_versions = self.get_default_version_for_browser(browser_type)?; + + let profile = crate::browser_runner::BrowserProfile { + name: new_profile_name.to_string(), + browser: browser_type.to_string(), + version: available_versions, + profile_path: new_profile_path.to_string_lossy().to_string(), + proxy: None, + process_id: None, + last_launch: None, + }; + + // Save the profile metadata + self.browser_runner.save_profile(&profile)?; + + println!( + "Successfully imported profile '{}' from '{}'", + new_profile_name, + source_path.display() + ); + + Ok(()) + } + + /// Get a default version for a browser type + fn get_default_version_for_browser( + &self, + browser_type: &str, + ) -> Result> { + // Try to get a downloaded version first, fallback to a reasonable default + let registry = + crate::downloaded_browsers::DownloadedBrowsersRegistry::load().unwrap_or_default(); + let downloaded_versions = registry.get_downloaded_versions(browser_type); + + if let Some(version) = downloaded_versions.first() { + return Ok(version.clone()); + } + + // If no downloaded versions, return a sensible default + match browser_type { + "firefox" => Ok("latest".to_string()), + "firefox-developer" => Ok("latest".to_string()), + "chromium" => Ok("latest".to_string()), + "brave" => Ok("latest".to_string()), + "zen" => Ok("latest".to_string()), + "mullvad-browser" => Ok("13.5.16".to_string()), // Mullvad Browser common version + "tor-browser" => Ok("latest".to_string()), + _ => Ok("latest".to_string()), + } + } + + /// Recursively copy directory contents + fn copy_directory_recursive( + source: &Path, + destination: &Path, + ) -> Result<(), Box> { + if !destination.exists() { + create_dir_all(destination)?; + } + + for entry in fs::read_dir(source)? { + let entry = entry?; + let source_path = entry.path(); + let dest_path = destination.join(entry.file_name()); + + if source_path.is_dir() { + Self::copy_directory_recursive(&source_path, &dest_path)?; + } else { + fs::copy(&source_path, &dest_path)?; + } + } + + Ok(()) + } +} + +// Tauri commands +#[tauri::command] +pub async fn detect_existing_profiles() -> Result, String> { + let importer = ProfileImporter::new(); + importer + .detect_existing_profiles() + .map_err(|e| format!("Failed to detect existing profiles: {e}")) +} + +#[tauri::command] +pub async fn import_browser_profile( + source_path: String, + browser_type: String, + new_profile_name: String, +) -> Result<(), String> { + let importer = ProfileImporter::new(); + importer + .import_profile(&source_path, &browser_type, &new_profile_name) + .map_err(|e| format!("Failed to import profile: {e}")) +} diff --git a/src/app/page.tsx b/src/app/page.tsx index d4f9fcc..6d2acf4 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,12 +2,19 @@ import { ChangeVersionDialog } from "@/components/change-version-dialog"; import { CreateProfileDialog } from "@/components/create-profile-dialog"; +import { ImportProfileDialog } from "@/components/import-profile-dialog"; import { ProfilesDataTable } from "@/components/profile-data-table"; import { ProfileSelectorDialog } from "@/components/profile-selector-dialog"; import { ProxySettingsDialog } from "@/components/proxy-settings-dialog"; import { SettingsDialog } from "@/components/settings-dialog"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipContent, @@ -20,7 +27,8 @@ import type { BrowserProfile, ProxySettings } from "@/types"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { useCallback, useEffect, useRef, useState } from "react"; -import { GoGear, GoPlus } from "react-icons/go"; +import { FaDownload } from "react-icons/fa"; +import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go"; type BrowserTypeString = | "mullvad-browser" @@ -43,6 +51,7 @@ export default function Home() { const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false); const [changeVersionDialogOpen, setChangeVersionDialogOpen] = useState(false); const [settingsDialogOpen, setSettingsDialogOpen] = useState(false); + const [importProfileDialogOpen, setImportProfileDialogOpen] = useState(false); const [pendingUrls, setPendingUrls] = useState([]); const [currentProfileForProxy, setCurrentProfileForProxy] = useState(null); @@ -407,21 +416,35 @@ export default function Home() {
Profiles
- - + + + + + { setSettingsDialogOpen(true); }} - className="flex gap-2 items-center" > - - - - Settings - + + Settings + + { + setImportProfileDialogOpen(true); + }} + > + + Import Profile + + + + +
+ + {/* Auto-Detect Mode */} + {importMode === "auto-detect" && ( +
+

Detected Browser Profiles

+ + {isLoading ? ( +
+

+ Scanning for browser profiles... +

+
+ ) : detectedProfiles.length === 0 ? ( +
+

+ No browser profiles found on your system. +

+

+ Try the manual import option if you have profiles in custom + locations. +

+
+ ) : ( +
+
+ + +
+ + {selectedProfile && ( +
+

+ Path:{" "} + {selectedProfile.path} +

+

+ Browser:{" "} + {getBrowserDisplayName(selectedProfile.browser)} +

+
+ )} + +
+ + { + setAutoDetectProfileName(e.target.value); + }} + placeholder="Enter a name for the imported profile" + /> +
+
+ )} +
+ )} + + {/* Manual Import Mode */} + {importMode === "manual" && ( +
+

Manual Profile Import

+ +
+
+ + +
+ +
+ +
+ { + setManualProfilePath(e.target.value); + }} + placeholder="Enter the full path to the profile folder" + /> + +
+

+ Example paths: +
+ macOS: ~/Library/Application + Support/Firefox/Profiles/xxx.default +
+ Windows: %APPDATA%\Mozilla\Firefox\Profiles\xxx.default +
+ Linux: ~/.mozilla/firefox/xxx.default +

+
+ +
+ + { + setManualProfileName(e.target.value); + }} + placeholder="Enter a name for the imported profile" + /> +
+
+
+ )} +
+ + + + {importMode === "auto-detect" ? ( + { + void handleAutoDetectImport(); + }} + disabled={ + !selectedDetectedProfile || + !autoDetectProfileName.trim() || + isLoading + } + > + Import Detected Profile + + ) : ( + { + void handleManualImport(); + }} + disabled={ + !manualBrowserType || + !manualProfilePath.trim() || + !manualProfileName.trim() + } + > + Import Manual Profile + + )} + + + + ); +} diff --git a/src/types.ts b/src/types.ts index a26cd26..0751a81 100644 --- a/src/types.ts +++ b/src/types.ts @@ -20,6 +20,13 @@ export interface BrowserProfile { last_launch?: number; } +export interface DetectedProfile { + browser: string; + name: string; + path: string; + description: string; +} + export interface AppUpdateInfo { current_version: string; new_version: string;