feat: mass import via gui, api, and mcp

This commit is contained in:
zhom
2026-07-17 02:05:34 +04:00
parent e1c9ce6525
commit 4f7910dd23
16 changed files with 1905 additions and 540 deletions
+252 -1
View File
@@ -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<BatchStopResult>,
}
#[derive(Debug, Serialize, ToSchema)]
struct DetectedProfilesResponse {
profiles: Vec<crate::profile_importer::DetectedProfile>,
total: usize,
}
#[derive(Debug, Deserialize)]
struct DetectImportQuery {
/// Optional folder to scan instead of the default browser locations.
folder: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema)]
struct ImportProfilesRequest {
/// Profiles to import. Each item is isolated — one failure doesn't stop the rest.
items: Vec<crate::profile_importer::ImportProfileItem>,
/// Optional group to assign every imported profile to.
group_id: Option<String>,
/// How to handle an already-taken profile name: "skip" or "rename"
/// (auto-suffix). Defaults to "rename".
duplicate_strategy: Option<crate::profile_importer::DuplicateStrategy>,
/// Wayfern fingerprint/config applied to every imported profile. Omit to
/// have fresh fingerprints generated automatically.
#[schema(value_type = Option<Object>)]
wayfern_config: Option<serde_json::Value>,
}
#[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<String>,
}
#[derive(Debug, Serialize, ToSchema)]
struct ImportProxiesResponse {
imported_count: usize,
skipped_count: usize,
errors: Vec<String>,
proxies: Vec<ApiProxyResponse>,
}
#[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<ApiServerState>,
Json(request): Json<ImportProxiesRequest>,
) -> Result<Json<ImportProxiesResponse>, (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<String>, 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<DetectImportQuery>,
State(_state): State<ApiServerState>,
) -> Result<Json<DetectedProfilesResponse>, (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<ApiServerState>,
Json(request): Json<ImportProfilesRequest>,
) -> Result<Json<crate::profile_importer::ProfileImportBatchResult>, (StatusCode, String)> {
let wayfern_config: Option<crate::wayfern_manager::WayfernConfig> = 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}");
}
+8 -2
View File
@@ -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,
+149
View File
@@ -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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
let items: Vec<crate::profile_importer::ImportProfileItem> = 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::<crate::profile_importer::DuplicateStrategy>)
.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"));
+593 -34
View File
@@ -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<String>,
}
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<String>,
/// Structured `{"code": …}` error string when status is "failed".
pub error: Option<String>,
}
#[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<ProfileImportItemResult>,
}
#[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<DetectedProfile>,
}
#[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<dyn std::error::Error>) -> 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>) -> 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<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
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<String> = 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<ArchiveScanResult, Box<dyn std::error::Error>> {
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<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
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<ImportProfileItem>,
group_id: Option<String>,
duplicate_strategy: DuplicateStrategy,
wayfern_config: Option<WayfernConfig>,
) -> Result<ProfileImportBatchResult, Box<dyn std::error::Error>> {
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<String> = 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<String>,
group_id: Option<String>,
wayfern_config: Option<WayfernConfig>,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
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(&copy_source, &copy_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<Vec<DetectedProfile>, 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<Vec<DetectedProfile>, 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<ArchiveScanResult, String> {
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<String>,
items: Vec<ImportProfileItem>,
group_id: Option<String>,
duplicate_strategy: Option<DuplicateStrategy>,
wayfern_config: Option<WayfernConfig>,
) -> Result<(), String> {
) -> Result<ProfileImportBatchResult, String> {
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<String> = ["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<String> = 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");
}
}