From 4f7910dd23ef987ead1ada7566de8aef9d182885 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Fri, 17 Jul 2026 02:05:34 +0400 Subject: [PATCH] feat: mass import via gui, api, and mcp --- src-tauri/src/api_server.rs | 253 ++++++- src-tauri/src/lib.rs | 10 +- src-tauri/src/mcp_server.rs | 149 ++++ src-tauri/src/profile_importer.rs | 627 +++++++++++++++- src/components/import-profile-dialog.tsx | 915 ++++++++++++++--------- src/i18n/locales/en.json | 48 +- src/i18n/locales/es.json | 48 +- src/i18n/locales/fr.json | 48 +- src/i18n/locales/ja.json | 48 +- src/i18n/locales/ko.json | 48 +- src/i18n/locales/pt.json | 48 +- src/i18n/locales/ru.json | 48 +- src/i18n/locales/vi.json | 48 +- src/i18n/locales/zh.json | 48 +- src/lib/backend-errors.ts | 24 + src/types.ts | 35 + 16 files changed, 1905 insertions(+), 540 deletions(-) diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index e96b0a0..82ec879 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -5,7 +5,7 @@ use crate::profile::manager::ProfileManager; use crate::proxy_manager::PROXY_MANAGER; use crate::tag_manager::TAG_MANAGER; use axum::{ - extract::{Path, State}, + extract::{Path, Query, State}, http::{HeaderMap, StatusCode}, middleware::{self, Next}, response::{Json, Response}, @@ -282,6 +282,52 @@ struct BatchStopResponse { results: Vec, } +#[derive(Debug, Serialize, ToSchema)] +struct DetectedProfilesResponse { + profiles: Vec, + total: usize, +} + +#[derive(Debug, Deserialize)] +struct DetectImportQuery { + /// Optional folder to scan instead of the default browser locations. + folder: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +struct ImportProfilesRequest { + /// Profiles to import. Each item is isolated — one failure doesn't stop the rest. + items: Vec, + /// Optional group to assign every imported profile to. + group_id: Option, + /// How to handle an already-taken profile name: "skip" or "rename" + /// (auto-suffix). Defaults to "rename". + duplicate_strategy: Option, + /// Wayfern fingerprint/config applied to every imported profile. Omit to + /// have fresh fingerprints generated automatically. + #[schema(value_type = Option)] + wayfern_config: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +struct ImportProxiesRequest { + /// "txt" — one proxy per line (`host:port`, `host:port:user:pass`, or URL + /// forms like `http://user:pass@host:port`). "json" — a Donut proxy export. + format: String, + /// Raw proxy list / export content. + content: String, + /// Name prefix for txt imports; proxies are named "{prefix} Proxy {n}". + name_prefix: Option, +} + +#[derive(Debug, Serialize, ToSchema)] +struct ImportProxiesResponse { + imported_count: usize, + skipped_count: usize, + errors: Vec, + proxies: Vec, +} + #[derive(OpenApi)] #[openapi( paths( @@ -295,6 +341,8 @@ struct BatchStopResponse { kill_profile, batch_run_profiles, batch_stop_profiles, + detect_import_profiles, + import_profiles_api, import_profile_cookies, get_groups, get_group, @@ -305,6 +353,7 @@ struct BatchStopResponse { get_proxies, get_proxy, create_proxy, + import_proxies_api, update_proxy, delete_proxy, get_vpns, @@ -353,6 +402,15 @@ struct BatchStopResponse { ImportCookiesRequest, ImportCookiesResponse, ProxySettings, + DetectedProfilesResponse, + ImportProfilesRequest, + ImportProxiesRequest, + ImportProxiesResponse, + crate::profile_importer::DetectedProfile, + crate::profile_importer::ImportProfileItem, + crate::profile_importer::DuplicateStrategy, + crate::profile_importer::ProfileImportItemResult, + crate::profile_importer::ProfileImportBatchResult, )), tags( (name = "profiles", description = "Profile management endpoints"), @@ -451,11 +509,14 @@ impl ApiServer { .routes(routes!(kill_profile)) .routes(routes!(batch_run_profiles)) .routes(routes!(batch_stop_profiles)) + .routes(routes!(detect_import_profiles)) + .routes(routes!(import_profiles_api)) .routes(routes!(import_profile_cookies)) .routes(routes!(get_groups, create_group)) .routes(routes!(get_group, update_group, delete_group)) .routes(routes!(get_tags)) .routes(routes!(get_proxies, create_proxy)) + .routes(routes!(import_proxies_api)) .routes(routes!(get_proxy, update_proxy, delete_proxy)) .routes(routes!(get_vpns, create_vpn)) .routes(routes!(import_vpn)) @@ -1477,6 +1538,77 @@ async fn create_proxy( } } +// API Handler - Bulk-import proxies from a txt list or a Donut JSON export. +// Mirrors the MCP `import_proxies` tool. +#[utoipa::path( + post, + path = "/v1/proxies/import", + request_body = ImportProxiesRequest, + responses( + (status = 200, description = "Import completed; inspect counts and per-proxy errors", body = ImportProxiesResponse), + (status = 400, description = "Invalid format or no valid proxies in content"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error") + ), + security( + ("bearer_auth" = []) + ), + tag = "proxies" +)] +async fn import_proxies_api( + State(state): State, + Json(request): Json, +) -> Result, (StatusCode, String)> { + let result = match request.format.as_str() { + "json" => PROXY_MANAGER + .import_proxies_json(&state.app_handle, &request.content) + .map_err(manager_error_response)?, + "txt" => { + use crate::proxy_manager::{ProxyManager, ProxyParseResult}; + + let parsed: Vec<_> = ProxyManager::parse_txt_proxies(&request.content) + .into_iter() + .filter_map(|r| match r { + ProxyParseResult::Parsed(p) => Some(p), + _ => None, + }) + .collect(); + + if parsed.is_empty() { + return Err(( + StatusCode::BAD_REQUEST, + "No valid proxies found in content".to_string(), + )); + } + + PROXY_MANAGER + .import_proxies_from_parsed(&state.app_handle, parsed, request.name_prefix) + .map_err(manager_error_response)? + } + other => { + return Err(( + StatusCode::BAD_REQUEST, + format!("Invalid format \"{other}\", must be \"json\" or \"txt\""), + )) + } + }; + + Ok(Json(ImportProxiesResponse { + imported_count: result.imported_count, + skipped_count: result.skipped_count, + errors: result.errors, + proxies: result + .proxies + .into_iter() + .map(|p| ApiProxyResponse { + id: p.id, + name: p.name, + proxy_settings: p.proxy_settings, + }) + .collect(), + })) +} + #[utoipa::path( put, path = "/v1/proxies/{id}", @@ -2218,6 +2350,93 @@ async fn batch_stop_profiles( Ok(Json(BatchStopResponse { results })) } +// API Handler - Detect importable browser profiles on this machine, or scan a +// custom folder. Free: importing is not gated behind browser automation. +#[utoipa::path( + get, + path = "/v1/profiles/import/detect", + params( + ("folder" = Option, Query, description = "Optional folder to scan instead of the default browser locations. Accepts a single profile dir, a Chromium user-data dir, or a folder holding one profile dir per child.") + ), + responses( + (status = 200, description = "Detected importable profiles", body = DetectedProfilesResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Folder not found"), + (status = 500, description = "Internal server error") + ), + security( + ("bearer_auth" = []) + ), + tag = "profiles" +)] +async fn detect_import_profiles( + Query(query): Query, + State(_state): State, +) -> Result, (StatusCode, String)> { + let importer = crate::profile_importer::ProfileImporter::instance(); + let profiles = match query.folder.as_deref() { + Some(folder) => importer.scan_folder(std::path::Path::new(folder)), + None => importer.detect_existing_profiles(), + } + .map_err(manager_error_response)?; + let total = profiles.len(); + Ok(Json(DetectedProfilesResponse { profiles, total })) +} + +// API Handler - Bulk-import browser profiles from on-disk profile folders. +// Free (parity with create_profile); only fingerprint OS spoofing is Pro. +// Items are isolated — one failure doesn't stop the rest. +#[utoipa::path( + post, + path = "/v1/profiles/import", + request_body = ImportProfilesRequest, + responses( + (status = 200, description = "Batch import completed; inspect per-item results", body = crate::profile_importer::ProfileImportBatchResult), + (status = 400, description = "No items, or invalid input"), + (status = 401, description = "Unauthorized"), + (status = 402, description = "Fingerprint OS spoofing requires an active Pro subscription"), + (status = 404, description = "Group not found"), + (status = 500, description = "Internal server error") + ), + security( + ("bearer_auth" = []) + ), + tag = "profiles" +)] +async fn import_profiles_api( + State(state): State, + Json(request): Json, +) -> Result, (StatusCode, String)> { + let wayfern_config: Option = request + .wayfern_config + .as_ref() + .and_then(|config| serde_json::from_value(config.clone()).ok()); + + let fingerprint_os = wayfern_config.as_ref().and_then(|c| c.os.as_deref()); + if !crate::cloud_auth::CLOUD_AUTH + .is_fingerprint_os_allowed(fingerprint_os) + .await + { + return Err(( + StatusCode::PAYMENT_REQUIRED, + "Fingerprint OS spoofing requires an active Pro subscription.".to_string(), + )); + } + + let importer = crate::profile_importer::ProfileImporter::instance(); + importer + .import_profiles( + &state.app_handle, + request.items, + request.group_id, + request.duplicate_strategy.unwrap_or_default(), + wayfern_config, + ) + .await + .map(Json) + .map_err(manager_error_response) +} + #[utoipa::path( post, path = "/v1/profiles/{id}/cookies/import", @@ -2477,6 +2696,35 @@ mod tests { !update_proxy.iter().any(|f| f == "proxy_settings"), "proxy_settings must be optional on update, required list: {update_proxy:?}" ); + + let import_profiles = schema_required(&spec, "ImportProfilesRequest"); + for field in ["group_id", "duplicate_strategy", "wayfern_config"] { + assert!( + !import_profiles.iter().any(|f| f == field), + "{field} must be optional on profile import, required list: {import_profiles:?}" + ); + } + + let import_item = schema_required(&spec, "ImportProfileItem"); + for field in ["proxy_id", "browser_type"] { + assert!( + !import_item.iter().any(|f| f == field), + "{field} must be optional on import items, required list: {import_item:?}" + ); + } + } + + #[test] + fn import_profiles_request_allows_minimal_body() { + // Only items with source_path + new_profile_name are required; everything + // else has defaults. + let json = r#"{"items": [{"source_path": "/tmp/p", "new_profile_name": "Imported"}]}"#; + let parsed: ImportProfilesRequest = + serde_json::from_str(json).expect("minimal import body must deserialize"); + assert_eq!(parsed.items.len(), 1); + assert!(parsed.group_id.is_none()); + assert!(parsed.duplicate_strategy.is_none()); + assert_eq!(parsed.items[0].browser_type, "chromium"); } // The served /openapi.json comes from the hand-maintained ApiDoc `paths(...)` @@ -2494,6 +2742,9 @@ mod tests { "/v1/extension-groups", "/v1/extensions/{id}", "/v1/extension-groups/{id}", + "/v1/profiles/import", + "/v1/profiles/import/detect", + "/v1/proxies/import", ] { assert!(paths.contains_key(path), "missing from ApiDoc: {path}"); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index db7f185..c1954be 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -125,7 +125,10 @@ use app_auto_updater::{ restart_application, }; -use profile_importer::{detect_existing_profiles, import_browser_profile}; +use profile_importer::{ + cleanup_profile_import_scratch, detect_existing_profiles, import_browser_profiles, + scan_folder_for_profiles, scan_profile_archive, +}; use extension_manager::{ add_extension, add_extension_to_group, assign_extension_group_to_profile, create_extension_group, @@ -2268,7 +2271,10 @@ pub fn run() { download_and_prepare_app_update, restart_application, detect_existing_profiles, - import_browser_profile, + import_browser_profiles, + scan_folder_for_profiles, + scan_profile_archive, + cleanup_profile_import_scratch, check_missing_binaries, check_missing_geoip_database, ensure_all_binaries_exist, diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 4ac16e6..ddfe0ca 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -638,6 +638,60 @@ impl McpServer { "required": ["name", "browser"] }), }, + McpTool { + name: "detect_browser_profiles".to_string(), + description: "Detect importable Chromium-family browser profiles (Chrome, Chromium, Brave) on this machine, or scan a custom folder for profile directories".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "folder": { + "type": "string", + "description": "Optional folder to scan instead of the default browser locations. Accepts a single profile dir, a Chromium user-data dir, or a folder holding one profile dir per child." + } + } + }), + }, + McpTool { + name: "import_browser_profiles".to_string(), + description: "Bulk-import browser profiles from on-disk profile folders (e.g. paths returned by detect_browser_profiles). Each imported profile becomes a Wayfern profile; items are isolated so one failure doesn't stop the rest".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "source_path": { + "type": "string", + "description": "Path to the source profile directory" + }, + "new_profile_name": { + "type": "string", + "description": "Name for the imported profile" + }, + "proxy_id": { + "type": "string", + "description": "Optional proxy UUID to assign to this profile" + } + }, + "required": ["source_path", "new_profile_name"] + }, + "description": "Profiles to import" + }, + "group_id": { + "type": "string", + "description": "Optional group UUID assigned to every imported profile" + }, + "duplicate_strategy": { + "type": "string", + "enum": ["skip", "rename"], + "description": "How to handle an already-taken profile name (default: rename with a numeric suffix)" + } + }, + "required": ["items"] + }), + }, McpTool { name: "update_profile".to_string(), description: "Update an existing browser profile's settings".to_string(), @@ -1731,6 +1785,9 @@ impl McpServer { self.handle_batch_stop_profiles(arguments).await } "create_profile" => self.handle_create_profile(arguments).await, + // Profile import (free, like create_profile — importing is not automation) + "detect_browser_profiles" => self.handle_detect_browser_profiles(arguments).await, + "import_browser_profiles" => self.handle_import_browser_profiles(arguments).await, "update_profile" => self.handle_update_profile(arguments).await, "delete_profile" => self.handle_delete_profile(arguments).await, "list_tags" => self.handle_list_tags().await, @@ -3201,6 +3258,95 @@ impl McpServer { })) } + // Profile import handlers + async fn handle_detect_browser_profiles( + &self, + arguments: &serde_json::Value, + ) -> Result { + let importer = crate::profile_importer::ProfileImporter::instance(); + let profiles = match arguments.get("folder").and_then(|v| v.as_str()) { + Some(folder) => importer.scan_folder(std::path::Path::new(folder)), + None => importer.detect_existing_profiles(), + } + .map_err(|e| McpError { + code: -32000, + message: format!("Failed to detect profiles: {e}"), + })?; + + Ok(serde_json::json!({ + "content": [{ + "type": "text", + "text": serde_json::to_string_pretty(&profiles).unwrap_or_else(|_| "[]".to_string()) + }] + })) + } + + async fn handle_import_browser_profiles( + &self, + arguments: &serde_json::Value, + ) -> Result { + let items: Vec = arguments + .get("items") + .cloned() + .ok_or_else(|| McpError { + code: -32602, + message: "Missing items".to_string(), + }) + .and_then(|v| { + serde_json::from_value(v).map_err(|e| McpError { + code: -32602, + message: format!("Invalid items: {e}"), + }) + })?; + + let group_id = arguments + .get("group_id") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let duplicate_strategy = arguments + .get("duplicate_strategy") + .cloned() + .map(serde_json::from_value::) + .transpose() + .map_err(|e| McpError { + code: -32602, + message: format!("Invalid duplicate_strategy: {e}"), + })? + .unwrap_or_default(); + + // Clone the handle instead of holding the inner lock across a potentially + // multi-GB copy. + let app_handle = { + let inner = self.inner.lock().await; + inner.app_handle.clone().ok_or_else(|| McpError { + code: -32000, + message: "MCP server not properly initialized".to_string(), + })? + }; + + let result = crate::profile_importer::ProfileImporter::instance() + .import_profiles(&app_handle, items, group_id, duplicate_strategy, None) + .await + .map_err(|e| McpError { + code: -32000, + message: format!("Failed to import profiles: {e}"), + })?; + + Ok(serde_json::json!({ + "content": [{ + "type": "text", + "text": format!( + "Import complete: {} imported, {} skipped, {} failed\n{}", + result.imported_count, + result.skipped_count, + result.failed_count, + serde_json::to_string_pretty(&result.results).unwrap_or_default() + ) + }] + })) + } + // Cookie management handlers async fn handle_import_profile_cookies( &self, @@ -5304,6 +5450,9 @@ mod tests { assert!(tool_names.contains(&"run_profile")); assert!(tool_names.contains(&"kill_profile")); assert!(tool_names.contains(&"get_profile_status")); + // Profile import tools + assert!(tool_names.contains(&"detect_browser_profiles")); + assert!(tool_names.contains(&"import_browser_profiles")); // Group tools assert!(tool_names.contains(&"list_groups")); assert!(tool_names.contains(&"get_group")); diff --git a/src-tauri/src/profile_importer.rs b/src-tauri/src/profile_importer.rs index 4575346..d1970fb 100644 --- a/src-tauri/src/profile_importer.rs +++ b/src-tauri/src/profile_importer.rs @@ -1,16 +1,22 @@ use directories::BaseDirs; use serde::{Deserialize, Serialize}; use std::collections::HashSet; -use std::fs::{self, create_dir_all}; -use std::path::Path; +use std::fs::{self, create_dir_all, File}; +use std::io; +use std::path::{Path, PathBuf}; use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry; +use crate::events; use crate::profile::types::{get_host_os, BrowserProfile, SyncMode}; use crate::profile::ProfileManager; use crate::proxy_manager::PROXY_MANAGER; use crate::wayfern_manager::WayfernConfig; -#[derive(Debug, Serialize, Deserialize, Clone)] +/// Prefix for temp directories that hold extracted profile archives. Cleanup +/// refuses to delete anything outside the system temp dir with this prefix. +const IMPORT_SCRATCH_PREFIX: &str = "donutbrowser-profile-import-"; + +#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)] pub struct DetectedProfile { pub browser: String, pub mapped_browser: String, @@ -19,11 +25,113 @@ pub struct DetectedProfile { pub description: String, } +#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)] +pub struct ImportProfileItem { + pub source_path: String, + #[serde(default = "default_import_browser_type")] + pub browser_type: String, + pub new_profile_name: String, + #[serde(default)] + pub proxy_id: Option, +} + +fn default_import_browser_type() -> String { + "chromium".to_string() +} + +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, utoipa::ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum DuplicateStrategy { + /// Skip items whose requested name is already taken. + Skip, + /// Auto-suffix the requested name (`Name (2)`, `Name (3)`, …) until free. + #[default] + Rename, +} + +#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)] +pub struct ProfileImportItemResult { + /// Final profile name (after any duplicate-rename). + pub name: String, + pub source_path: String, + /// "imported" | "skipped" | "failed" + pub status: String, + pub profile_id: Option, + /// Structured `{"code": …}` error string when status is "failed". + pub error: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)] +pub struct ProfileImportBatchResult { + pub imported_count: usize, + pub skipped_count: usize, + pub failed_count: usize, + pub results: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)] +pub struct ArchiveScanResult { + /// Temp directory the archive was extracted into. Pass back to + /// `cleanup_profile_import_scratch` once the import is done. + pub extracted_dir: String, + pub profiles: Vec, +} + +#[derive(Debug, Serialize, Clone)] +struct ProfileImportProgress { + total: usize, + completed: usize, + index: usize, + name: String, + /// "importing" | "imported" | "skipped" | "failed" + status: String, +} + fn map_browser_type(_browser: &str) -> &str { // Every import source maps to Wayfern — the only launchable engine. "wayfern" } +/// Convert an importer error into the structured `{"code": …}` string the +/// frontend translates. Errors that are already structured pass through. +pub fn error_to_code_string(e: Box) -> String { + let msg = e.to_string(); + if msg.starts_with('{') { + msg + } else { + serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": msg } }).to_string() + } +} + +/// Resolve a requested profile name against the set of taken (lowercased) +/// names by appending ` (2)`, ` (3)`, … . The chosen name is added to `taken`. +fn resolve_duplicate_name(requested: &str, taken: &mut HashSet) -> String { + if taken.insert(requested.to_lowercase()) { + return requested.to_string(); + } + let mut n = 2usize; + loop { + let candidate = format!("{requested} ({n})"); + if taken.insert(candidate.to_lowercase()) { + return candidate; + } + n += 1; + } +} + +fn emit_import_progress(total: usize, completed: usize, index: usize, name: &str, status: &str) { + let _ = events::emit( + "profile-import-progress", + &ProfileImportProgress { + total, + completed, + index, + name: name.to_string(), + status: status.to_string(), + }, + ); +} + pub struct ProfileImporter { base_dirs: BaseDirs, downloaded_browsers_registry: &'static DownloadedBrowsersRegistry, @@ -66,6 +174,181 @@ impl ProfileImporter { Ok(unique_profiles) } + /// Scan an arbitrary folder for importable Chromium-family profiles. + /// Handles three shapes: the folder itself is a profile (has `Preferences`), + /// the folder is a user-data dir (`Default` / `Profile N` children), or the + /// folder holds one profile directory per child (exported/migrated layouts, + /// including one nested user-data dir per child). + pub fn scan_folder( + &self, + folder: &Path, + ) -> Result, Box> { + if !folder.exists() || !folder.is_dir() { + return Err( + serde_json::json!({ "code": "IMPORT_SOURCE_NOT_FOUND" }) + .to_string() + .into(), + ); + } + + if folder.join("Preferences").exists() { + let name = folder + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("Imported profile") + .to_string(); + return Ok(vec![DetectedProfile { + browser: "chromium".to_string(), + mapped_browser: map_browser_type("chromium").to_string(), + name, + path: folder.to_string_lossy().to_string(), + description: "Chromium profile".to_string(), + }]); + } + + let mut profiles = self.scan_chrome_profiles_dir(folder, "chromium")?; + let mut seen: HashSet = profiles.iter().map(|p| p.path.clone()).collect(); + + if let Ok(entries) = fs::read_dir(folder) { + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let dir_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string(); + + if path.join("Preferences").exists() { + let path_str = path.to_string_lossy().to_string(); + if seen.insert(path_str.clone()) { + profiles.push(DetectedProfile { + browser: "chromium".to_string(), + mapped_browser: map_browser_type("chromium").to_string(), + name: dir_name, + path: path_str, + description: "Chromium profile".to_string(), + }); + } + } else { + for nested in self.scan_chrome_profiles_dir(&path, "chromium")? { + if seen.insert(nested.path.clone()) { + profiles.push(DetectedProfile { + name: format!("{} - {}", dir_name, nested.description), + ..nested + }); + } + } + } + } + } + + Ok(profiles) + } + + /// Extract a ZIP archive into a scratch temp dir and scan it for profiles. + pub async fn extract_archive_and_scan( + &self, + archive_path: &str, + ) -> Result> { + let path = Path::new(archive_path); + if !path.exists() { + return Err( + serde_json::json!({ "code": "IMPORT_SOURCE_NOT_FOUND" }) + .to_string() + .into(), + ); + } + + let extension = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + if extension != "zip" { + return Err( + serde_json::json!({ "code": "UNSUPPORTED_ARCHIVE_FORMAT" }) + .to_string() + .into(), + ); + } + + let dest = + std::env::temp_dir().join(format!("{IMPORT_SCRATCH_PREFIX}{}", uuid::Uuid::new_v4())); + let archive = path.to_path_buf(); + let dest_clone = dest.clone(); + tokio::task::spawn_blocking(move || Self::extract_zip_archive(&archive, &dest_clone)) + .await + .map_err(|e| format!("Archive extraction task failed: {e}"))? + .map_err(|e| { + let _ = fs::remove_dir_all(&dest); + serde_json::json!({ "code": "ARCHIVE_EXTRACTION_FAILED", "params": { "detail": e } }) + .to_string() + })?; + + let profiles = self.scan_folder(&dest)?; + Ok(ArchiveScanResult { + extracted_dir: dest.to_string_lossy().to_string(), + profiles, + }) + } + + fn extract_zip_archive(zip_path: &Path, dest: &Path) -> Result<(), String> { + create_dir_all(dest).map_err(|e| e.to_string())?; + let file = File::open(zip_path) + .map_err(|e| format!("Failed to open ZIP file {}: {}", zip_path.display(), e))?; + let mut archive = zip::ZipArchive::new(io::BufReader::new(file)) + .map_err(|e| format!("Failed to read ZIP archive {}: {}", zip_path.display(), e))?; + + for i in 0..archive.len() { + let mut entry = archive + .by_index(i) + .map_err(|e| format!("Failed to read ZIP entry at index {i}: {e}"))?; + + // enclosed_name prevents path traversal via ../ entries. + let enclosed = entry + .enclosed_name() + .ok_or_else(|| format!("ZIP contains an invalid entry path: {}", entry.name()))?; + let outpath = dest.join(enclosed); + + if entry.is_dir() { + create_dir_all(&outpath).map_err(|e| e.to_string())?; + } else { + if let Some(parent) = outpath.parent() { + create_dir_all(parent).map_err(|e| e.to_string())?; + } + let mut outfile = File::create(&outpath) + .map_err(|e| format!("Failed to create file {}: {}", outpath.display(), e))?; + io::copy(&mut entry, &mut outfile) + .map_err(|e| format!("Failed to extract file {}: {}", outpath.display(), e))?; + } + } + + Ok(()) + } + + /// Remove a scratch dir created by `extract_archive_and_scan`. Refuses paths + /// outside the system temp dir or without the import-scratch prefix. + pub fn cleanup_scratch_dir(extracted_dir: &str) -> Result<(), String> { + let path = PathBuf::from(extracted_dir); + let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if !path.starts_with(std::env::temp_dir()) || !dir_name.starts_with(IMPORT_SCRATCH_PREFIX) { + return Err( + serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": "Refusing to remove a non-scratch directory" } }) + .to_string(), + ); + } + if path.exists() { + fs::remove_dir_all(&path).map_err(|e| { + serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } }) + .to_string() + })?; + } + Ok(()) + } + fn detect_chrome_profiles(&self) -> Result, Box> { let mut profiles = Vec::new(); @@ -216,6 +499,139 @@ impl ProfileImporter { } } + /// Import a batch of profiles. Items are isolated: one failure doesn't stop + /// the rest. Emits `profile-import-progress` events around each item. + pub async fn import_profiles( + &self, + app_handle: &tauri::AppHandle, + items: Vec, + group_id: Option, + duplicate_strategy: DuplicateStrategy, + wayfern_config: Option, + ) -> Result> { + if items.is_empty() { + return Err( + serde_json::json!({ "code": "IMPORT_NO_ITEMS" }) + .to_string() + .into(), + ); + } + + if let Some(ref gid) = group_id { + let groups = crate::group_manager::GroupManager::new().get_all_groups()?; + if !groups.iter().any(|g| &g.id == gid) { + return Err( + serde_json::json!({ "code": "GROUP_NOT_FOUND" }) + .to_string() + .into(), + ); + } + } + + let mut taken_names: HashSet = self + .profile_manager + .list_profiles()? + .iter() + .map(|p| p.name.to_lowercase()) + .collect(); + + let total = items.len(); + let mut results = Vec::with_capacity(total); + let mut imported_count = 0usize; + let mut skipped_count = 0usize; + let mut failed_count = 0usize; + let mut completed = 0usize; + + for (index, item) in items.into_iter().enumerate() { + let requested = item.new_profile_name.trim().to_string(); + if requested.is_empty() { + failed_count += 1; + completed += 1; + emit_import_progress(total, completed, index, &item.source_path, "failed"); + results.push(ProfileImportItemResult { + name: requested, + source_path: item.source_path, + status: "failed".to_string(), + profile_id: None, + error: Some(serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" }).to_string()), + }); + continue; + } + + let final_name = if taken_names.contains(&requested.to_lowercase()) { + match duplicate_strategy { + DuplicateStrategy::Skip => { + skipped_count += 1; + completed += 1; + emit_import_progress(total, completed, index, &requested, "skipped"); + results.push(ProfileImportItemResult { + name: requested, + source_path: item.source_path, + status: "skipped".to_string(), + profile_id: None, + error: None, + }); + continue; + } + DuplicateStrategy::Rename => resolve_duplicate_name(&requested, &mut taken_names), + } + } else { + taken_names.insert(requested.to_lowercase()); + requested + }; + + emit_import_progress(total, completed, index, &final_name, "importing"); + + match self + .import_profile( + app_handle, + &item.source_path, + &item.browser_type, + &final_name, + item.proxy_id.clone(), + group_id.clone(), + wayfern_config.clone(), + ) + .await + { + Ok(profile) => { + imported_count += 1; + completed += 1; + emit_import_progress(total, completed, index, &final_name, "imported"); + let _ = events::emit_empty("profiles-changed"); + results.push(ProfileImportItemResult { + name: final_name, + source_path: item.source_path, + status: "imported".to_string(), + profile_id: Some(profile.id.to_string()), + error: None, + }); + } + Err(e) => { + failed_count += 1; + completed += 1; + emit_import_progress(total, completed, index, &final_name, "failed"); + // The name was reserved but the import failed — free it again. + taken_names.remove(&final_name.to_lowercase()); + results.push(ProfileImportItemResult { + name: final_name, + source_path: item.source_path, + status: "failed".to_string(), + profile_id: None, + error: Some(error_to_code_string(e)), + }); + } + } + } + + Ok(ProfileImportBatchResult { + imported_count, + skipped_count, + failed_count, + results, + }) + } + #[allow(clippy::too_many_arguments)] pub async fn import_profile( &self, @@ -224,11 +640,16 @@ impl ProfileImporter { browser_type: &str, new_profile_name: &str, proxy_id: Option, + group_id: Option, wayfern_config: Option, - ) -> Result<(), Box> { + ) -> Result> { let source_path = Path::new(source_path); if !source_path.exists() { - return Err("Source profile path does not exist".into()); + return Err( + serde_json::json!({ "code": "IMPORT_SOURCE_NOT_FOUND" }) + .to_string() + .into(), + ); } let mapped = map_browser_type(browser_type); @@ -244,7 +665,11 @@ impl ProfileImporter { .iter() .any(|p| p.name.to_lowercase() == new_profile_name.to_lowercase()) { - return Err(format!("Profile with name '{new_profile_name}' already exists").into()); + return Err( + serde_json::json!({ "code": "PROFILE_NAME_EXISTS", "params": { "name": new_profile_name } }) + .to_string() + .into(), + ); } let profile_id = uuid::Uuid::new_v4(); @@ -255,9 +680,30 @@ impl ProfileImporter { create_dir_all(&new_profile_uuid_dir)?; create_dir_all(&new_profile_data_dir)?; - Self::copy_directory_recursive(source_path, &new_profile_data_dir)?; + // Profile dirs can be multiple GB — keep the copy off the async runtime. + let copy_source = source_path.to_path_buf(); + let copy_dest = new_profile_data_dir.clone(); + let copy_result = tokio::task::spawn_blocking(move || { + Self::copy_directory_recursive(©_source, ©_dest).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("Profile copy task failed: {e}"))?; + if let Err(e) = copy_result { + let _ = fs::remove_dir_all(&new_profile_uuid_dir); + return Err( + serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e } }) + .to_string() + .into(), + ); + } - let version = self.get_default_version_for_browser(mapped)?; + let version = match self.get_default_version_for_browser(mapped) { + Ok(version) => version, + Err(e) => { + let _ = fs::remove_dir_all(&new_profile_uuid_dir); + return Err(e); + } + }; let final_wayfern_config = if mapped == "wayfern" { let mut config = wayfern_config.unwrap_or_default(); @@ -328,10 +774,13 @@ impl ProfileImporter { // launch's signature-mismatch refresh verifies the location either way. Ok((fp, _geolocation_applied)) => config.fingerprint = Some(fp), Err(e) => { + let _ = fs::remove_dir_all(&new_profile_uuid_dir); return Err( - format!( - "Failed to generate fingerprint for imported profile '{new_profile_name}': {e}" - ) + serde_json::json!({ + "code": "INTERNAL_ERROR", + "params": { "detail": format!("Failed to generate fingerprint for imported profile '{new_profile_name}': {e}") } + }) + .to_string() .into(), ); } @@ -356,7 +805,7 @@ impl ProfileImporter { last_launch: None, release_type: "stable".to_string(), wayfern_config: final_wayfern_config, - group_id: None, + group_id, tags: Vec::new(), note: None, window_color: None, @@ -388,7 +837,7 @@ impl ProfileImporter { source_path.display() ); - Ok(()) + Ok(profile) } fn get_default_version_for_browser( @@ -404,11 +853,11 @@ impl ProfileImporter { } Err( - format!( - "No downloaded versions found for browser '{}'. Please download a version of {} first before importing profiles.", - browser_type, - self.get_browser_display_name(browser_type) - ) + serde_json::json!({ + "code": "BROWSER_NOT_DOWNLOADED", + "params": { "browser": self.get_browser_display_name(browser_type) } + }) + .to_string() .into(), ) } @@ -442,39 +891,59 @@ pub async fn detect_existing_profiles() -> Result, String> let importer = ProfileImporter::instance(); importer .detect_existing_profiles() - .map_err(|e| format!("Failed to detect existing profiles: {e}")) + .map_err(error_to_code_string) } #[tauri::command] -pub async fn import_browser_profile( +pub async fn scan_folder_for_profiles(folder_path: String) -> Result, String> { + let importer = ProfileImporter::instance(); + importer + .scan_folder(Path::new(&folder_path)) + .map_err(error_to_code_string) +} + +#[tauri::command] +pub async fn scan_profile_archive(archive_path: String) -> Result { + let importer = ProfileImporter::instance(); + importer + .extract_archive_and_scan(&archive_path) + .await + .map_err(error_to_code_string) +} + +#[tauri::command] +pub async fn cleanup_profile_import_scratch(extracted_dir: String) -> Result<(), String> { + ProfileImporter::cleanup_scratch_dir(&extracted_dir) +} + +#[tauri::command] +pub async fn import_browser_profiles( app_handle: tauri::AppHandle, - source_path: String, - browser_type: String, - new_profile_name: String, - proxy_id: Option, + items: Vec, + group_id: Option, + duplicate_strategy: Option, wayfern_config: Option, -) -> Result<(), String> { +) -> Result { let fingerprint_os = wayfern_config.as_ref().and_then(|c| c.os.as_deref()); if !crate::cloud_auth::CLOUD_AUTH .is_fingerprint_os_allowed(fingerprint_os) .await { - return Err("Fingerprint OS spoofing requires an active Pro subscription".to_string()); + return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string()); } let importer = ProfileImporter::instance(); importer - .import_profile( + .import_profiles( &app_handle, - &source_path, - &browser_type, - &new_profile_name, - proxy_id, + items, + group_id, + duplicate_strategy.unwrap_or_default(), wayfern_config, ) .await - .map_err(|e| format!("Failed to import profile: {e}")) + .map_err(error_to_code_string) } lazy_static::lazy_static! { @@ -596,8 +1065,98 @@ mod tests { let error_msg = result.unwrap_err().to_string(); assert!( - error_msg.contains("No downloaded versions found"), - "Error should mention no versions found" + error_msg.contains("BROWSER_NOT_DOWNLOADED"), + "Error should carry the BROWSER_NOT_DOWNLOADED code" ); } + + #[test] + fn test_resolve_duplicate_name() { + let mut taken: HashSet = ["existing".to_string()].into_iter().collect(); + + assert_eq!(resolve_duplicate_name("Fresh", &mut taken), "Fresh"); + // Case-insensitive collision gets a suffix. + assert_eq!( + resolve_duplicate_name("Existing", &mut taken), + "Existing (2)" + ); + assert_eq!( + resolve_duplicate_name("Existing", &mut taken), + "Existing (3)" + ); + // The fresh name reserved above now collides too. + assert_eq!(resolve_duplicate_name("fresh", &mut taken), "fresh (2)"); + } + + #[test] + fn test_scan_folder_single_profile() { + let (importer, temp_dir) = create_test_profile_importer(); + + let profile_dir = temp_dir.path().join("my-profile"); + fs::create_dir_all(&profile_dir).unwrap(); + fs::write(profile_dir.join("Preferences"), "{}").unwrap(); + + let profiles = importer.scan_folder(&profile_dir).unwrap(); + assert_eq!(profiles.len(), 1); + assert_eq!(profiles[0].name, "my-profile"); + assert_eq!(profiles[0].mapped_browser, "wayfern"); + } + + #[test] + fn test_scan_folder_user_data_dir_and_children() { + let (importer, temp_dir) = create_test_profile_importer(); + + let root = temp_dir.path().join("exported"); + // User-data-dir shape. + fs::create_dir_all(root.join("Default")).unwrap(); + fs::write(root.join("Default/Preferences"), "{}").unwrap(); + fs::create_dir_all(root.join("Profile 2")).unwrap(); + fs::write(root.join("Profile 2/Preferences"), "{}").unwrap(); + // Free-form exported profile dir. + fs::create_dir_all(root.join("account-a")).unwrap(); + fs::write(root.join("account-a/Preferences"), "{}").unwrap(); + // Nested user-data-dir one level down. + fs::create_dir_all(root.join("old-chrome/Default")).unwrap(); + fs::write(root.join("old-chrome/Default/Preferences"), "{}").unwrap(); + // Noise: dir without Preferences anywhere. + fs::create_dir_all(root.join("random")).unwrap(); + + let profiles = importer.scan_folder(&root).unwrap(); + let mut names: Vec = profiles.iter().map(|p| p.name.clone()).collect(); + names.sort(); + assert_eq!(profiles.len(), 4, "should find 4 profiles: {names:?}"); + assert!(names.contains(&"account-a".to_string())); + assert!(names.iter().any(|n| n.contains("old-chrome"))); + } + + #[test] + fn test_scan_folder_missing() { + let (importer, temp_dir) = create_test_profile_importer(); + + let result = importer.scan_folder(&temp_dir.path().join("nope")); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("IMPORT_SOURCE_NOT_FOUND")); + } + + #[test] + fn test_cleanup_scratch_dir_refuses_foreign_paths() { + let temp_dir = TempDir::new().unwrap(); + let foreign = temp_dir.path().join("not-scratch"); + fs::create_dir_all(&foreign).unwrap(); + + let result = ProfileImporter::cleanup_scratch_dir(&foreign.to_string_lossy()); + assert!( + result.is_err(), + "must refuse dirs without the scratch prefix" + ); + assert!(foreign.exists()); + + let scratch = std::env::temp_dir().join(format!("{IMPORT_SCRATCH_PREFIX}test-cleanup")); + fs::create_dir_all(&scratch).unwrap(); + ProfileImporter::cleanup_scratch_dir(&scratch.to_string_lossy()).unwrap(); + assert!(!scratch.exists(), "scratch dir should be removed"); + } } diff --git a/src/components/import-profile-dialog.tsx b/src/components/import-profile-dialog.tsx index 55d22d7..a992712 100644 --- a/src/components/import-profile-dialog.tsx +++ b/src/components/import-profile-dialog.tsx @@ -1,10 +1,11 @@ "use client"; import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; import { open } from "@tauri-apps/plugin-dialog"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { FaFolder } from "react-icons/fa"; +import { FaFileArchive, FaFolder } from "react-icons/fa"; import { toast } from "sonner"; import { LoadingButton } from "@/components/loading-button"; import { Alert, AlertDescription } from "@/components/ui/alert"; @@ -15,6 +16,7 @@ import { AnimatedTabsTrigger, } from "@/components/ui/animated-tabs"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, @@ -23,6 +25,7 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Progress } from "@/components/ui/progress"; import { Select, SelectContent, @@ -31,18 +34,21 @@ import { SelectValue, } from "@/components/ui/select"; import { WayfernConfigForm } from "@/components/wayfern-config-form"; -import { useBrowserSupport } from "@/hooks/use-browser-support"; +import { useGroupEvents } from "@/hooks/use-group-events"; import { useProxyEvents } from "@/hooks/use-proxy-events"; -import { parseBackendError, translateBackendError } from "@/lib/backend-errors"; +import { translateBackendError } from "@/lib/backend-errors"; import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils"; import { cn } from "@/lib/utils"; -import type { DetectedProfile, WayfernConfig } from "@/types"; +import type { + ArchiveScanResult, + DetectedProfile, + ImportProfileItem, + ProfileImportBatchResult, + ProfileImportProgress, + WayfernConfig, +} from "@/types"; import { RippleButton } from "./ui/ripple"; -const getMappedBrowser = (_browser: string): "wayfern" => { - return "wayfern"; -}; - interface ImportProfileDialogProps { isOpen: boolean; onClose: () => void; @@ -50,6 +56,10 @@ interface ImportProfileDialogProps { subPage?: boolean; } +type Step = "select" | "configure" | "importing"; +type ImportMode = "auto-detect" | "manual"; +type DuplicateStrategy = "rename" | "skip"; + export function ImportProfileDialog({ isOpen, onClose, @@ -57,42 +67,56 @@ export function ImportProfileDialog({ subPage, }: ImportProfileDialogProps) { const { t } = useTranslation(); + const [currentStep, setCurrentStep] = useState("select"); + const [importMode, setImportMode] = useState("auto-detect"); + const [detectedProfiles, setDetectedProfiles] = useState( [], ); + const [scannedProfiles, setScannedProfiles] = useState([]); const [isLoading, setIsLoading] = useState(false); - const [isImporting, setIsImporting] = useState(false); - const [importMode, setImportMode] = useState<"auto-detect" | "manual">( - "auto-detect", - ); - const [currentStep, setCurrentStep] = useState<"select" | "configure">( - "select", - ); + const [isScanning, setIsScanning] = useState(false); + const [manualPath, setManualPath] = useState(""); + const [extractedDir, setExtractedDir] = useState(null); + + const [selectedPaths, setSelectedPaths] = useState>(new Set()); + const [profileNames, setProfileNames] = useState>({}); + + const [selectedGroupId, setSelectedGroupId] = useState("none"); + const [isCreatingGroup, setIsCreatingGroup] = useState(false); + const [newGroupName, setNewGroupName] = useState(""); + const [duplicateStrategy, setDuplicateStrategy] = + useState("rename"); + // "none" | "round-robin" | a stored proxy id + const [proxyAssignment, setProxyAssignment] = useState("none"); const [wayfernConfig, setWayfernConfig] = useState({}); - const [selectedProxyId, setSelectedProxyId] = useState(); - // Auto-detect state - const [selectedDetectedProfile, setSelectedDetectedProfile] = useState< - string | null - >(null); - const [autoDetectProfileName, setAutoDetectProfileName] = useState(""); + const [isImporting, setIsImporting] = useState(false); + const [progress, setProgress] = useState(null); + const [result, setResult] = useState(null); - // Manual import state - const [manualBrowserType, setManualBrowserType] = useState( - null, - ); - const [manualProfilePath, setManualProfilePath] = useState(""); - const [manualProfileName, setManualProfileName] = useState(""); - - const { supportedBrowsers, isLoading: isLoadingSupport } = - useBrowserSupport(); const { storedProxies } = useProxyEvents(); + const { groups } = useGroupEvents(); - // Only Chromium-family browsers can be imported as Wayfern profiles. - const importableBrowsers = supportedBrowsers.filter( - (browser) => getMappedBrowser(browser) === "wayfern", + const activeProfiles = + importMode === "auto-detect" ? detectedProfiles : scannedProfiles; + const selectedProfiles = useMemo( + () => activeProfiles.filter((p) => selectedPaths.has(p.path)), + [activeProfiles, selectedPaths], ); + const registerProfileNames = useCallback((profiles: DetectedProfile[]) => { + setProfileNames((prev) => { + const next = { ...prev }; + for (const profile of profiles) { + if (next[profile.path] === undefined) { + next[profile.path] = profile.name; + } + } + return next; + }); + }, []); + const loadDetectedProfiles = useCallback(async () => { setIsLoading(true); try { @@ -100,16 +124,9 @@ export function ImportProfileDialog({ "detect_existing_profiles", ); setDetectedProfiles(profiles); - + registerProfileNames(profiles); if (profiles.length === 0) { setImportMode("manual"); - } else { - setSelectedDetectedProfile(profiles[0].path); - - const profile = profiles[0]; - const browserName = getBrowserDisplayName(profile.browser); - const defaultName = `Imported ${browserName} Profile`; - setAutoDetectProfileName(defaultName); } } catch (error) { console.error("Failed to detect existing profiles:", error); @@ -117,10 +134,56 @@ export function ImportProfileDialog({ } finally { setIsLoading(false); } - }, [t]); + }, [t, registerProfileNames]); - const selectedProfile = detectedProfiles.find( - (p) => p.path === selectedDetectedProfile, + const cleanupExtractedDir = useCallback(async (dir: string | null) => { + if (!dir) return; + try { + await invoke("cleanup_profile_import_scratch", { extractedDir: dir }); + } catch (error) { + console.error("Failed to clean up extracted archive:", error); + } + }, []); + + const applyScanResult = useCallback( + (profiles: DetectedProfile[]) => { + setScannedProfiles(profiles); + registerProfileNames(profiles); + setSelectedPaths(new Set(profiles.map((p) => p.path))); + if (profiles.length === 0) { + toast.info(t("importProfile.noProfilesInLocation")); + } + }, + [registerProfileNames, t], + ); + + const scanPath = useCallback( + async (path: string) => { + setIsScanning(true); + try { + if (path.toLowerCase().endsWith(".zip")) { + await cleanupExtractedDir(extractedDir); + setExtractedDir(null); + const scan = await invoke("scan_profile_archive", { + archivePath: path, + }); + setExtractedDir(scan.extracted_dir); + applyScanResult(scan.profiles); + } else { + const profiles = await invoke( + "scan_folder_for_profiles", + { folderPath: path }, + ); + applyScanResult(profiles); + } + } catch (error) { + console.error("Failed to scan for profiles:", error); + toast.error(translateBackendError(t, error)); + } finally { + setIsScanning(false); + } + }, + [applyScanResult, cleanupExtractedDir, extractedDir, t], ); const handleBrowseFolder = async () => { @@ -130,9 +193,9 @@ export function ImportProfileDialog({ multiple: false, title: t("importProfile.selectFolderTitle"), }); - if (selected && typeof selected === "string") { - setManualProfilePath(selected); + setManualPath(selected); + await scanPath(selected); } } catch (error) { console.error("Failed to open folder dialog:", error); @@ -140,167 +203,226 @@ export function ImportProfileDialog({ } }; - const handleImport = useCallback(async () => { - let sourcePath: string; - let browserType: string; - let newProfileName: string; + const handleBrowseArchive = async () => { + try { + const selected = await open({ + multiple: false, + title: t("importProfile.selectArchiveTitle"), + filters: [{ name: "ZIP", extensions: ["zip"] }], + }); + if (selected && typeof selected === "string") { + setManualPath(selected); + await scanPath(selected); + } + } catch (error) { + console.error("Failed to open archive dialog:", error); + toast.error(t("importProfile.folderDialogFailed")); + } + }; - if (importMode === "auto-detect") { - if (!selectedDetectedProfile || !autoDetectProfileName.trim()) { - toast.error(t("importProfile.selectAndName")); - return; + const togglePath = (path: string, checked: boolean) => { + setSelectedPaths((prev) => { + const next = new Set(prev); + if (checked) { + next.add(path); + } else { + next.delete(path); } - const profile = detectedProfiles.find( - (p) => p.path === selectedDetectedProfile, + return next; + }); + }; + + const toggleAll = (checked: boolean) => { + setSelectedPaths( + checked ? new Set(activeProfiles.map((p) => p.path)) : new Set(), + ); + }; + + const proxyIdForIndex = useCallback( + (index: number): string | null => { + if (proxyAssignment === "none") return null; + if (proxyAssignment === "round-robin") { + if (storedProxies.length === 0) return null; + return storedProxies[index % storedProxies.length].id; + } + return proxyAssignment; + }, + [proxyAssignment, storedProxies], + ); + + const handleCreateGroup = async () => { + const name = newGroupName.trim(); + if (!name) return; + try { + const group = await invoke<{ id: string; name: string }>( + "create_profile_group", + { name }, ); - if (!profile) { - toast.error(t("importProfile.profileNotFound")); - return; - } - sourcePath = profile.path; - browserType = profile.browser; - newProfileName = autoDetectProfileName.trim(); - } else { - if ( - !manualBrowserType || - !manualProfilePath.trim() || - !manualProfileName.trim() - ) { - toast.error(t("importProfile.fillFields")); - return; - } - sourcePath = manualProfilePath.trim(); - browserType = manualBrowserType; - newProfileName = manualProfileName.trim(); + setSelectedGroupId(group.id); + setIsCreatingGroup(false); + setNewGroupName(""); + } catch (error) { + console.error("Failed to create group:", error); + toast.error(translateBackendError(t, error)); + } + }; + + const handleImport = useCallback(async () => { + if (selectedProfiles.length === 0) { + toast.error(t("importProfile.selectAtLeastOne")); + return; + } + if ( + selectedProfiles.some((p) => !(profileNames[p.path] ?? p.name).trim()) + ) { + toast.error(t("importProfile.emptyNames")); + return; } - const mappedBrowser = - importMode === "auto-detect" && selectedProfile - ? getMappedBrowser(selectedProfile.mapped_browser) - : getMappedBrowser(browserType); + const items: ImportProfileItem[] = selectedProfiles.map((p, index) => ({ + source_path: p.path, + browser_type: p.browser, + new_profile_name: (profileNames[p.path] ?? p.name).trim(), + proxy_id: proxyIdForIndex(index), + })); + setCurrentStep("importing"); setIsImporting(true); + setProgress(null); + setResult(null); try { - await invoke("import_browser_profile", { - sourcePath, - browserType, - newProfileName, - proxyId: selectedProxyId ?? null, - wayfernConfig: mappedBrowser === "wayfern" ? wayfernConfig : null, - }); - - toast.success( - t("importProfile.importedSuccess", { name: newProfileName }), + const batchResult = await invoke( + "import_browser_profiles", + { + items, + groupId: selectedGroupId === "none" ? null : selectedGroupId, + duplicateStrategy: duplicateStrategy, + wayfernConfig, + }, + ); + setResult(batchResult); + toast.success( + t("importProfile.resultsSummary", { + imported: batchResult.imported_count, + skipped: batchResult.skipped_count, + failed: batchResult.failed_count, + }), ); - onClose(); } catch (error) { - console.error("Failed to import profile:", error); - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (parseBackendError(error)) { - // Structured backend error (e.g. CAMOUFOX_IMPORT_DEPRECATED) — localize. - toast.error(translateBackendError(t, error)); - } else if (errorMessage.includes("No downloaded versions found")) { - const browserDisplayName = getBrowserDisplayName(browserType); - toast.error( - t("importProfile.notInstalled", { browser: browserDisplayName }), - { - duration: 8000, - }, - ); - } else { - toast.error(t("importProfile.importFailed", { error: errorMessage })); - } + console.error("Failed to import profiles:", error); + toast.error(translateBackendError(t, error)); + setCurrentStep("configure"); } finally { setIsImporting(false); } }, [ - importMode, - selectedDetectedProfile, - autoDetectProfileName, - detectedProfiles, - manualBrowserType, - manualProfilePath, - manualProfileName, - selectedProxyId, + selectedProfiles, + profileNames, + proxyIdForIndex, + selectedGroupId, + duplicateStrategy, wayfernConfig, - onClose, - selectedProfile, t, ]); const handleClose = () => { + void cleanupExtractedDir(extractedDir); setCurrentStep("select"); + setImportMode(detectedProfiles.length > 0 ? "auto-detect" : "manual"); + setScannedProfiles([]); + setManualPath(""); + setExtractedDir(null); + setSelectedPaths(new Set()); + setProfileNames({}); + setSelectedGroupId("none"); + setIsCreatingGroup(false); + setNewGroupName(""); + setDuplicateStrategy("rename"); + setProxyAssignment("none"); setWayfernConfig({}); - setSelectedProxyId(undefined); - setSelectedDetectedProfile(null); - setAutoDetectProfileName(""); - setManualBrowserType(null); - setManualProfilePath(""); - setManualProfileName(""); - if (detectedProfiles.length > 0) { - setImportMode("auto-detect"); - } else { - setImportMode("manual"); - } + setProgress(null); + setResult(null); onClose(); }; - useEffect(() => { - if (selectedDetectedProfile) { - const profile = detectedProfiles.find( - (p) => p.path === selectedDetectedProfile, - ); - if (profile) { - const browserName = getBrowserDisplayName(profile.browser); - const defaultName = `Old ${browserName}`; - setAutoDetectProfileName(defaultName); - } - } - }, [selectedDetectedProfile, detectedProfiles]); - - const currentMappedBrowser = useMemo(() => { - if (importMode === "auto-detect" && selectedProfile) { - return getMappedBrowser(selectedProfile.mapped_browser); - } - if (importMode === "manual" && manualBrowserType) { - return getMappedBrowser(manualBrowserType); - } - return null; - }, [importMode, selectedProfile, manualBrowserType]); - - const canProceedToNext = useMemo(() => { - if (importMode === "auto-detect") { - return ( - !isLoading && - !!selectedDetectedProfile && - !!autoDetectProfileName.trim() - ); - } - return ( - !!manualBrowserType && - !!manualProfilePath.trim() && - !!manualProfileName.trim() - ); - }, [ - importMode, - isLoading, - selectedDetectedProfile, - autoDetectProfileName, - manualBrowserType, - manualProfilePath, - manualProfileName, - ]); - useEffect(() => { if (isOpen) { void loadDetectedProfiles(); } }, [isOpen, loadDetectedProfiles]); + useEffect(() => { + if (!isOpen) return; + const unlistenPromise = listen( + "profile-import-progress", + (event) => { + setProgress(event.payload); + }, + ); + return () => { + void unlistenPromise.then((unlisten) => unlisten()); + }; + }, [isOpen]); + + const allSelected = + activeProfiles.length > 0 && selectedPaths.size >= activeProfiles.length; + + const renderProfileList = (profiles: DetectedProfile[]) => ( +
+
+ + + {t("importProfile.selectedCount", { count: selectedPaths.size })} + +
+
+ {profiles.map((profile) => { + const IconComponent = getBrowserIcon(profile.browser); + const checkboxId = `import-profile-${encodeURIComponent(profile.path)}`; + return ( + + ); + })} +
+
+ ); + + const progressPercent = + progress && progress.total > 0 + ? Math.round((progress.completed / progress.total) * 100) + : 0; + return ( - + {!subPage && ( @@ -317,9 +439,10 @@ export function ImportProfileDialog({ {currentStep === "select" && ( - setImportMode(v as "auto-detect" | "manual") - } + onValueChange={(v) => { + setImportMode(v as ImportMode); + setSelectedPaths(new Set()); + }} className="flex flex-col gap-6" > @@ -353,93 +476,7 @@ export function ImportProfileDialog({

) : ( -
-
- - -
- - {selectedProfile && ( -
-

- - {t("importProfile.pathLabel")} - {" "} - {selectedProfile.path} -

-

- - {t("importProfile.browserLabel")} - {" "} - {getBrowserDisplayName(selectedProfile.browser)} -

-
- )} - -
- - { - setAutoDetectProfileName(e.target.value); - }} - placeholder={t( - "importProfile.newProfileNamePlaceholder", - )} - /> -
-
+ renderProfileList(detectedProfiles) )} @@ -450,121 +487,199 @@ export function ImportProfileDialog({ {t("importProfile.manualTitle")} -
-
- - -
- -
- -
- { - setManualProfilePath(e.target.value); - }} - placeholder={t( - "importProfile.profileFolderPlaceholder", - )} - /> - -
-

- {t("importProfile.examplePaths")} -
- macOS: ~/Library/Application - Support/Google/Chrome/Default -
- Windows: %LOCALAPPDATA%\Google\Chrome\User Data\Default -
- Linux: ~/.config/google-chrome/Default -

-
- -
- +
+ +
{ - setManualProfileName(e.target.value); + setManualPath(e.target.value); }} placeholder={t( - "importProfile.newProfileNamePlaceholder", + "importProfile.profileFolderPlaceholder", )} /> + + + void scanPath(manualPath.trim())} + > + {t("importProfile.scanButton")} +
+

+ {t("importProfile.manualHint")} +

+

+ {t("importProfile.examplePaths")} +
+ macOS: ~/Library/Application Support/Google/Chrome/Default +
+ Windows: %LOCALAPPDATA%\Google\Chrome\User Data\Default +
+ Linux: ~/.config/google-chrome/Default +

+ + {scannedProfiles.length > 0 && + renderProfileList(scannedProfiles)}
)} - {currentStep === "configure" && currentMappedBrowser && ( + {currentStep === "configure" && (
{t("importProfile.importedAs", { - browser: getBrowserDisplayName(currentMappedBrowser), + browser: getBrowserDisplayName("wayfern"), })} +
+ +
+ {selectedProfiles.map((profile) => ( +
+ + {profile.name} + + { + setProfileNames((prev) => ({ + ...prev, + [profile.path]: e.target.value, + })); + }} + placeholder={t( + "importProfile.newProfileNamePlaceholder", + )} + /> +
+ ))} +
+
+ +
+ + {isCreatingGroup ? ( +
+ setNewGroupName(e.target.value)} + placeholder={t("importProfile.newGroupNamePlaceholder")} + /> + + +
+ ) : ( + + )} +
+ +
+ + +
+