mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-22 20:31:03 +02:00
feat: mass import via gui, api, and mcp
This commit is contained in:
+252
-1
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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(©_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<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");
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+32
-16
@@ -1378,16 +1378,9 @@
|
||||
"scanning": "Scanning for browser profiles...",
|
||||
"noneFound": "No browser profiles found on your system.",
|
||||
"noneFoundHint": "Try the manual import option if you have profiles in custom locations.",
|
||||
"selectProfile": "Select Profile:",
|
||||
"selectProfilePlaceholder": "Choose a detected profile",
|
||||
"pathLabel": "Path:",
|
||||
"browserLabel": "Browser:",
|
||||
"newProfileName": "New Profile Name:",
|
||||
"newProfileNamePlaceholder": "Enter a name for the imported profile",
|
||||
"manualTitle": "Manual Profile Import",
|
||||
"browserType": "Browser Type:",
|
||||
"loadingBrowsers": "Loading browsers...",
|
||||
"selectBrowserType": "Select browser type",
|
||||
"profileFolderPath": "Profile Folder Path:",
|
||||
"profileFolderPlaceholder": "Enter the full path to the profile folder",
|
||||
"browseFolderTitle": "Browse for folder",
|
||||
@@ -1395,17 +1388,34 @@
|
||||
"selectFolderTitle": "Select Browser Profile Folder",
|
||||
"folderDialogFailed": "Failed to open folder dialog",
|
||||
"detectFailed": "Failed to detect existing browser profiles",
|
||||
"fillFields": "Please fill in all fields",
|
||||
"selectAndName": "Please select a profile and provide a name",
|
||||
"profileNotFound": "Selected profile not found",
|
||||
"importedSuccess": "Successfully imported profile \"{{name}}\"",
|
||||
"notInstalled": "{{browser}} is not installed. Please download {{browser}} first from the main window, then try importing again.",
|
||||
"importFailed": "Failed to import profile: {{error}}",
|
||||
"proxyOptional": "Proxy (Optional)",
|
||||
"noProxy": "No proxy",
|
||||
"nextButton": "Next",
|
||||
"importButton": "Import",
|
||||
"importedAs": "This profile will be imported as a {{browser}} profile."
|
||||
"importedAs": "This profile will be imported as a {{browser}} profile.",
|
||||
"selectAll": "Select all",
|
||||
"selectedCount": "{{count}} selected",
|
||||
"scanButton": "Scan",
|
||||
"manualHint": "Choose a profile folder, a browser user-data folder, a folder of exported profiles, or a ZIP archive.",
|
||||
"selectArchiveTitle": "Select ZIP archive",
|
||||
"noProfilesInLocation": "No profiles found in this location.",
|
||||
"selectAtLeastOne": "Select at least one profile to import",
|
||||
"emptyNames": "Every selected profile needs a name",
|
||||
"profilesToImport": "Profiles to import",
|
||||
"groupOptional": "Group (Optional)",
|
||||
"noGroup": "No group",
|
||||
"createNewGroup": "Create new group…",
|
||||
"newGroupNamePlaceholder": "New group name",
|
||||
"duplicateStrategyLabel": "If a profile name already exists",
|
||||
"duplicateRename": "Rename automatically",
|
||||
"duplicateSkip": "Skip",
|
||||
"proxyRoundRobin": "Distribute stored proxies (round-robin)",
|
||||
"importingTitle": "Importing profiles…",
|
||||
"importProgress": "{{completed}} of {{total}} processed",
|
||||
"resultsSummary": "{{imported}} imported, {{skipped}} skipped, {{failed}} failed",
|
||||
"statusImported": "Imported",
|
||||
"statusSkipped": "Skipped",
|
||||
"statusFailed": "Failed",
|
||||
"importButtonCount": "Import ({{count}})"
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "Syncing...",
|
||||
@@ -1802,7 +1812,13 @@
|
||||
"updateChecksumsUnavailable": "The update {{version}} could not be verified because its checksum file could not be retrieved. The update was not installed; it will be retried later.",
|
||||
"updateChecksumMismatch": "The downloaded update file {{file}} failed checksum verification and was discarded. Please try again.",
|
||||
"nameCannotBeEmpty": "Name cannot be empty",
|
||||
"wayfernVersionNotAvailable": "Wayfern version {{requested}} is not available for download. The current version is {{current}}."
|
||||
"wayfernVersionNotAvailable": "Wayfern version {{requested}} is not available for download. The current version is {{current}}.",
|
||||
"profileNameExists": "A profile named \"{{name}}\" already exists",
|
||||
"importSourceNotFound": "The source path does not exist",
|
||||
"importNoItems": "Nothing selected to import",
|
||||
"browserNotDownloaded": "No downloaded version of {{browser}} is available. Download it first, then retry the import.",
|
||||
"archiveExtractionFailed": "Failed to extract the archive: {{detail}}",
|
||||
"unsupportedArchiveFormat": "Unsupported archive format. Only ZIP archives are supported."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profiles",
|
||||
|
||||
+32
-16
@@ -1378,16 +1378,9 @@
|
||||
"scanning": "Buscando perfiles de navegador...",
|
||||
"noneFound": "No se encontraron perfiles de navegador en tu sistema.",
|
||||
"noneFoundHint": "Prueba la importación manual si tienes perfiles en ubicaciones personalizadas.",
|
||||
"selectProfile": "Seleccionar perfil:",
|
||||
"selectProfilePlaceholder": "Elige un perfil detectado",
|
||||
"pathLabel": "Ruta:",
|
||||
"browserLabel": "Navegador:",
|
||||
"newProfileName": "Nombre del nuevo perfil:",
|
||||
"newProfileNamePlaceholder": "Introduce un nombre para el perfil importado",
|
||||
"manualTitle": "Importación manual de perfil",
|
||||
"browserType": "Tipo de navegador:",
|
||||
"loadingBrowsers": "Cargando navegadores...",
|
||||
"selectBrowserType": "Selecciona el tipo de navegador",
|
||||
"profileFolderPath": "Ruta de la carpeta del perfil:",
|
||||
"profileFolderPlaceholder": "Introduce la ruta completa a la carpeta del perfil",
|
||||
"browseFolderTitle": "Buscar carpeta",
|
||||
@@ -1395,17 +1388,34 @@
|
||||
"selectFolderTitle": "Seleccionar carpeta de perfil",
|
||||
"folderDialogFailed": "Error al abrir el diálogo de carpeta",
|
||||
"detectFailed": "Error al detectar los perfiles de navegador existentes",
|
||||
"fillFields": "Por favor, completa todos los campos",
|
||||
"selectAndName": "Selecciona un perfil y proporciona un nombre",
|
||||
"profileNotFound": "Perfil seleccionado no encontrado",
|
||||
"importedSuccess": "Perfil \"{{name}}\" importado correctamente",
|
||||
"notInstalled": "{{browser}} no está instalado. Por favor descarga {{browser}} primero desde la ventana principal y luego intenta importar de nuevo.",
|
||||
"importFailed": "Error al importar el perfil: {{error}}",
|
||||
"proxyOptional": "Proxy (Opcional)",
|
||||
"noProxy": "Sin proxy",
|
||||
"nextButton": "Siguiente",
|
||||
"importButton": "Importar",
|
||||
"importedAs": "Este perfil se importará como un perfil de {{browser}}."
|
||||
"importedAs": "Este perfil se importará como un perfil de {{browser}}.",
|
||||
"selectAll": "Seleccionar todo",
|
||||
"selectedCount": "{{count}} seleccionados",
|
||||
"scanButton": "Escanear",
|
||||
"manualHint": "Elige una carpeta de perfil, una carpeta de datos de usuario del navegador, una carpeta con perfiles exportados o un archivo ZIP.",
|
||||
"selectArchiveTitle": "Seleccionar archivo ZIP",
|
||||
"noProfilesInLocation": "No se encontraron perfiles en esta ubicación.",
|
||||
"selectAtLeastOne": "Selecciona al menos un perfil para importar",
|
||||
"emptyNames": "Cada perfil seleccionado necesita un nombre",
|
||||
"profilesToImport": "Perfiles a importar",
|
||||
"groupOptional": "Grupo (opcional)",
|
||||
"noGroup": "Sin grupo",
|
||||
"createNewGroup": "Crear nuevo grupo…",
|
||||
"newGroupNamePlaceholder": "Nombre del nuevo grupo",
|
||||
"duplicateStrategyLabel": "Si el nombre del perfil ya existe",
|
||||
"duplicateRename": "Renombrar automáticamente",
|
||||
"duplicateSkip": "Omitir",
|
||||
"proxyRoundRobin": "Distribuir proxies guardados (round-robin)",
|
||||
"importingTitle": "Importando perfiles…",
|
||||
"importProgress": "{{completed}} de {{total}} procesados",
|
||||
"resultsSummary": "{{imported}} importados, {{skipped}} omitidos, {{failed}} fallidos",
|
||||
"statusImported": "Importado",
|
||||
"statusSkipped": "Omitido",
|
||||
"statusFailed": "Fallido",
|
||||
"importButtonCount": "Importar ({{count}})"
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "Sincronizando...",
|
||||
@@ -1802,7 +1812,13 @@
|
||||
"updateChecksumsUnavailable": "No se pudo verificar la actualización {{version}} porque no se pudo obtener su archivo de sumas de comprobación. La actualización no se instaló; se reintentará más tarde.",
|
||||
"updateChecksumMismatch": "El archivo de actualización descargado {{file}} no superó la verificación de suma de comprobación y fue descartado. Inténtalo de nuevo.",
|
||||
"nameCannotBeEmpty": "El nombre no puede estar vacío",
|
||||
"wayfernVersionNotAvailable": "La versión {{requested}} de Wayfern no está disponible para descargar. La versión actual es {{current}}."
|
||||
"wayfernVersionNotAvailable": "La versión {{requested}} de Wayfern no está disponible para descargar. La versión actual es {{current}}.",
|
||||
"profileNameExists": "Ya existe un perfil llamado \"{{name}}\"",
|
||||
"importSourceNotFound": "La ruta de origen no existe",
|
||||
"importNoItems": "No hay nada seleccionado para importar",
|
||||
"browserNotDownloaded": "No hay ninguna versión descargada de {{browser}}. Descárgala primero y vuelve a intentar la importación.",
|
||||
"archiveExtractionFailed": "No se pudo extraer el archivo: {{detail}}",
|
||||
"unsupportedArchiveFormat": "Formato de archivo no compatible. Solo se admiten archivos ZIP."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfiles",
|
||||
|
||||
+32
-16
@@ -1378,16 +1378,9 @@
|
||||
"scanning": "Recherche de profils de navigateur...",
|
||||
"noneFound": "Aucun profil de navigateur trouvé sur votre système.",
|
||||
"noneFoundHint": "Essayez l'import manuel si vos profils sont dans des emplacements personnalisés.",
|
||||
"selectProfile": "Sélectionner un profil :",
|
||||
"selectProfilePlaceholder": "Choisissez un profil détecté",
|
||||
"pathLabel": "Chemin :",
|
||||
"browserLabel": "Navigateur :",
|
||||
"newProfileName": "Nom du nouveau profil :",
|
||||
"newProfileNamePlaceholder": "Entrez un nom pour le profil importé",
|
||||
"manualTitle": "Import manuel de profil",
|
||||
"browserType": "Type de navigateur :",
|
||||
"loadingBrowsers": "Chargement des navigateurs...",
|
||||
"selectBrowserType": "Sélectionnez le type de navigateur",
|
||||
"profileFolderPath": "Chemin du dossier du profil :",
|
||||
"profileFolderPlaceholder": "Entrez le chemin complet vers le dossier du profil",
|
||||
"browseFolderTitle": "Parcourir le dossier",
|
||||
@@ -1395,17 +1388,34 @@
|
||||
"selectFolderTitle": "Sélectionnez un dossier de profil de navigateur",
|
||||
"folderDialogFailed": "Échec de l'ouverture de la boîte de dialogue de dossier",
|
||||
"detectFailed": "Échec de la détection des profils de navigateur existants",
|
||||
"fillFields": "Veuillez remplir tous les champs",
|
||||
"selectAndName": "Sélectionnez un profil et fournissez un nom",
|
||||
"profileNotFound": "Profil sélectionné introuvable",
|
||||
"importedSuccess": "Profil « {{name}} » importé avec succès",
|
||||
"notInstalled": "{{browser}} n'est pas installé. Veuillez télécharger {{browser}} depuis la fenêtre principale puis réessayer.",
|
||||
"importFailed": "Échec de l'import du profil : {{error}}",
|
||||
"proxyOptional": "Proxy (optionnel)",
|
||||
"noProxy": "Aucun proxy",
|
||||
"nextButton": "Suivant",
|
||||
"importButton": "Importer",
|
||||
"importedAs": "Ce profil sera importé en tant que profil {{browser}}."
|
||||
"importedAs": "Ce profil sera importé en tant que profil {{browser}}.",
|
||||
"selectAll": "Tout sélectionner",
|
||||
"selectedCount": "{{count}} sélectionné(s)",
|
||||
"scanButton": "Analyser",
|
||||
"manualHint": "Choisissez un dossier de profil, un dossier de données utilisateur du navigateur, un dossier de profils exportés ou une archive ZIP.",
|
||||
"selectArchiveTitle": "Sélectionner une archive ZIP",
|
||||
"noProfilesInLocation": "Aucun profil trouvé à cet emplacement.",
|
||||
"selectAtLeastOne": "Sélectionnez au moins un profil à importer",
|
||||
"emptyNames": "Chaque profil sélectionné doit avoir un nom",
|
||||
"profilesToImport": "Profils à importer",
|
||||
"groupOptional": "Groupe (facultatif)",
|
||||
"noGroup": "Aucun groupe",
|
||||
"createNewGroup": "Créer un nouveau groupe…",
|
||||
"newGroupNamePlaceholder": "Nom du nouveau groupe",
|
||||
"duplicateStrategyLabel": "Si le nom du profil existe déjà",
|
||||
"duplicateRename": "Renommer automatiquement",
|
||||
"duplicateSkip": "Ignorer",
|
||||
"proxyRoundRobin": "Répartir les proxys enregistrés (round-robin)",
|
||||
"importingTitle": "Importation des profils…",
|
||||
"importProgress": "{{completed}} sur {{total}} traités",
|
||||
"resultsSummary": "{{imported}} importés, {{skipped}} ignorés, {{failed}} échoués",
|
||||
"statusImported": "Importé",
|
||||
"statusSkipped": "Ignoré",
|
||||
"statusFailed": "Échec",
|
||||
"importButtonCount": "Importer ({{count}})"
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "Synchronisation...",
|
||||
@@ -1802,7 +1812,13 @@
|
||||
"updateChecksumsUnavailable": "La mise à jour {{version}} n'a pas pu être vérifiée car son fichier de sommes de contrôle n'a pas pu être récupéré. La mise à jour n'a pas été installée ; une nouvelle tentative aura lieu plus tard.",
|
||||
"updateChecksumMismatch": "Le fichier de mise à jour téléchargé {{file}} a échoué à la vérification de la somme de contrôle et a été supprimé. Veuillez réessayer.",
|
||||
"nameCannotBeEmpty": "Le nom ne peut pas être vide",
|
||||
"wayfernVersionNotAvailable": "La version {{requested}} de Wayfern n'est pas disponible au téléchargement. La version actuelle est {{current}}."
|
||||
"wayfernVersionNotAvailable": "La version {{requested}} de Wayfern n'est pas disponible au téléchargement. La version actuelle est {{current}}.",
|
||||
"profileNameExists": "Un profil nommé « {{name}} » existe déjà",
|
||||
"importSourceNotFound": "Le chemin source n'existe pas",
|
||||
"importNoItems": "Rien n'est sélectionné pour l'importation",
|
||||
"browserNotDownloaded": "Aucune version téléchargée de {{browser}} n'est disponible. Téléchargez-la d'abord, puis réessayez l'importation.",
|
||||
"archiveExtractionFailed": "Échec de l'extraction de l'archive : {{detail}}",
|
||||
"unsupportedArchiveFormat": "Format d'archive non pris en charge. Seules les archives ZIP sont prises en charge."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profils",
|
||||
|
||||
+32
-16
@@ -1378,16 +1378,9 @@
|
||||
"scanning": "ブラウザプロファイルをスキャン中...",
|
||||
"noneFound": "システムにブラウザプロファイルが見つかりませんでした。",
|
||||
"noneFoundHint": "カスタムの場所にプロファイルがある場合は手動インポートをお試しください。",
|
||||
"selectProfile": "プロファイルを選択:",
|
||||
"selectProfilePlaceholder": "検出されたプロファイルを選択",
|
||||
"pathLabel": "パス:",
|
||||
"browserLabel": "ブラウザ:",
|
||||
"newProfileName": "新しいプロファイル名:",
|
||||
"newProfileNamePlaceholder": "インポートするプロファイルの名前を入力",
|
||||
"manualTitle": "手動プロファイルインポート",
|
||||
"browserType": "ブラウザの種類:",
|
||||
"loadingBrowsers": "ブラウザを読み込み中...",
|
||||
"selectBrowserType": "ブラウザの種類を選択",
|
||||
"profileFolderPath": "プロファイルフォルダパス:",
|
||||
"profileFolderPlaceholder": "プロファイルフォルダのフルパスを入力",
|
||||
"browseFolderTitle": "フォルダを参照",
|
||||
@@ -1395,17 +1388,34 @@
|
||||
"selectFolderTitle": "ブラウザプロファイルフォルダを選択",
|
||||
"folderDialogFailed": "フォルダダイアログを開けませんでした",
|
||||
"detectFailed": "既存のブラウザプロファイルの検出に失敗しました",
|
||||
"fillFields": "すべてのフィールドを入力してください",
|
||||
"selectAndName": "プロファイルを選択し、名前を入力してください",
|
||||
"profileNotFound": "選択されたプロファイルが見つかりません",
|
||||
"importedSuccess": "プロファイル「{{name}}」をインポートしました",
|
||||
"notInstalled": "{{browser}} はインストールされていません。メインウィンドウから {{browser}} をダウンロードしてからもう一度インポートしてください。",
|
||||
"importFailed": "プロファイルのインポートに失敗しました: {{error}}",
|
||||
"proxyOptional": "プロキシ (任意)",
|
||||
"noProxy": "プロキシなし",
|
||||
"nextButton": "次へ",
|
||||
"importButton": "インポート",
|
||||
"importedAs": "このプロファイルは {{browser}} プロファイルとしてインポートされます。"
|
||||
"importedAs": "このプロファイルは {{browser}} プロファイルとしてインポートされます。",
|
||||
"selectAll": "すべて選択",
|
||||
"selectedCount": "{{count}}件選択中",
|
||||
"scanButton": "スキャン",
|
||||
"manualHint": "プロファイルフォルダ、ブラウザのユーザーデータフォルダ、エクスポートされたプロファイルのフォルダ、またはZIPアーカイブを選択してください。",
|
||||
"selectArchiveTitle": "ZIPアーカイブを選択",
|
||||
"noProfilesInLocation": "この場所にプロファイルが見つかりませんでした。",
|
||||
"selectAtLeastOne": "インポートするプロファイルを1つ以上選択してください",
|
||||
"emptyNames": "選択したすべてのプロファイルに名前が必要です",
|
||||
"profilesToImport": "インポートするプロファイル",
|
||||
"groupOptional": "グループ(任意)",
|
||||
"noGroup": "グループなし",
|
||||
"createNewGroup": "新しいグループを作成…",
|
||||
"newGroupNamePlaceholder": "新しいグループ名",
|
||||
"duplicateStrategyLabel": "プロファイル名が既に存在する場合",
|
||||
"duplicateRename": "自動的に名前を変更",
|
||||
"duplicateSkip": "スキップ",
|
||||
"proxyRoundRobin": "保存済みプロキシを順番に割り当て(ラウンドロビン)",
|
||||
"importingTitle": "プロファイルをインポート中…",
|
||||
"importProgress": "{{total}}件中{{completed}}件処理済み",
|
||||
"resultsSummary": "インポート{{imported}}件、スキップ{{skipped}}件、失敗{{failed}}件",
|
||||
"statusImported": "インポート済み",
|
||||
"statusSkipped": "スキップ",
|
||||
"statusFailed": "失敗",
|
||||
"importButtonCount": "インポート ({{count}})"
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "同期中...",
|
||||
@@ -1802,7 +1812,13 @@
|
||||
"updateChecksumsUnavailable": "アップデート {{version}} のチェックサムファイルを取得できなかったため、検証できませんでした。アップデートはインストールされませんでした。後で再試行されます。",
|
||||
"updateChecksumMismatch": "ダウンロードしたアップデートファイル {{file}} はチェックサム検証に失敗したため破棄されました。もう一度お試しください。",
|
||||
"nameCannotBeEmpty": "名前を空にすることはできません",
|
||||
"wayfernVersionNotAvailable": "Wayfernのバージョン{{requested}}はダウンロードできません。現在のバージョンは{{current}}です。"
|
||||
"wayfernVersionNotAvailable": "Wayfernのバージョン{{requested}}はダウンロードできません。現在のバージョンは{{current}}です。",
|
||||
"profileNameExists": "「{{name}}」という名前のプロファイルは既に存在します",
|
||||
"importSourceNotFound": "ソースパスが存在しません",
|
||||
"importNoItems": "インポートする項目が選択されていません",
|
||||
"browserNotDownloaded": "{{browser}}のダウンロード済みバージョンがありません。先にダウンロードしてから、再度インポートしてください。",
|
||||
"archiveExtractionFailed": "アーカイブの展開に失敗しました:{{detail}}",
|
||||
"unsupportedArchiveFormat": "サポートされていないアーカイブ形式です。ZIPアーカイブのみサポートされています。"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "プロファイル",
|
||||
|
||||
+32
-16
@@ -1378,16 +1378,9 @@
|
||||
"scanning": "브라우저 프로필 검색 중...",
|
||||
"noneFound": "시스템에서 브라우저 프로필을 찾을 수 없습니다.",
|
||||
"noneFoundHint": "사용자 지정 위치에 프로필이 있는 경우 수동 가져오기 옵션을 시도해 보세요.",
|
||||
"selectProfile": "프로필 선택:",
|
||||
"selectProfilePlaceholder": "감지된 프로필 선택",
|
||||
"pathLabel": "경로:",
|
||||
"browserLabel": "브라우저:",
|
||||
"newProfileName": "새 프로필 이름:",
|
||||
"newProfileNamePlaceholder": "가져올 프로필의 이름을 입력하세요",
|
||||
"manualTitle": "수동 프로필 가져오기",
|
||||
"browserType": "브라우저 유형:",
|
||||
"loadingBrowsers": "브라우저 불러오는 중...",
|
||||
"selectBrowserType": "브라우저 유형 선택",
|
||||
"profileFolderPath": "프로필 폴더 경로:",
|
||||
"profileFolderPlaceholder": "프로필 폴더의 전체 경로 입력",
|
||||
"browseFolderTitle": "폴더 찾아보기",
|
||||
@@ -1395,17 +1388,34 @@
|
||||
"selectFolderTitle": "브라우저 프로필 폴더 선택",
|
||||
"folderDialogFailed": "폴더 대화상자 열기 실패",
|
||||
"detectFailed": "기존 브라우저 프로필 감지 실패",
|
||||
"fillFields": "모든 필드를 입력하세요",
|
||||
"selectAndName": "프로필을 선택하고 이름을 제공하세요",
|
||||
"profileNotFound": "선택한 프로필을 찾을 수 없습니다",
|
||||
"importedSuccess": "프로필 \"{{name}}\"을(를) 가져왔습니다",
|
||||
"notInstalled": "{{browser}}이(가) 설치되지 않았습니다. 메인 창에서 먼저 {{browser}}을(를) 다운로드한 후 다시 가져오기를 시도하세요.",
|
||||
"importFailed": "프로필 가져오기 실패: {{error}}",
|
||||
"proxyOptional": "프록시 (선택 사항)",
|
||||
"noProxy": "프록시 없음",
|
||||
"nextButton": "다음",
|
||||
"importButton": "가져오기",
|
||||
"importedAs": "이 프로필은 {{browser}} 프로필로 가져옵니다."
|
||||
"importedAs": "이 프로필은 {{browser}} 프로필로 가져옵니다.",
|
||||
"selectAll": "모두 선택",
|
||||
"selectedCount": "{{count}}개 선택됨",
|
||||
"scanButton": "스캔",
|
||||
"manualHint": "프로필 폴더, 브라우저 사용자 데이터 폴더, 내보낸 프로필 폴더 또는 ZIP 아카이브를 선택하세요.",
|
||||
"selectArchiveTitle": "ZIP 아카이브 선택",
|
||||
"noProfilesInLocation": "이 위치에서 프로필을 찾을 수 없습니다.",
|
||||
"selectAtLeastOne": "가져올 프로필을 하나 이상 선택하세요",
|
||||
"emptyNames": "선택한 모든 프로필에 이름이 필요합니다",
|
||||
"profilesToImport": "가져올 프로필",
|
||||
"groupOptional": "그룹 (선택 사항)",
|
||||
"noGroup": "그룹 없음",
|
||||
"createNewGroup": "새 그룹 만들기…",
|
||||
"newGroupNamePlaceholder": "새 그룹 이름",
|
||||
"duplicateStrategyLabel": "프로필 이름이 이미 있는 경우",
|
||||
"duplicateRename": "자동으로 이름 변경",
|
||||
"duplicateSkip": "건너뛰기",
|
||||
"proxyRoundRobin": "저장된 프록시 순환 할당 (라운드 로빈)",
|
||||
"importingTitle": "프로필 가져오는 중…",
|
||||
"importProgress": "{{total}}개 중 {{completed}}개 처리됨",
|
||||
"resultsSummary": "가져옴 {{imported}}, 건너뜀 {{skipped}}, 실패 {{failed}}",
|
||||
"statusImported": "가져옴",
|
||||
"statusSkipped": "건너뜀",
|
||||
"statusFailed": "실패",
|
||||
"importButtonCount": "가져오기 ({{count}})"
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "동기화 중...",
|
||||
@@ -1802,7 +1812,13 @@
|
||||
"updateChecksumsUnavailable": "업데이트 {{version}}의 체크섬 파일을 가져올 수 없어 검증하지 못했습니다. 업데이트가 설치되지 않았으며 나중에 다시 시도됩니다.",
|
||||
"updateChecksumMismatch": "다운로드한 업데이트 파일 {{file}}이(가) 체크섬 검증에 실패하여 삭제되었습니다. 다시 시도해 주세요.",
|
||||
"nameCannotBeEmpty": "이름은 비워둘 수 없습니다",
|
||||
"wayfernVersionNotAvailable": "Wayfern 버전 {{requested}}은(는) 다운로드할 수 없습니다. 현재 버전은 {{current}}입니다."
|
||||
"wayfernVersionNotAvailable": "Wayfern 버전 {{requested}}은(는) 다운로드할 수 없습니다. 현재 버전은 {{current}}입니다.",
|
||||
"profileNameExists": "\"{{name}}\" 이름의 프로필이 이미 있습니다",
|
||||
"importSourceNotFound": "원본 경로가 존재하지 않습니다",
|
||||
"importNoItems": "가져올 항목이 선택되지 않았습니다",
|
||||
"browserNotDownloaded": "{{browser}}의 다운로드된 버전이 없습니다. 먼저 다운로드한 후 가져오기를 다시 시도하세요.",
|
||||
"archiveExtractionFailed": "아카이브 추출에 실패했습니다: {{detail}}",
|
||||
"unsupportedArchiveFormat": "지원되지 않는 아카이브 형식입니다. ZIP 아카이브만 지원됩니다."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "프로필",
|
||||
|
||||
+32
-16
@@ -1378,16 +1378,9 @@
|
||||
"scanning": "Procurando perfis de navegador...",
|
||||
"noneFound": "Nenhum perfil de navegador encontrado no seu sistema.",
|
||||
"noneFoundHint": "Tente a importação manual se você tem perfis em locais personalizados.",
|
||||
"selectProfile": "Selecionar perfil:",
|
||||
"selectProfilePlaceholder": "Escolha um perfil detectado",
|
||||
"pathLabel": "Caminho:",
|
||||
"browserLabel": "Navegador:",
|
||||
"newProfileName": "Nome do novo perfil:",
|
||||
"newProfileNamePlaceholder": "Insira um nome para o perfil importado",
|
||||
"manualTitle": "Importação manual de perfil",
|
||||
"browserType": "Tipo de navegador:",
|
||||
"loadingBrowsers": "Carregando navegadores...",
|
||||
"selectBrowserType": "Selecione o tipo de navegador",
|
||||
"profileFolderPath": "Caminho da pasta do perfil:",
|
||||
"profileFolderPlaceholder": "Insira o caminho completo para a pasta do perfil",
|
||||
"browseFolderTitle": "Procurar pasta",
|
||||
@@ -1395,17 +1388,34 @@
|
||||
"selectFolderTitle": "Selecionar pasta de perfil do navegador",
|
||||
"folderDialogFailed": "Falha ao abrir o diálogo de pasta",
|
||||
"detectFailed": "Falha ao detectar perfis de navegador existentes",
|
||||
"fillFields": "Por favor, preencha todos os campos",
|
||||
"selectAndName": "Selecione um perfil e forneça um nome",
|
||||
"profileNotFound": "Perfil selecionado não encontrado",
|
||||
"importedSuccess": "Perfil \"{{name}}\" importado com sucesso",
|
||||
"notInstalled": "{{browser}} não está instalado. Baixe {{browser}} primeiro pela janela principal e tente importar novamente.",
|
||||
"importFailed": "Falha ao importar perfil: {{error}}",
|
||||
"proxyOptional": "Proxy (Opcional)",
|
||||
"noProxy": "Sem proxy",
|
||||
"nextButton": "Próximo",
|
||||
"importButton": "Importar",
|
||||
"importedAs": "Este perfil será importado como um perfil {{browser}}."
|
||||
"importedAs": "Este perfil será importado como um perfil {{browser}}.",
|
||||
"selectAll": "Selecionar tudo",
|
||||
"selectedCount": "{{count}} selecionados",
|
||||
"scanButton": "Verificar",
|
||||
"manualHint": "Escolha uma pasta de perfil, uma pasta de dados de usuário do navegador, uma pasta com perfis exportados ou um arquivo ZIP.",
|
||||
"selectArchiveTitle": "Selecionar arquivo ZIP",
|
||||
"noProfilesInLocation": "Nenhum perfil encontrado neste local.",
|
||||
"selectAtLeastOne": "Selecione pelo menos um perfil para importar",
|
||||
"emptyNames": "Cada perfil selecionado precisa de um nome",
|
||||
"profilesToImport": "Perfis a importar",
|
||||
"groupOptional": "Grupo (opcional)",
|
||||
"noGroup": "Sem grupo",
|
||||
"createNewGroup": "Criar novo grupo…",
|
||||
"newGroupNamePlaceholder": "Nome do novo grupo",
|
||||
"duplicateStrategyLabel": "Se o nome do perfil já existir",
|
||||
"duplicateRename": "Renomear automaticamente",
|
||||
"duplicateSkip": "Ignorar",
|
||||
"proxyRoundRobin": "Distribuir proxies salvos (round-robin)",
|
||||
"importingTitle": "Importando perfis…",
|
||||
"importProgress": "{{completed}} de {{total}} processados",
|
||||
"resultsSummary": "{{imported}} importados, {{skipped}} ignorados, {{failed}} com falha",
|
||||
"statusImported": "Importado",
|
||||
"statusSkipped": "Ignorado",
|
||||
"statusFailed": "Falhou",
|
||||
"importButtonCount": "Importar ({{count}})"
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "Sincronizando...",
|
||||
@@ -1802,7 +1812,13 @@
|
||||
"updateChecksumsUnavailable": "Não foi possível verificar a atualização {{version}} porque o arquivo de somas de verificação não pôde ser obtido. A atualização não foi instalada; será tentada novamente mais tarde.",
|
||||
"updateChecksumMismatch": "O arquivo de atualização baixado {{file}} falhou na verificação de soma de verificação e foi descartado. Tente novamente.",
|
||||
"nameCannotBeEmpty": "O nome não pode estar vazio",
|
||||
"wayfernVersionNotAvailable": "A versão {{requested}} do Wayfern não está disponível para download. A versão atual é {{current}}."
|
||||
"wayfernVersionNotAvailable": "A versão {{requested}} do Wayfern não está disponível para download. A versão atual é {{current}}.",
|
||||
"profileNameExists": "Já existe um perfil chamado \"{{name}}\"",
|
||||
"importSourceNotFound": "O caminho de origem não existe",
|
||||
"importNoItems": "Nada selecionado para importar",
|
||||
"browserNotDownloaded": "Nenhuma versão baixada de {{browser}} está disponível. Baixe-a primeiro e tente importar novamente.",
|
||||
"archiveExtractionFailed": "Falha ao extrair o arquivo: {{detail}}",
|
||||
"unsupportedArchiveFormat": "Formato de arquivo não suportado. Apenas arquivos ZIP são suportados."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfis",
|
||||
|
||||
+32
-16
@@ -1378,16 +1378,9 @@
|
||||
"scanning": "Поиск профилей браузера...",
|
||||
"noneFound": "На вашей системе профили браузера не найдены.",
|
||||
"noneFoundHint": "Попробуйте ручной импорт, если профили находятся в нестандартных местах.",
|
||||
"selectProfile": "Выбрать профиль:",
|
||||
"selectProfilePlaceholder": "Выберите обнаруженный профиль",
|
||||
"pathLabel": "Путь:",
|
||||
"browserLabel": "Браузер:",
|
||||
"newProfileName": "Имя нового профиля:",
|
||||
"newProfileNamePlaceholder": "Введите имя для импортированного профиля",
|
||||
"manualTitle": "Ручной импорт профиля",
|
||||
"browserType": "Тип браузера:",
|
||||
"loadingBrowsers": "Загрузка браузеров...",
|
||||
"selectBrowserType": "Выберите тип браузера",
|
||||
"profileFolderPath": "Путь к папке профиля:",
|
||||
"profileFolderPlaceholder": "Введите полный путь к папке профиля",
|
||||
"browseFolderTitle": "Выбрать папку",
|
||||
@@ -1395,17 +1388,34 @@
|
||||
"selectFolderTitle": "Выберите папку профиля браузера",
|
||||
"folderDialogFailed": "Не удалось открыть диалог выбора папки",
|
||||
"detectFailed": "Не удалось обнаружить существующие профили браузера",
|
||||
"fillFields": "Пожалуйста, заполните все поля",
|
||||
"selectAndName": "Выберите профиль и укажите имя",
|
||||
"profileNotFound": "Выбранный профиль не найден",
|
||||
"importedSuccess": "Профиль «{{name}}» успешно импортирован",
|
||||
"notInstalled": "{{browser}} не установлен. Сначала загрузите {{browser}} из главного окна, затем попробуйте импортировать снова.",
|
||||
"importFailed": "Не удалось импортировать профиль: {{error}}",
|
||||
"proxyOptional": "Прокси (необязательно)",
|
||||
"noProxy": "Без прокси",
|
||||
"nextButton": "Далее",
|
||||
"importButton": "Импорт",
|
||||
"importedAs": "Этот профиль будет импортирован как профиль {{browser}}."
|
||||
"importedAs": "Этот профиль будет импортирован как профиль {{browser}}.",
|
||||
"selectAll": "Выбрать все",
|
||||
"selectedCount": "Выбрано: {{count}}",
|
||||
"scanButton": "Сканировать",
|
||||
"manualHint": "Выберите папку профиля, папку данных браузера, папку с экспортированными профилями или ZIP-архив.",
|
||||
"selectArchiveTitle": "Выбрать ZIP-архив",
|
||||
"noProfilesInLocation": "В этом расположении профили не найдены.",
|
||||
"selectAtLeastOne": "Выберите хотя бы один профиль для импорта",
|
||||
"emptyNames": "Каждому выбранному профилю нужно имя",
|
||||
"profilesToImport": "Профили для импорта",
|
||||
"groupOptional": "Группа (необязательно)",
|
||||
"noGroup": "Без группы",
|
||||
"createNewGroup": "Создать новую группу…",
|
||||
"newGroupNamePlaceholder": "Название новой группы",
|
||||
"duplicateStrategyLabel": "Если имя профиля уже существует",
|
||||
"duplicateRename": "Переименовать автоматически",
|
||||
"duplicateSkip": "Пропустить",
|
||||
"proxyRoundRobin": "Распределить сохранённые прокси (по кругу)",
|
||||
"importingTitle": "Импорт профилей…",
|
||||
"importProgress": "Обработано {{completed}} из {{total}}",
|
||||
"resultsSummary": "Импортировано: {{imported}}, пропущено: {{skipped}}, с ошибкой: {{failed}}",
|
||||
"statusImported": "Импортирован",
|
||||
"statusSkipped": "Пропущен",
|
||||
"statusFailed": "Ошибка",
|
||||
"importButtonCount": "Импортировать ({{count}})"
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "Синхронизация...",
|
||||
@@ -1802,7 +1812,13 @@
|
||||
"updateChecksumsUnavailable": "Не удалось проверить обновление {{version}}: файл контрольных сумм не удалось получить. Обновление не было установлено; попытка будет повторена позже.",
|
||||
"updateChecksumMismatch": "Загруженный файл обновления {{file}} не прошёл проверку контрольной суммы и был удалён. Попробуйте ещё раз.",
|
||||
"nameCannotBeEmpty": "Имя не может быть пустым",
|
||||
"wayfernVersionNotAvailable": "Версия Wayfern {{requested}} недоступна для загрузки. Текущая версия — {{current}}."
|
||||
"wayfernVersionNotAvailable": "Версия Wayfern {{requested}} недоступна для загрузки. Текущая версия — {{current}}.",
|
||||
"profileNameExists": "Профиль с именем «{{name}}» уже существует",
|
||||
"importSourceNotFound": "Исходный путь не существует",
|
||||
"importNoItems": "Ничего не выбрано для импорта",
|
||||
"browserNotDownloaded": "Нет загруженной версии {{browser}}. Сначала загрузите её, затем повторите импорт.",
|
||||
"archiveExtractionFailed": "Не удалось распаковать архив: {{detail}}",
|
||||
"unsupportedArchiveFormat": "Неподдерживаемый формат архива. Поддерживаются только ZIP-архивы."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Профили",
|
||||
|
||||
+32
-16
@@ -1378,16 +1378,9 @@
|
||||
"scanning": "Đang quét profile trình duyệt...",
|
||||
"noneFound": "Không tìm thấy profile trình duyệt nào trên hệ thống của bạn.",
|
||||
"noneFoundHint": "Thử tùy chọn nhập thủ công nếu bạn có profile ở vị trí tùy chỉnh.",
|
||||
"selectProfile": "Chọn profile:",
|
||||
"selectProfilePlaceholder": "Chọn profile đã phát hiện",
|
||||
"pathLabel": "Đường dẫn:",
|
||||
"browserLabel": "Trình duyệt:",
|
||||
"newProfileName": "Tên profile mới:",
|
||||
"newProfileNamePlaceholder": "Nhập tên cho profile được nhập",
|
||||
"manualTitle": "Nhập profile thủ công",
|
||||
"browserType": "Loại trình duyệt:",
|
||||
"loadingBrowsers": "Đang tải trình duyệt...",
|
||||
"selectBrowserType": "Chọn loại trình duyệt",
|
||||
"profileFolderPath": "Đường dẫn thư mục profile:",
|
||||
"profileFolderPlaceholder": "Nhập đường dẫn đầy đủ đến thư mục profile",
|
||||
"browseFolderTitle": "Duyệt thư mục",
|
||||
@@ -1395,17 +1388,34 @@
|
||||
"selectFolderTitle": "Chọn thư mục profile trình duyệt",
|
||||
"folderDialogFailed": "Mở hộp thoại thư mục thất bại",
|
||||
"detectFailed": "Phát hiện profile trình duyệt hiện có thất bại",
|
||||
"fillFields": "Vui lòng điền đầy đủ các trường",
|
||||
"selectAndName": "Vui lòng chọn profile và cung cấp tên",
|
||||
"profileNotFound": "Không tìm thấy profile đã chọn",
|
||||
"importedSuccess": "Đã nhập thành công profile \"{{name}}\"",
|
||||
"notInstalled": "{{browser}} chưa được cài đặt. Vui lòng tải xuống {{browser}} trước từ cửa sổ chính, sau đó thử nhập lại.",
|
||||
"importFailed": "Nhập profile thất bại: {{error}}",
|
||||
"proxyOptional": "Proxy (Tùy chọn)",
|
||||
"noProxy": "Không có proxy",
|
||||
"nextButton": "Tiếp theo",
|
||||
"importButton": "Nhập",
|
||||
"importedAs": "Profile này sẽ được nhập dưới dạng profile {{browser}}."
|
||||
"importedAs": "Profile này sẽ được nhập dưới dạng profile {{browser}}.",
|
||||
"selectAll": "Chọn tất cả",
|
||||
"selectedCount": "Đã chọn {{count}}",
|
||||
"scanButton": "Quét",
|
||||
"manualHint": "Chọn thư mục hồ sơ, thư mục dữ liệu người dùng của trình duyệt, thư mục chứa các hồ sơ đã xuất hoặc tệp ZIP.",
|
||||
"selectArchiveTitle": "Chọn tệp ZIP",
|
||||
"noProfilesInLocation": "Không tìm thấy hồ sơ nào ở vị trí này.",
|
||||
"selectAtLeastOne": "Chọn ít nhất một hồ sơ để nhập",
|
||||
"emptyNames": "Mỗi hồ sơ đã chọn cần có tên",
|
||||
"profilesToImport": "Hồ sơ sẽ nhập",
|
||||
"groupOptional": "Nhóm (tùy chọn)",
|
||||
"noGroup": "Không có nhóm",
|
||||
"createNewGroup": "Tạo nhóm mới…",
|
||||
"newGroupNamePlaceholder": "Tên nhóm mới",
|
||||
"duplicateStrategyLabel": "Nếu tên hồ sơ đã tồn tại",
|
||||
"duplicateRename": "Tự động đổi tên",
|
||||
"duplicateSkip": "Bỏ qua",
|
||||
"proxyRoundRobin": "Phân bổ proxy đã lưu (xoay vòng)",
|
||||
"importingTitle": "Đang nhập hồ sơ…",
|
||||
"importProgress": "Đã xử lý {{completed}} / {{total}}",
|
||||
"resultsSummary": "Đã nhập {{imported}}, bỏ qua {{skipped}}, thất bại {{failed}}",
|
||||
"statusImported": "Đã nhập",
|
||||
"statusSkipped": "Bỏ qua",
|
||||
"statusFailed": "Thất bại",
|
||||
"importButtonCount": "Nhập ({{count}})"
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "Đang đồng bộ...",
|
||||
@@ -1802,7 +1812,13 @@
|
||||
"updateChecksumsUnavailable": "Không thể xác minh bản cập nhật {{version}} vì không thể tải tệp checksum. Bản cập nhật chưa được cài đặt; sẽ thử lại sau.",
|
||||
"updateChecksumMismatch": "Tệp cập nhật đã tải xuống {{file}} không vượt qua kiểm tra checksum và đã bị loại bỏ. Vui lòng thử lại.",
|
||||
"nameCannotBeEmpty": "Tên không được để trống",
|
||||
"wayfernVersionNotAvailable": "Phiên bản Wayfern {{requested}} không có sẵn để tải xuống. Phiên bản hiện tại là {{current}}."
|
||||
"wayfernVersionNotAvailable": "Phiên bản Wayfern {{requested}} không có sẵn để tải xuống. Phiên bản hiện tại là {{current}}.",
|
||||
"profileNameExists": "Hồ sơ có tên \"{{name}}\" đã tồn tại",
|
||||
"importSourceNotFound": "Đường dẫn nguồn không tồn tại",
|
||||
"importNoItems": "Chưa chọn mục nào để nhập",
|
||||
"browserNotDownloaded": "Không có phiên bản {{browser}} nào đã tải xuống. Hãy tải xuống trước, sau đó thử nhập lại.",
|
||||
"archiveExtractionFailed": "Không thể giải nén tệp: {{detail}}",
|
||||
"unsupportedArchiveFormat": "Định dạng tệp nén không được hỗ trợ. Chỉ hỗ trợ tệp ZIP."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profile",
|
||||
|
||||
+32
-16
@@ -1378,16 +1378,9 @@
|
||||
"scanning": "正在扫描浏览器配置文件...",
|
||||
"noneFound": "在你的系统上未找到浏览器配置文件。",
|
||||
"noneFoundHint": "如果配置文件位于自定义位置,请尝试手动导入选项。",
|
||||
"selectProfile": "选择配置文件:",
|
||||
"selectProfilePlaceholder": "选择检测到的配置文件",
|
||||
"pathLabel": "路径:",
|
||||
"browserLabel": "浏览器:",
|
||||
"newProfileName": "新配置文件名称:",
|
||||
"newProfileNamePlaceholder": "输入要导入的配置文件名称",
|
||||
"manualTitle": "手动配置文件导入",
|
||||
"browserType": "浏览器类型:",
|
||||
"loadingBrowsers": "正在加载浏览器...",
|
||||
"selectBrowserType": "选择浏览器类型",
|
||||
"profileFolderPath": "配置文件夹路径:",
|
||||
"profileFolderPlaceholder": "输入配置文件夹的完整路径",
|
||||
"browseFolderTitle": "浏览文件夹",
|
||||
@@ -1395,17 +1388,34 @@
|
||||
"selectFolderTitle": "选择浏览器配置文件夹",
|
||||
"folderDialogFailed": "打开文件夹对话框失败",
|
||||
"detectFailed": "检测现有浏览器配置文件失败",
|
||||
"fillFields": "请填写所有字段",
|
||||
"selectAndName": "请选择配置文件并提供名称",
|
||||
"profileNotFound": "未找到所选配置文件",
|
||||
"importedSuccess": "已成功导入配置文件「{{name}}」",
|
||||
"notInstalled": "{{browser}} 未安装。请先从主窗口下载 {{browser}},然后再尝试导入。",
|
||||
"importFailed": "导入配置文件失败: {{error}}",
|
||||
"proxyOptional": "代理 (可选)",
|
||||
"noProxy": "无代理",
|
||||
"nextButton": "下一步",
|
||||
"importButton": "导入",
|
||||
"importedAs": "此配置文件将作为 {{browser}} 配置文件导入。"
|
||||
"importedAs": "此配置文件将作为 {{browser}} 配置文件导入。",
|
||||
"selectAll": "全选",
|
||||
"selectedCount": "已选择 {{count}} 个",
|
||||
"scanButton": "扫描",
|
||||
"manualHint": "选择配置文件夹、浏览器用户数据文件夹、包含导出配置文件的文件夹或 ZIP 压缩包。",
|
||||
"selectArchiveTitle": "选择 ZIP 压缩包",
|
||||
"noProfilesInLocation": "在此位置未找到配置文件。",
|
||||
"selectAtLeastOne": "请至少选择一个要导入的配置文件",
|
||||
"emptyNames": "每个所选配置文件都需要一个名称",
|
||||
"profilesToImport": "要导入的配置文件",
|
||||
"groupOptional": "分组(可选)",
|
||||
"noGroup": "无分组",
|
||||
"createNewGroup": "创建新分组…",
|
||||
"newGroupNamePlaceholder": "新分组名称",
|
||||
"duplicateStrategyLabel": "如果配置文件名称已存在",
|
||||
"duplicateRename": "自动重命名",
|
||||
"duplicateSkip": "跳过",
|
||||
"proxyRoundRobin": "轮流分配已保存的代理(轮询)",
|
||||
"importingTitle": "正在导入配置文件…",
|
||||
"importProgress": "已处理 {{completed}} / {{total}}",
|
||||
"resultsSummary": "已导入 {{imported}} 个,跳过 {{skipped}} 个,失败 {{failed}} 个",
|
||||
"statusImported": "已导入",
|
||||
"statusSkipped": "已跳过",
|
||||
"statusFailed": "失败",
|
||||
"importButtonCount": "导入 ({{count}})"
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "同步中...",
|
||||
@@ -1802,7 +1812,13 @@
|
||||
"updateChecksumsUnavailable": "无法验证更新 {{version}}:无法获取其校验和文件。更新未安装,稍后将重试。",
|
||||
"updateChecksumMismatch": "下载的更新文件 {{file}} 未通过校验和验证,已被丢弃。请重试。",
|
||||
"nameCannotBeEmpty": "名称不能为空",
|
||||
"wayfernVersionNotAvailable": "Wayfern 版本 {{requested}} 无法下载。当前版本为 {{current}}。"
|
||||
"wayfernVersionNotAvailable": "Wayfern 版本 {{requested}} 无法下载。当前版本为 {{current}}。",
|
||||
"profileNameExists": "名为“{{name}}”的配置文件已存在",
|
||||
"importSourceNotFound": "源路径不存在",
|
||||
"importNoItems": "未选择要导入的内容",
|
||||
"browserNotDownloaded": "没有已下载的 {{browser}} 版本。请先下载,然后重试导入。",
|
||||
"archiveExtractionFailed": "解压压缩包失败:{{detail}}",
|
||||
"unsupportedArchiveFormat": "不支持的压缩包格式。仅支持 ZIP 压缩包。"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "配置文件",
|
||||
|
||||
@@ -38,6 +38,12 @@ export type BackendErrorCode =
|
||||
| "CAMOUFOX_IMPORT_DEPRECATED"
|
||||
| "UPDATE_CHECKSUMS_UNAVAILABLE"
|
||||
| "UPDATE_CHECKSUM_MISMATCH"
|
||||
| "PROFILE_NAME_EXISTS"
|
||||
| "IMPORT_SOURCE_NOT_FOUND"
|
||||
| "IMPORT_NO_ITEMS"
|
||||
| "BROWSER_NOT_DOWNLOADED"
|
||||
| "ARCHIVE_EXTRACTION_FAILED"
|
||||
| "UNSUPPORTED_ARCHIVE_FORMAT"
|
||||
| "INTERNAL_ERROR";
|
||||
|
||||
export interface BackendError {
|
||||
@@ -157,6 +163,24 @@ export function translateBackendError(t: TFunction, err: unknown): string {
|
||||
return t("backendErrors.updateChecksumMismatch", {
|
||||
file: parsed.params?.file ?? "",
|
||||
});
|
||||
case "PROFILE_NAME_EXISTS":
|
||||
return t("backendErrors.profileNameExists", {
|
||||
name: parsed.params?.name ?? "",
|
||||
});
|
||||
case "IMPORT_SOURCE_NOT_FOUND":
|
||||
return t("backendErrors.importSourceNotFound");
|
||||
case "IMPORT_NO_ITEMS":
|
||||
return t("backendErrors.importNoItems");
|
||||
case "BROWSER_NOT_DOWNLOADED":
|
||||
return t("backendErrors.browserNotDownloaded", {
|
||||
browser: parsed.params?.browser ?? "",
|
||||
});
|
||||
case "ARCHIVE_EXTRACTION_FAILED":
|
||||
return t("backendErrors.archiveExtractionFailed", {
|
||||
detail: parsed.params?.detail ?? "",
|
||||
});
|
||||
case "UNSUPPORTED_ARCHIVE_FORMAT":
|
||||
return t("backendErrors.unsupportedArchiveFormat");
|
||||
case "INTERNAL_ERROR":
|
||||
return t("backendErrors.internal", {
|
||||
detail: parsed.params?.detail ?? "",
|
||||
|
||||
@@ -197,6 +197,41 @@ export interface DetectedProfile {
|
||||
mapped_browser: string;
|
||||
}
|
||||
|
||||
export interface ImportProfileItem {
|
||||
source_path: string;
|
||||
browser_type?: string;
|
||||
new_profile_name: string;
|
||||
proxy_id?: string | null;
|
||||
}
|
||||
|
||||
export interface ProfileImportItemResult {
|
||||
name: string;
|
||||
source_path: string;
|
||||
status: "imported" | "skipped" | "failed";
|
||||
profile_id: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface ProfileImportBatchResult {
|
||||
imported_count: number;
|
||||
skipped_count: number;
|
||||
failed_count: number;
|
||||
results: ProfileImportItemResult[];
|
||||
}
|
||||
|
||||
export interface ArchiveScanResult {
|
||||
extracted_dir: string;
|
||||
profiles: DetectedProfile[];
|
||||
}
|
||||
|
||||
export interface ProfileImportProgress {
|
||||
total: number;
|
||||
completed: number;
|
||||
index: number;
|
||||
name: string;
|
||||
status: "importing" | "imported" | "skipped" | "failed";
|
||||
}
|
||||
|
||||
export interface BrowserReleaseTypes {
|
||||
stable?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user