diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index 1d710db..fc941ec 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -87,6 +87,8 @@ pub struct UpdateProfileRequest { pub tags: Option>, pub extension_group_id: Option, pub proxy_bypass_rules: Option>, + /// One of "Disabled", "Regular", "Encrypted". + pub sync_mode: Option, } #[derive(Clone)] @@ -397,10 +399,15 @@ impl ApiServer { .route("/events", get(ws_handler)) .with_state(ws_state); + let api_for_v1 = api.clone(); let app = Router::new() .merge(v1_routes) .nest("/ws", ws_routes) .route("/openapi.json", get(move || async move { Json(api) })) + .route( + "/v1/openapi.json", + get(move || async move { Json(api_for_v1) }), + ) // Outermost layer: logs every request so customer reports show what // their automation is actually calling, what the response status was, // and how long it took. Never logs request bodies or auth headers. @@ -929,6 +936,15 @@ async fn update_profile( } } + if let Some(sync_mode) = request.sync_mode { + if crate::sync::set_profile_sync_mode(state.app_handle.clone(), id.clone(), sync_mode) + .await + .is_err() + { + return Err(StatusCode::BAD_REQUEST); + } + } + // Return updated profile get_profile(Path(id), State(state)).await } diff --git a/src-tauri/src/camoufox_manager.rs b/src-tauri/src/camoufox_manager.rs index e5cdd9a..68252ad 100644 --- a/src-tauri/src/camoufox_manager.rs +++ b/src-tauri/src/camoufox_manager.rs @@ -287,7 +287,7 @@ impl CamoufoxManager { } } - let child = command + let mut child = command .spawn() .map_err(|e| format!("Failed to spawn Camoufox process: {e}"))?; @@ -296,6 +296,34 @@ impl CamoufoxManager { log::info!("Camoufox launched with PID: {:?}", process_id); + // Watch the child so its exit status (signal / non-zero code) lands in + // the log. Without this, all we see is "PID X is no longer running" via + // the periodic sysinfo poll, with no clue why it died. + let watch_profile_path = profile_path.to_string(); + tokio::spawn(async move { + match child.wait().await { + Ok(status) => { + if status.success() { + log::info!( + "Camoufox PID {:?} for {} exited cleanly (status=0)", + process_id, + watch_profile_path + ); + } else { + log::warn!( + "Camoufox PID {:?} for {} exited abnormally: {}", + process_id, + watch_profile_path, + status + ); + } + } + Err(e) => { + log::warn!("Failed to await Camoufox PID {:?} exit: {}", process_id, e); + } + } + }); + // Store the instance let instance = CamoufoxInstance { id: instance_id.clone(), @@ -557,28 +585,28 @@ impl CamoufoxManager { for (id, instance) in inner.instances.iter() { if let Some(process_id) = instance.process_id { - // Check if the process is still alive if !self.is_server_running(process_id).await { - // Process is dead - // Camoufox instance is no longer running + log::info!( + "Camoufox instance {} (PID {}) is no longer running; profile_path={:?}", + id, + process_id, + instance.profile_path + ); dead_instances.push(id.clone()); instances_to_remove.push(id.clone()); } } else { - // No process_id means it's likely a dead instance - // Camoufox instance has no PID, marking as dead + log::info!("Camoufox instance {} has no PID, marking as dead", id); dead_instances.push(id.clone()); instances_to_remove.push(id.clone()); } } } - // Remove dead instances if !instances_to_remove.is_empty() { let mut inner = self.inner.lock().await; for id in &instances_to_remove { inner.instances.remove(id); - // Removed dead Camoufox instance } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8ba1671..97314d2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -99,7 +99,7 @@ use settings_manager::{ }; use sync::{ - check_has_e2e_password, delete_e2e_password, enable_sync_for_all_entities, + cancel_profile_sync, check_has_e2e_password, delete_e2e_password, enable_sync_for_all_entities, get_unsynced_entity_counts, is_group_in_use_by_synced_profile, is_proxy_in_use_by_synced_profile, is_vpn_in_use_by_synced_profile, request_profile_sync, rollover_encryption_for_all_entities, set_e2e_password, set_extension_group_sync_enabled, set_extension_sync_enabled, @@ -2057,6 +2057,7 @@ pub fn run() { get_sync_settings, save_sync_settings, set_profile_sync_mode, + cancel_profile_sync, request_profile_sync, set_proxy_sync_enabled, set_group_sync_enabled, diff --git a/src-tauri/src/settings_manager.rs b/src-tauri/src/settings_manager.rs index 84e87e9..d2a38e8 100644 --- a/src-tauri/src/settings_manager.rs +++ b/src-tauri/src/settings_manager.rs @@ -52,7 +52,7 @@ pub struct AppSettings { #[serde(default)] pub launch_on_login_declined: bool, // User permanently declined the launch-on-login prompt #[serde(default)] - pub language: Option, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ru", or None for system default + pub language: Option, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ko", "ru", or None for system default #[serde(default)] pub window_resize_warning_dismissed: bool, #[serde(default)] diff --git a/src-tauri/src/sync/engine.rs b/src-tauri/src/sync/engine.rs index 49a37e9..754f64c 100644 --- a/src-tauri/src/sync/engine.rs +++ b/src-tauri/src/sync/engine.rs @@ -10,11 +10,48 @@ use chrono::{DateTime, Utc}; use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex as StdMutex}; use std::time::Instant; use tokio::sync::{Mutex as TokioMutex, Semaphore}; +lazy_static::lazy_static! { + static ref SYNC_CANCEL_FLAGS: StdMutex>> = + StdMutex::new(HashMap::new()); +} + +fn register_sync_cancel(profile_id: &str) -> Arc { + let mut map = SYNC_CANCEL_FLAGS.lock().unwrap(); + let flag = Arc::new(AtomicBool::new(false)); + map.insert(profile_id.to_string(), flag.clone()); + flag +} + +fn clear_sync_cancel(profile_id: &str) { + SYNC_CANCEL_FLAGS.lock().unwrap().remove(profile_id); +} + +pub fn request_sync_cancel(profile_id: &str) -> bool { + if let Some(flag) = SYNC_CANCEL_FLAGS.lock().unwrap().get(profile_id) { + flag.store(true, Ordering::SeqCst); + true + } else { + false + } +} + +struct SyncCancelGuard(String); +impl Drop for SyncCancelGuard { + fn drop(&mut self) { + clear_sync_cancel(&self.0); + } +} + +#[tauri::command] +pub async fn cancel_profile_sync(profile_id: String) -> Result { + Ok(request_sync_cancel(&profile_id)) +} + /// Upload/download concurrency limit const SYNC_CONCURRENCY: usize = 32; @@ -391,6 +428,9 @@ impl SyncEngine { let profile_dir = profiles_dir.join(profile.id.to_string()); let profile_id = profile.id.to_string(); + let cancel_flag = register_sync_cancel(&profile_id); + let _cancel_guard = SyncCancelGuard(profile_id.clone()); + // Determine team key prefix for team profiles let key_prefix = Self::get_team_key_prefix(profile).await; @@ -514,10 +554,16 @@ impl SyncEngine { &diff.files_to_upload, encryption_key.as_ref(), &key_prefix, + &cancel_flag, ) .await?; } + if cancel_flag.load(Ordering::Relaxed) { + log::info!("Sync cancelled for profile {} after uploads", profile_id); + return Err(SyncError::Cancelled); + } + // Perform downloads if !diff.files_to_download.is_empty() { self @@ -529,10 +575,16 @@ impl SyncEngine { &diff.files_to_download, encryption_key.as_ref(), &key_prefix, + &cancel_flag, ) .await?; } + if cancel_flag.load(Ordering::Relaxed) { + log::info!("Sync cancelled for profile {} after downloads", profile_id); + return Err(SyncError::Cancelled); + } + // Delete local files that don't exist remotely (when remote is newer) for path in &diff.files_to_delete_local { let file_path = profile_dir.join(path); @@ -823,6 +875,7 @@ impl SyncEngine { files: &[super::manifest::ManifestFileEntry], encryption_key: Option<&[u8; 32]>, key_prefix: &str, + cancel_flag: &Arc, ) -> SyncResult<()> { if files.is_empty() { return Ok(()); @@ -930,6 +983,13 @@ impl SyncEngine { let save_counter = Arc::new(AtomicU64::new(0)); for file in &files_to_process { + if cancel_flag.load(Ordering::Relaxed) { + log::info!( + "Upload cancelled for profile {} before scheduling more files", + profile_id_owned + ); + break; + } let sem = semaphore.clone(); let file_path = profile_dir.join(&file.path); let relative_path = file.path.clone(); @@ -958,6 +1018,7 @@ impl SyncEngine { let resume_state = resume_state.clone(); let save_counter = save_counter.clone(); let profile_dir_clone = profile_dir.clone(); + let cancel_flag_task = cancel_flag.clone(); let content_type = mime_guess::from_path(&file.path) .first() .map(|m| m.to_string()); @@ -965,6 +1026,10 @@ impl SyncEngine { handles.push(tokio::spawn(async move { let _permit = sem.acquire().await.unwrap(); + if cancel_flag_task.load(Ordering::Relaxed) { + return Err((relative_path, "cancelled".to_string(), false)); + } + let data = match fs::read(&file_path) { Ok(d) => d, Err(e) if e.kind() == std::io::ErrorKind::NotFound && !critical => { @@ -1095,6 +1160,7 @@ impl SyncEngine { files: &[super::manifest::ManifestFileEntry], encryption_key: Option<&[u8; 32]>, key_prefix: &str, + cancel_flag: &Arc, ) -> SyncResult<()> { if files.is_empty() { return Ok(()); @@ -1194,6 +1260,13 @@ impl SyncEngine { let save_counter = Arc::new(AtomicU64::new(0)); for file in &files_to_process { + if cancel_flag.load(Ordering::Relaxed) { + log::info!( + "Download cancelled for profile {} before scheduling more files", + profile_id_owned + ); + break; + } let sem = semaphore.clone(); let file_path = profile_dir.join(&file.path); let relative_path = file.path.clone(); @@ -1222,13 +1295,21 @@ impl SyncEngine { let resume_state = resume_state.clone(); let save_counter = save_counter.clone(); let profile_dir_clone = profile_dir.clone(); + let cancel_flag_task = cancel_flag.clone(); handles.push(tokio::spawn(async move { let _permit = sem.acquire().await.unwrap(); + if cancel_flag_task.load(Ordering::Relaxed) { + return Err((relative_path, "cancelled".to_string(), false)); + } + // Retry loop for network downloads let mut last_err = String::new(); for attempt in 0..MAX_FILE_RETRIES { + if cancel_flag_task.load(Ordering::Relaxed) { + return Err((relative_path, "cancelled".to_string(), false)); + } match client.download_bytes(&url).await { Ok(data) => { let write_data = if let Some(ref key) = enc_key { @@ -2361,6 +2442,8 @@ impl SyncEngine { ); } if !manifest.files.is_empty() { + let cancel_flag = register_sync_cancel(profile_id); + let _cancel_guard = SyncCancelGuard(profile_id.to_string()); self .download_profile_files( app_handle, @@ -2370,6 +2453,7 @@ impl SyncEngine { &manifest.files, encryption_key.as_ref(), key_prefix, + &cancel_flag, ) .await?; } @@ -2506,8 +2590,46 @@ impl SyncEngine { profiles_to_check.len() ); - // For each remote profile, check if it exists locally and download if missing + // For each remote profile, check if it exists locally and download if missing. + // Skip any profile that has a tombstone — a leftover manifest under a + // tombstoned id means delete_prefix raced or partially failed, and + // re-downloading it here is what surfaced the "Browsing keeps re-syncing" + // bug after a delete. for (profile_id, key_prefix) in &profiles_to_check { + let personal_tombstone = format!("tombstones/profiles/{}.json", profile_id); + let has_personal_tombstone = matches!( + self.client.stat(&personal_tombstone).await, + Ok(stat) if stat.exists + ); + let team_tombstone_key = if key_prefix.is_empty() { + None + } else { + Some(format!( + "{}tombstones/profiles/{}.json", + key_prefix, profile_id + )) + }; + let has_team_tombstone = if let Some(ref tk) = team_tombstone_key { + matches!(self.client.stat(tk).await, Ok(stat) if stat.exists) + } else { + false + }; + if has_personal_tombstone || has_team_tombstone { + log::info!( + "Skipping download of tombstoned profile {} (clearing leftover remote files)", + profile_id + ); + let prefix = format!("{}profiles/{}/", key_prefix, profile_id); + if let Err(e) = self.client.delete_prefix(&prefix, None).await { + log::warn!( + "Failed to clear stale remote files for tombstoned profile {}: {}", + profile_id, + e + ); + } + continue; + } + match self .download_profile_if_missing(app_handle, profile_id, key_prefix) .await @@ -2571,6 +2693,24 @@ impl SyncEngine { }; if has_personal_tombstone || has_team_tombstone { + // Originator guard: re-read the profile right before deleting. If the + // local user disabled sync between the snapshot above and this stat + // call, they're the one who wrote this tombstone — keep their local + // copy. Tombstones must delete remote-originated changes, never the + // sender's own data. (Caused mass local deletion in v0.24.x.) + let still_sync_enabled = profile_manager + .list_profiles() + .unwrap_or_default() + .iter() + .find(|p| p.id.to_string() == *pid) + .is_some_and(|p| p.is_sync_enabled()); + if !still_sync_enabled { + log::info!( + "Profile {} has a tombstone but sync is no longer enabled locally — keeping local copy (originating device)", + pid + ); + continue; + } log::info!( "Profile {} has remote tombstone, deleting locally (deleted on another device)", pid @@ -2948,6 +3088,11 @@ pub async fn set_profile_sync_mode( return Err("Cannot modify sync settings for a cross-OS profile".to_string()); } + let enabling_now = new_mode != SyncMode::Disabled; + if enabling_now && profile.process_id.is_some() { + return Err(serde_json::json!({ "code": "PROFILE_RUNNING" }).to_string()); + } + if profile.ephemeral { return Err("Cannot enable sync for an ephemeral profile".to_string()); } @@ -3029,6 +3174,22 @@ pub async fn set_profile_sync_mode( let _ = events::emit("profiles-changed", ()); + // When (re-)enabling sync, clear any stale tombstone from a previous + // disable on this device. Otherwise the next reconcile on another + // device — or even a race on this one — would see the tombstone and + // delete the freshly re-uploaded data. + if enabling { + if let Ok(engine) = SyncEngine::create_from_settings(&app_handle).await { + let key_prefix = SyncEngine::get_team_key_prefix(&profile).await; + let personal_tombstone = format!("tombstones/profiles/{}.json", profile_id); + let _ = engine.client.delete(&personal_tombstone, None).await; + if !key_prefix.is_empty() { + let team_tombstone = format!("{}tombstones/profiles/{}.json", key_prefix, profile_id); + let _ = engine.client.delete(&team_tombstone, None).await; + } + } + } + if enabling { let is_running = profile.process_id.is_some(); @@ -3084,28 +3245,25 @@ pub async fn set_profile_sync_mode( log::warn!("Scheduler not initialized, sync will not start"); } } else { - // Delete remote data when disabling sync + // Delete remote data when disabling sync. Awaited (not spawned) so the + // tombstone write completes before this command returns. A previous + // tokio::spawn here allowed the tombstone-write to land *after* a fast + // user-triggered re-enable's tombstone-clear, re-introducing the + // tombstone and tripping the reconcile-pass deletion of a profile the + // user had just re-enabled (e.g. Personal (z.ai) on 2026-05-20). if old_mode != SyncMode::Disabled { - let profile_id_clone = profile_id.clone(); - let app_handle_clone = app_handle.clone(); - tokio::spawn(async move { - match SyncEngine::create_from_settings(&app_handle_clone).await { - Ok(engine) => { - if let Err(e) = engine.delete_profile(&profile_id_clone).await { - log::warn!( - "Failed to delete profile {} from sync: {}", - profile_id_clone, - e - ); - } else { - log::info!("Profile {} deleted from sync service", profile_id_clone); - } - } - Err(e) => { - log::debug!("Sync not configured, skipping remote deletion: {}", e); + match SyncEngine::create_from_settings(&app_handle).await { + Ok(engine) => { + if let Err(e) = engine.delete_profile(&profile_id).await { + log::warn!("Failed to delete profile {} from sync: {}", profile_id, e); + } else { + log::info!("Profile {} deleted from sync service", profile_id); } } - }); + Err(e) => { + log::debug!("Sync not configured, skipping remote deletion: {}", e); + } + } } let _ = events::emit( @@ -3183,6 +3341,28 @@ pub async fn sync_profile(app_handle: tauri::AppHandle, profile_id: String) -> R trigger_sync_for_profile(app_handle, profile_id).await } +/// Ensure the device has either a cloud login or a self-hosted server URL + token. +/// Returns a JSON error code string consumable by the frontend translator. +async fn ensure_sync_configured(app_handle: &tauri::AppHandle) -> Result<(), String> { + let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await; + if cloud_logged_in { + return Ok(()); + } + let manager = SettingsManager::instance(); + let settings = manager.load_settings().map_err(|e| { + serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } }) + .to_string() + })?; + if settings.sync_server_url.is_none() { + return Err(serde_json::json!({ "code": "SYNC_NOT_CONFIGURED" }).to_string()); + } + let token = manager.get_sync_token(app_handle).await.ok().flatten(); + if token.is_none() { + return Err(serde_json::json!({ "code": "SYNC_NOT_CONFIGURED" }).to_string()); + } + Ok(()) +} + pub async fn trigger_sync_for_profile( app_handle: tauri::AppHandle, profile_id: String, @@ -3222,43 +3402,29 @@ pub async fn set_proxy_sync_enabled( let proxy = proxies .iter() .find(|p| p.id == proxy_id) - .ok_or_else(|| format!("Proxy with ID '{proxy_id}' not found"))?; + .ok_or_else(|| serde_json::json!({ "code": "PROXY_NOT_FOUND" }).to_string())?; // Block modifying sync for cloud-managed proxies if proxy.is_cloud_managed { - return Err("Cannot modify sync for a cloud-managed proxy".to_string()); + return Err(serde_json::json!({ "code": "CANNOT_MODIFY_CLOUD_MANAGED_PROXY" }).to_string()); } // If disabling, check if proxy is used by any synced profile if !enabled && is_proxy_used_by_synced_profile(&proxy_id) { - return Err("Sync cannot be disabled while this proxy is used by synced profiles".to_string()); + return Err(serde_json::json!({ "code": "SYNC_LOCKED_BY_PROFILE" }).to_string()); } // If enabling, check that sync settings are configured if enabled { - let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await; - - if !cloud_logged_in { - let manager = SettingsManager::instance(); - let settings = manager - .load_settings() - .map_err(|e| format!("Failed to load settings: {e}"))?; - - if settings.sync_server_url.is_none() { - return Err( - "Sync server not configured. Please configure sync settings first.".to_string(), - ); - } - - let token = manager.get_sync_token(&app_handle).await.ok().flatten(); - if token.is_none() { - return Err("Sync token not configured. Please configure sync settings first.".to_string()); - } - } + ensure_sync_configured(&app_handle).await?; } let new_last_sync = if enabled { proxy.last_sync } else { None }; - proxy_manager.set_stored_proxy_sync_state(&proxy_id, enabled, new_last_sync)?; + proxy_manager + .set_stored_proxy_sync_state(&proxy_id, enabled, new_last_sync) + .map_err(|e| { + serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e } }).to_string() + })?; let _ = events::emit("stored-proxies-changed", ()); @@ -3299,36 +3465,18 @@ pub async fn set_group_sync_enabled( groups .iter() .find(|g| g.id == group_id) - .ok_or_else(|| format!("Group with ID '{group_id}' not found"))? + .ok_or_else(|| serde_json::json!({ "code": "GROUP_NOT_FOUND" }).to_string())? .clone() }; // If disabling, check if group is used by any synced profile if !enabled && is_group_used_by_synced_profile(&group_id) { - return Err("Sync cannot be disabled while this group is used by synced profiles".to_string()); + return Err(serde_json::json!({ "code": "SYNC_LOCKED_BY_PROFILE" }).to_string()); } // If enabling, check that sync settings are configured if enabled { - let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await; - - if !cloud_logged_in { - let manager = SettingsManager::instance(); - let settings = manager - .load_settings() - .map_err(|e| format!("Failed to load settings: {e}"))?; - - if settings.sync_server_url.is_none() { - return Err( - "Sync server not configured. Please configure sync settings first.".to_string(), - ); - } - - let token = manager.get_sync_token(&app_handle).await.ok().flatten(); - if token.is_none() { - return Err("Sync token not configured. Please configure sync settings first.".to_string()); - } - } + ensure_sync_configured(&app_handle).await?; } let mut updated_group = group.clone(); @@ -3341,7 +3489,10 @@ pub async fn set_group_sync_enabled( { let group_manager = crate::group_manager::GROUP_MANAGER.lock().unwrap(); if let Err(e) = group_manager.update_group_internal(&updated_group) { - return Err(format!("Failed to update group: {e}")); + return Err( + serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } }) + .to_string(), + ); } } @@ -3392,35 +3543,17 @@ pub async fn set_vpn_sync_enabled( let storage = crate::vpn::VPN_STORAGE.lock().unwrap(); storage .load_config(&vpn_id) - .map_err(|e| format!("VPN with ID '{vpn_id}' not found: {e}"))? + .map_err(|_| serde_json::json!({ "code": "VPN_NOT_FOUND" }).to_string())? }; // If disabling, check if VPN is used by any synced profile if !enabled && is_vpn_used_by_synced_profile(&vpn_id) { - return Err("Sync cannot be disabled while this VPN is used by synced profiles".to_string()); + return Err(serde_json::json!({ "code": "SYNC_LOCKED_BY_PROFILE" }).to_string()); } // If enabling, check that sync settings are configured if enabled { - let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await; - - if !cloud_logged_in { - let manager = SettingsManager::instance(); - let settings = manager - .load_settings() - .map_err(|e| format!("Failed to load settings: {e}"))?; - - if settings.sync_server_url.is_none() { - return Err( - "Sync server not configured. Please configure sync settings first.".to_string(), - ); - } - - let token = manager.get_sync_token(&app_handle).await.ok().flatten(); - if token.is_none() { - return Err("Sync token not configured. Please configure sync settings first.".to_string()); - } - } + ensure_sync_configured(&app_handle).await?; } let last_sync = if enabled { vpn.last_sync } else { None }; @@ -3429,7 +3562,10 @@ pub async fn set_vpn_sync_enabled( let storage = crate::vpn::VPN_STORAGE.lock().unwrap(); storage .update_sync_fields(&vpn_id, enabled, last_sync) - .map_err(|e| format!("Failed to update VPN sync: {e}"))?; + .map_err(|e| { + serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } }) + .to_string() + })?; } let _ = events::emit("vpn-configs-changed", ()); @@ -3526,48 +3662,10 @@ pub fn get_unsynced_entity_counts() -> Result { #[tauri::command] pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Result<(), String> { - // Enable sync for all eligible profiles. Without this the user would see - // groups/proxies/vpns syncing while their profiles stay local-only — the - // long-standing source of issue #352. Encrypted mode wins when an E2E - // password is already configured; otherwise we fall back to plain Regular. - { - let profile_manager = ProfileManager::instance(); - let profiles = profile_manager - .list_profiles() - .map_err(|e| format!("Failed to list profiles: {e}"))?; - let desired_mode = if encryption::has_e2e_password() { - SyncMode::Encrypted - } else { - SyncMode::Regular - }; - let desired_mode_str = match desired_mode { - SyncMode::Encrypted => "Encrypted", - SyncMode::Regular => "Regular", - SyncMode::Disabled => "Disabled", - }; - for profile in &profiles { - // Skip profiles that are already syncing (any non-Disabled mode), - // ephemeral profiles (data wipes on quit, sync is meaningless), and - // cross-OS profiles (the OS-specific binary isn't installed locally - // so a sync round-trip would be one-sided). - if profile.sync_mode != SyncMode::Disabled || profile.ephemeral || profile.is_cross_os() { - continue; - } - if let Err(e) = set_profile_sync_mode( - app_handle.clone(), - profile.id.to_string(), - desired_mode_str.to_string(), - ) - .await - { - log::warn!( - "Failed to enable sync for profile {} ({}): {e}", - profile.name, - profile.id - ); - } - } - } + // Intentionally excludes profiles: enabling profile sync uploads the entire + // browser data dir per profile, which is destructive if the user expected + // an opt-in. Profile sync stays under explicit per-profile control via + // set_profile_sync_mode. This command only touches metadata-sized entities. // Enable sync for all unsynced proxies { @@ -3664,26 +3762,11 @@ pub async fn set_extension_sync_enabled( let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); manager .get_extension(&extension_id) - .map_err(|e| format!("Extension with ID '{extension_id}' not found: {e}"))? + .map_err(|_| serde_json::json!({ "code": "EXTENSION_NOT_FOUND" }).to_string())? }; if enabled { - let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await; - if !cloud_logged_in { - let manager = SettingsManager::instance(); - let settings = manager - .load_settings() - .map_err(|e| format!("Failed to load settings: {e}"))?; - if settings.sync_server_url.is_none() { - return Err( - "Sync server not configured. Please configure sync settings first.".to_string(), - ); - } - let token = manager.get_sync_token(&app_handle).await.ok().flatten(); - if token.is_none() { - return Err("Sync token not configured. Please configure sync settings first.".to_string()); - } - } + ensure_sync_configured(&app_handle).await?; } let mut updated_ext = ext; @@ -3696,7 +3779,10 @@ pub async fn set_extension_sync_enabled( let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); manager .update_extension_internal(&updated_ext) - .map_err(|e| format!("Failed to update extension sync: {e}"))?; + .map_err(|e| { + serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } }) + .to_string() + })?; } let _ = events::emit("extensions-changed", ()); @@ -3720,26 +3806,11 @@ pub async fn set_extension_group_sync_enabled( let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); manager .get_group(&extension_group_id) - .map_err(|e| format!("Extension group with ID '{extension_group_id}' not found: {e}"))? + .map_err(|_| serde_json::json!({ "code": "EXTENSION_GROUP_NOT_FOUND" }).to_string())? }; if enabled { - let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await; - if !cloud_logged_in { - let manager = SettingsManager::instance(); - let settings = manager - .load_settings() - .map_err(|e| format!("Failed to load settings: {e}"))?; - if settings.sync_server_url.is_none() { - return Err( - "Sync server not configured. Please configure sync settings first.".to_string(), - ); - } - let token = manager.get_sync_token(&app_handle).await.ok().flatten(); - if token.is_none() { - return Err("Sync token not configured. Please configure sync settings first.".to_string()); - } - } + ensure_sync_configured(&app_handle).await?; } let mut updated_group = group; @@ -3750,9 +3821,10 @@ pub async fn set_extension_group_sync_enabled( { let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); - manager - .update_group_internal(&updated_group) - .map_err(|e| format!("Failed to update extension group sync: {e}"))?; + manager.update_group_internal(&updated_group).map_err(|e| { + serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } }) + .to_string() + })?; } let _ = events::emit("extensions-changed", ()); diff --git a/src-tauri/src/sync/manifest.rs b/src-tauri/src/sync/manifest.rs index 214f325..229d9f5 100644 --- a/src-tauri/src/sync/manifest.rs +++ b/src-tauri/src/sync/manifest.rs @@ -35,6 +35,16 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[ "**/startupCache/**", "**/safebrowsing/**", "**/storage/temporary/**", + "**/storage/default/*/cache/**", + "**/datareporting/**", + "**/saved-telemetry-pings/**", + "**/sessionstore-backups/**", + "**/sessions/**", + "**/serviceworker.txt", + "**/AlternateServices.bin", + "**/SiteSecurityServiceState.bin", + "**/favicons.sqlite", + "**/favicons.sqlite-*", "**/crashes/**", "**/minidumps/**", "*.tmp", diff --git a/src-tauri/src/sync/mod.rs b/src-tauri/src/sync/mod.rs index 8e14d51..ca49fb9 100644 --- a/src-tauri/src/sync/mod.rs +++ b/src-tauri/src/sync/mod.rs @@ -11,9 +11,9 @@ pub use encryption::{ check_has_e2e_password, delete_e2e_password, set_e2e_password, verify_e2e_password, }; pub use engine::{ - enable_extension_group_sync_if_needed, enable_group_sync_if_needed, enable_proxy_sync_if_needed, - enable_sync_for_all_entities, enable_vpn_sync_if_needed, get_unsynced_entity_counts, - is_group_in_use_by_synced_profile, is_group_used_by_synced_profile, + cancel_profile_sync, enable_extension_group_sync_if_needed, enable_group_sync_if_needed, + enable_proxy_sync_if_needed, enable_sync_for_all_entities, enable_vpn_sync_if_needed, + get_unsynced_entity_counts, is_group_in_use_by_synced_profile, is_group_used_by_synced_profile, is_proxy_in_use_by_synced_profile, is_proxy_used_by_synced_profile, is_sync_configured, is_vpn_in_use_by_synced_profile, is_vpn_used_by_synced_profile, request_profile_sync, rollover_encryption_for_all_entities, set_extension_group_sync_enabled, diff --git a/src-tauri/src/sync/scheduler.rs b/src-tauri/src/sync/scheduler.rs index 62be7c5..8da2050 100644 --- a/src-tauri/src/sync/scheduler.rs +++ b/src-tauri/src/sync/scheduler.rs @@ -716,16 +716,18 @@ impl SyncScheduler { match entity_type.as_str() { "profile" => { let profile_manager = ProfileManager::instance(); - let has_profile = { + let local_sync_enabled = { if let Ok(profiles) = profile_manager.list_profiles() { let profile_uuid = uuid::Uuid::parse_str(&entity_id).ok(); - profile_uuid.is_some_and(|uuid| profiles.iter().any(|p| p.id == uuid)) + profile_uuid + .and_then(|uuid| profiles.into_iter().find(|p| p.id == uuid)) + .is_some_and(|p| p.is_sync_enabled()) } else { false } }; - if has_profile { + if local_sync_enabled { log::info!( "Profile {} was deleted remotely, deleting locally", entity_id @@ -733,6 +735,11 @@ impl SyncScheduler { if let Err(e) = profile_manager.delete_profile_local_only(&entity_id) { log::warn!("Failed to delete tombstoned profile {}: {}", entity_id, e); } + } else { + log::info!( + "Profile {} has a tombstone but sync is no longer enabled locally — keeping local copy", + entity_id + ); } } "proxy" => { diff --git a/src-tauri/src/sync/types.rs b/src-tauri/src/sync/types.rs index 1c10426..5cf671e 100644 --- a/src-tauri/src/sync/types.rs +++ b/src-tauri/src/sync/types.rs @@ -166,6 +166,7 @@ pub enum SyncError { SerializationError(String), ConflictError(String), InvalidData(String), + Cancelled, } impl std::fmt::Display for SyncError { @@ -178,6 +179,7 @@ impl std::fmt::Display for SyncError { SyncError::SerializationError(msg) => write!(f, "Serialization error: {msg}"), SyncError::ConflictError(msg) => write!(f, "Conflict error: {msg}"), SyncError::InvalidData(msg) => write!(f, "Invalid data: {msg}"), + SyncError::Cancelled => write!(f, "Sync cancelled by user"), } } } diff --git a/src/app/page.tsx b/src/app/page.tsx index f18ddbf..d27414e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1174,7 +1174,7 @@ export default function Home() { failed_count: payload.failed_count ?? 0, phase: payload.phase, }, - { id: toastId }, + { id: toastId, profileId: payload.profile_id }, ); } }); diff --git a/src/components/delete-confirmation-dialog.tsx b/src/components/delete-confirmation-dialog.tsx index 342aabd..159b6b1 100644 --- a/src/components/delete-confirmation-dialog.tsx +++ b/src/components/delete-confirmation-dialog.tsx @@ -42,7 +42,7 @@ export function DeleteConfirmationDialog({ return ( - + {title} {description} diff --git a/src/components/extension-management-dialog.tsx b/src/components/extension-management-dialog.tsx index 90f942f..eacb12b 100644 --- a/src/components/extension-management-dialog.tsx +++ b/src/components/extension-management-dialog.tsx @@ -73,6 +73,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { parseBackendError, translateBackendError } from "@/lib/backend-errors"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; import type { Extension, ExtensionGroup } from "@/types"; import { DeleteConfirmationDialog } from "./delete-confirmation-dialog"; @@ -308,7 +309,11 @@ export function ExtensionManagementDialog({ ); void loadData(); } catch (err) { - showErrorToast(err instanceof Error ? err.message : String(err)); + showErrorToast( + parseBackendError(err) + ? translateBackendError(t, err) + : t("proxies.management.updateSyncFailed"), + ); } finally { setIsTogglingExtSync((prev) => ({ ...prev, [ext.id]: false })); } @@ -331,7 +336,11 @@ export function ExtensionManagementDialog({ ); void loadData(); } catch (err) { - showErrorToast(err instanceof Error ? err.message : String(err)); + showErrorToast( + parseBackendError(err) + ? translateBackendError(t, err) + : t("proxies.management.updateSyncFailed"), + ); } finally { setIsTogglingGroupSync((prev) => ({ ...prev, [group.id]: false })); } @@ -589,9 +598,15 @@ export function ExtensionManagementDialog({ }), ), ); - const failed = results.filter((r) => r.status === "rejected").length; - if (failed > 0) { - showErrorToast(t("proxies.management.updateSyncFailed")); + const firstRejection = results.find((r) => r.status === "rejected") as + | PromiseRejectedResult + | undefined; + if (firstRejection) { + showErrorToast( + parseBackendError(firstRejection.reason) + ? translateBackendError(t, firstRejection.reason) + : t("proxies.management.updateSyncFailed"), + ); } else { showSuccessToast( targetEnabled @@ -614,9 +629,15 @@ export function ExtensionManagementDialog({ }), ), ); - const failed = results.filter((r) => r.status === "rejected").length; - if (failed > 0) { - showErrorToast(t("proxies.management.updateSyncFailed")); + const firstRejection = results.find((r) => r.status === "rejected") as + | PromiseRejectedResult + | undefined; + if (firstRejection) { + showErrorToast( + parseBackendError(firstRejection.reason) + ? translateBackendError(t, firstRejection.reason) + : t("proxies.management.updateSyncFailed"), + ); } else { showSuccessToast( targetEnabled diff --git a/src/components/group-management-dialog.tsx b/src/components/group-management-dialog.tsx index 70bc98b..dce219a 100644 --- a/src/components/group-management-dialog.tsx +++ b/src/components/group-management-dialog.tsx @@ -57,6 +57,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { parseBackendError, translateBackendError } from "@/lib/backend-errors"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; import type { GroupWithCount, ProfileGroup } from "@/types"; import { RippleButton } from "./ui/ripple"; @@ -262,8 +263,8 @@ export function GroupManagementDialog({ } catch (error) { console.error("Failed to toggle sync:", error); showErrorToast( - error instanceof Error - ? error.message + parseBackendError(error) + ? translateBackendError(t, error) : t("proxies.management.updateSyncFailed"), ); } finally { @@ -529,9 +530,15 @@ export function GroupManagementDialog({ }), ), ); - const failed = results.filter((r) => r.status === "rejected").length; - if (failed > 0) { - showErrorToast(t("proxies.management.updateSyncFailed")); + const firstRejection = results.find((r) => r.status === "rejected") as + | PromiseRejectedResult + | undefined; + if (firstRejection) { + showErrorToast( + parseBackendError(firstRejection.reason) + ? translateBackendError(t, firstRejection.reason) + : t("proxies.management.updateSyncFailed"), + ); } else { showSuccessToast( targetEnabled diff --git a/src/components/integrations-dialog.tsx b/src/components/integrations-dialog.tsx index 91040e9..9924e98 100644 --- a/src/components/integrations-dialog.tsx +++ b/src/components/integrations-dialog.tsx @@ -120,6 +120,7 @@ export function IntegrationsDialog({ const [isMcpStarting, setIsMcpStarting] = useState(false); const [agents, setAgents] = useState([]); const [busyAgentIds, setBusyAgentIds] = useState>(new Set()); + const [apiPortDraft, setApiPortDraft] = useState("10108"); const { termsAccepted } = useWayfernTerms(); @@ -127,6 +128,7 @@ export function IntegrationsDialog({ try { const loaded = await invoke("get_app_settings"); setSettings(loaded); + setApiPortDraft(String(loaded.api_port ?? "")); } catch (e) { console.error("Failed to load settings:", e); } @@ -370,13 +372,24 @@ export function IntegrationsDialog({
{ + setApiPortDraft(e.target.value); const val = Number.parseInt(e.target.value, 10); - if (!Number.isNaN(val)) { + if ( + !Number.isNaN(val) && + val >= 1 && + val <= 65535 + ) { setSettings({ ...settings, api_port: val }); } }} + onBlur={() => { + const val = Number.parseInt(apiPortDraft, 10); + if (Number.isNaN(val) || val < 1 || val > 65535) { + setApiPortDraft(String(settings.api_port)); + } + }} className="w-24 font-mono" min={1} max={65535} diff --git a/src/components/loading-button.tsx b/src/components/loading-button.tsx index 99e7aa7..aacf58e 100644 --- a/src/components/loading-button.tsx +++ b/src/components/loading-button.tsx @@ -12,7 +12,7 @@ type Props = ButtonProps & { export const LoadingButton = ({ isLoading, className, ...props }: Props) => { return ( diff --git a/src/components/profile-info-dialog.tsx b/src/components/profile-info-dialog.tsx index 359d02a..9262d4a 100644 --- a/src/components/profile-info-dialog.tsx +++ b/src/components/profile-info-dialog.tsx @@ -582,8 +582,9 @@ function ProfileInfoLayout({ const deleteAction = findAction("delete"); const fingerprintAction = findAction("fingerprint"); - const cookiesAction = - findAction("manage cookies") ?? findAction("copy cookies"); + const cookiesManageAction = findAction("manage cookies"); + const cookiesCopyAction = findAction("copy cookies"); + const cookiesAction = cookiesManageAction ?? cookiesCopyAction; const extensionAction = findAction("extension"); const syncAction = findAction("sync"); const _launchHookAction = findAction("hook") ?? findAction("launch hook"); @@ -905,6 +906,7 @@ function ProfileInfoLayout({ profile={profile} isRunning={isRunning} isDisabled={isDisabled} + onCopyCookies={cookiesCopyAction?.onClick} t={t} /> )} @@ -1435,11 +1437,14 @@ function ExtensionsSectionInline({ function CookiesSectionInline({ profile, isRunning, + isDisabled, + onCopyCookies, t, }: { profile: BrowserProfile; isRunning: boolean; isDisabled: boolean; + onCopyCookies?: () => void; t: (key: string, options?: Record) => string; }) { type CookieStats = { @@ -1483,9 +1488,23 @@ function CookiesSectionInline({ return (
-
- - {t("profileInfo.sections.cookies")} +
+
+ + {t("profileInfo.sections.cookies")} +
+ {onCopyCookies && ( + + )}

{t("profileInfo.sectionDesc.cookies")} diff --git a/src/components/proxy-management-dialog.tsx b/src/components/proxy-management-dialog.tsx index c8e0cae..c361699 100644 --- a/src/components/proxy-management-dialog.tsx +++ b/src/components/proxy-management-dialog.tsx @@ -67,6 +67,7 @@ import { } from "@/components/ui/tooltip"; import { useProxyEvents } from "@/hooks/use-proxy-events"; import { useVpnEvents } from "@/hooks/use-vpn-events"; +import { parseBackendError, translateBackendError } from "@/lib/backend-errors"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; import { cn } from "@/lib/utils"; import type { ProxyCheckResult, StoredProxy, VpnConfig } from "@/types"; @@ -394,8 +395,8 @@ export function ProxyManagementDialog({ } catch (error) { console.error("Failed to toggle sync:", error); showErrorToast( - error instanceof Error - ? error.message + parseBackendError(error) + ? translateBackendError(t, error) : t("proxies.management.updateSyncFailed"), ); } finally { @@ -458,8 +459,8 @@ export function ProxyManagementDialog({ } catch (error) { console.error("Failed to toggle VPN sync:", error); showErrorToast( - error instanceof Error - ? error.message + parseBackendError(error) + ? translateBackendError(t, error) : t("proxies.management.updateSyncFailed"), ); } finally { @@ -1010,9 +1011,15 @@ export function ProxyManagementDialog({ }), ), ); - const failed = results.filter((r) => r.status === "rejected").length; - if (failed > 0) { - showErrorToast(t("proxies.management.updateSyncFailed")); + const firstRejection = results.find((r) => r.status === "rejected") as + | PromiseRejectedResult + | undefined; + if (firstRejection) { + showErrorToast( + parseBackendError(firstRejection.reason) + ? translateBackendError(t, firstRejection.reason) + : t("proxies.management.updateSyncFailed"), + ); } else { showSuccessToast( targetEnabled @@ -1039,9 +1046,15 @@ export function ProxyManagementDialog({ }), ), ); - const failed = results.filter((r) => r.status === "rejected").length; - if (failed > 0) { - showErrorToast(t("vpns.management.updateSyncFailed")); + const firstRejection = results.find((r) => r.status === "rejected") as + | PromiseRejectedResult + | undefined; + if (firstRejection) { + showErrorToast( + parseBackendError(firstRejection.reason) + ? translateBackendError(t, firstRejection.reason) + : t("proxies.management.updateSyncFailed"), + ); } else { showSuccessToast( targetEnabled @@ -1055,7 +1068,7 @@ export function ProxyManagementDialog({ return ( <>

- + {!subPage && ( {t("proxies.management.title")} @@ -1170,7 +1183,7 @@ export function ProxyManagementDialog({ } as React.CSSProperties } > - +
{proxiesTable.getHeaderGroups().map((headerGroup) => ( @@ -1251,7 +1264,7 @@ export function ProxyManagementDialog({ } as React.CSSProperties } > -
+
{vpnsTable.getHeaderGroups().map((headerGroup) => ( diff --git a/src/components/settings-dialog.tsx b/src/components/settings-dialog.tsx index eeafe88..98bc4ca 100644 --- a/src/components/settings-dialog.tsx +++ b/src/components/settings-dialog.tsx @@ -464,6 +464,7 @@ export function SettingsDialog({ | "fr" | "zh" | "ja" + | "ko" | "ru"), ); setOriginalLanguage(selectedLanguage); diff --git a/src/components/sync-all-dialog.tsx b/src/components/sync-all-dialog.tsx index c60b694..756427c 100644 --- a/src/components/sync-all-dialog.tsx +++ b/src/components/sync-all-dialog.tsx @@ -1,9 +1,12 @@ "use client"; import { invoke } from "@tauri-apps/api/core"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import { FiWifi } from "react-icons/fi"; +import { LuLayers, LuPuzzle, LuShield, LuUsers } from "react-icons/lu"; import { LoadingButton } from "@/components/loading-button"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -19,6 +22,8 @@ interface UnsyncedEntityCounts { proxies: number; groups: number; vpns: number; + extensions: number; + extension_groups: number; } interface SyncAllDialogProps { @@ -67,27 +72,55 @@ export function SyncAllDialog({ isOpen, onClose }: SyncAllDialogProps) { } }, [onClose, t]); - const totalCount = - (counts?.proxies ?? 0) + (counts?.groups ?? 0) + (counts?.vpns ?? 0); + const items = useMemo(() => { + if (!counts) return []; + return [ + { + key: "proxies", + count: counts.proxies, + label: t("syncAll.labels.proxies"), + Icon: FiWifi, + }, + { + key: "vpns", + count: counts.vpns, + label: t("syncAll.labels.vpns"), + Icon: LuShield, + }, + { + key: "groups", + count: counts.groups, + label: t("syncAll.labels.groups"), + Icon: LuUsers, + }, + { + key: "extensions", + count: counts.extensions, + label: t("syncAll.labels.extensions"), + Icon: LuPuzzle, + }, + { + key: "extensionGroups", + count: counts.extension_groups, + label: t("syncAll.labels.extensionGroups"), + Icon: LuLayers, + }, + ].filter((item) => item.count > 0); + }, [counts, t]); - // Don't show if there's nothing to sync + const totalCount = items.reduce((sum, item) => sum + item.count, 0); + + // Don't render anything when there's nothing to sync — the parent + // mounts this dialog eagerly after login, so silent-close is correct. if (!isLoading && totalCount === 0) { return null; } - const parts: string[] = []; - if (counts?.proxies && counts.proxies > 0) { - parts.push(t("syncAll.proxies", { count: counts.proxies })); - } - if (counts?.groups && counts.groups > 0) { - parts.push(t("syncAll.groups", { count: counts.groups })); - } - if (counts?.vpns && counts.vpns > 0) { - parts.push(t("syncAll.vpns", { count: counts.vpns })); - } - return ( - 0} onOpenChange={onClose}> + 0)} + onOpenChange={onClose} + > {t("syncAll.title")} @@ -99,10 +132,26 @@ export function SyncAllDialog({ isOpen, onClose }: SyncAllDialogProps) {
) : ( -
-

- {t("syncAll.itemsList", { items: parts.join(", ") })} -

+
+ {items.map(({ key, count, label, Icon }) => ( +
+
+ +
+
+ {label} +
+ + {count} + +
+ ))}
)} diff --git a/src/components/ui/animated-switch.tsx b/src/components/ui/animated-switch.tsx index 5841ae5..1cbe2f0 100644 --- a/src/components/ui/animated-switch.tsx +++ b/src/components/ui/animated-switch.tsx @@ -11,19 +11,18 @@ const MotionThumb = motion.create(SwitchPrimitive.Thumb); type AnimatedSwitchProps = React.ComponentProps; /** - * Toggle switch with a thumb that slides between the off (left) and on - * (right) positions and squashes wider while pressed. Animated via Framer - * Motion — no layout shift when the parent's width changes, and the - * pressed state is purely visual so external onCheckedChange semantics - * stay identical to a Radix Switch. + * Switch whose thumb actually slides between off and on. The Root flips + * its flex alignment on `data-state=checked`, which moves the Thumb's + * layout box; Framer Motion's `layout` prop tweens between the two + * positions. The thumb also squashes wider while pressed. */ function AnimatedSwitch({ className, ...props }: AnimatedSwitchProps) { return ( ); diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 87f7206..35e2f8b 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -5,6 +5,7 @@ import en from "./locales/en.json"; import es from "./locales/es.json"; import fr from "./locales/fr.json"; import ja from "./locales/ja.json"; +import ko from "./locales/ko.json"; import pt from "./locales/pt.json"; import ru from "./locales/ru.json"; import zh from "./locales/zh.json"; @@ -16,6 +17,7 @@ export const SUPPORTED_LANGUAGES = [ { code: "fr", name: "French", nativeName: "Français" }, { code: "zh", name: "Chinese", nativeName: "中文" }, { code: "ja", name: "Japanese", nativeName: "日本語" }, + { code: "ko", name: "Korean", nativeName: "한국어" }, { code: "ru", name: "Russian", nativeName: "Русский" }, ] as const; @@ -61,6 +63,7 @@ const resources = { fr: { translation: fr }, zh: { translation: zh }, ja: { translation: ja }, + ko: { translation: ko }, ru: { translation: ru }, }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index fccfdfd..d933b5a 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1070,16 +1070,16 @@ "syncAll": { "title": "Enable Sync for Existing Items", "description": "You have items that are not being synced. Would you like to enable sync for all of them?", - "itemsList": "Items not synced: {{items}}", - "proxies": "{{count}} proxy", - "proxies_plural": "{{count}} proxies", - "groups": "{{count}} group", - "groups_plural": "{{count}} groups", - "vpns": "{{count}} VPN", - "vpns_plural": "{{count}} VPNs", "enableAll": "Enable All", "skip": "Skip", - "success": "Sync enabled for all items" + "success": "Sync enabled for all items", + "labels": { + "proxies": "Proxies", + "vpns": "VPNs", + "groups": "Groups", + "extensions": "Extensions", + "extensionGroups": "Extension Groups" + } }, "crossOs": { "viewOnly": "This profile was created on {{os}} and is not supported on this system", @@ -1788,6 +1788,14 @@ "profileLocked": "Profile is locked. Enter the password first.", "invalidProfileId": "Invalid profile id", "passwordTooShort": "Password must be at least {{min}} characters", + "proxyNotFound": "Proxy not found", + "groupNotFound": "Group not found", + "vpnNotFound": "VPN not found", + "extensionNotFound": "Extension not found", + "extensionGroupNotFound": "Extension group not found", + "cannotModifyCloudManagedProxy": "Cannot modify sync for a cloud-managed proxy", + "syncLockedByProfile": "Sync cannot be disabled while this is used by synced profiles", + "syncNotConfigured": "Sync is not configured. Sign in or configure a self-hosted server first.", "internal": "Something went wrong: {{detail}}", "invalidLaunchHookUrl": "Invalid launch hook URL. Use a full http:// or https:// URL.", "cookieDbLocked": "Could not read cookies — the database is locked. Close the browser and try again.", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index df5244c..d5d08a2 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1070,16 +1070,16 @@ "syncAll": { "title": "Activar sincronización para elementos existentes", "description": "Tienes elementos que no se están sincronizando. ¿Te gustaría activar la sincronización para todos?", - "itemsList": "Elementos no sincronizados: {{items}}", - "proxies": "{{count}} proxy", - "proxies_plural": "{{count}} proxies", - "groups": "{{count}} grupo", - "groups_plural": "{{count}} grupos", - "vpns": "{{count}} VPN", - "vpns_plural": "{{count}} VPNs", "enableAll": "Activar todos", "skip": "Omitir", - "success": "Sincronización activada para todos los elementos" + "success": "Sincronización activada para todos los elementos", + "labels": { + "proxies": "Proxies", + "vpns": "VPN", + "groups": "Grupos", + "extensions": "Extensiones", + "extensionGroups": "Grupos de extensiones" + } }, "crossOs": { "viewOnly": "Este perfil fue creado en {{os}} y no es compatible con este sistema", @@ -1788,6 +1788,14 @@ "profileLocked": "El perfil está bloqueado. Introduce la contraseña primero.", "invalidProfileId": "ID de perfil no válido", "passwordTooShort": "La contraseña debe tener al menos {{min}} caracteres", + "proxyNotFound": "Proxy no encontrado", + "groupNotFound": "Grupo no encontrado", + "vpnNotFound": "VPN no encontrada", + "extensionNotFound": "Extensión no encontrada", + "extensionGroupNotFound": "Grupo de extensiones no encontrado", + "cannotModifyCloudManagedProxy": "No se puede modificar la sincronización de un proxy gestionado en la nube", + "syncLockedByProfile": "No se puede desactivar la sincronización mientras se usa en perfiles sincronizados", + "syncNotConfigured": "La sincronización no está configurada. Inicia sesión o configura un servidor propio.", "internal": "Algo salió mal: {{detail}}", "invalidLaunchHookUrl": "URL del hook de inicio no válida. Usa una URL completa http:// o https://.", "cookieDbLocked": "No se pudieron leer las cookies — la base de datos está bloqueada. Cierra el navegador e inténtalo de nuevo.", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 32e955f..3aafbfb 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1070,16 +1070,16 @@ "syncAll": { "title": "Activer la synchronisation pour les éléments existants", "description": "Vous avez des éléments qui ne sont pas synchronisés. Voulez-vous activer la synchronisation pour tous ?", - "itemsList": "Éléments non synchronisés : {{items}}", - "proxies": "{{count}} proxy", - "proxies_plural": "{{count}} proxies", - "groups": "{{count}} groupe", - "groups_plural": "{{count}} groupes", - "vpns": "{{count}} VPN", - "vpns_plural": "{{count}} VPNs", "enableAll": "Tout activer", "skip": "Ignorer", - "success": "Synchronisation activée pour tous les éléments" + "success": "Synchronisation activée pour tous les éléments", + "labels": { + "proxies": "Proxies", + "vpns": "VPN", + "groups": "Groupes", + "extensions": "Extensions", + "extensionGroups": "Groupes d'extensions" + } }, "crossOs": { "viewOnly": "Ce profil a été créé sur {{os}} et n'est pas pris en charge sur ce système", @@ -1788,6 +1788,14 @@ "profileLocked": "Le profil est verrouillé. Entrez d'abord le mot de passe.", "invalidProfileId": "Identifiant de profil non valide", "passwordTooShort": "Le mot de passe doit comporter au moins {{min}} caractères", + "proxyNotFound": "Proxy introuvable", + "groupNotFound": "Groupe introuvable", + "vpnNotFound": "VPN introuvable", + "extensionNotFound": "Extension introuvable", + "extensionGroupNotFound": "Groupe d'extensions introuvable", + "cannotModifyCloudManagedProxy": "Impossible de modifier la synchronisation d'un proxy géré dans le cloud", + "syncLockedByProfile": "La synchronisation ne peut pas être désactivée tant qu'elle est utilisée par des profils synchronisés", + "syncNotConfigured": "La synchronisation n'est pas configurée. Connectez-vous ou configurez un serveur auto-hébergé.", "internal": "Une erreur s'est produite : {{detail}}", "invalidLaunchHookUrl": "URL du hook de lancement invalide. Utilisez une URL http:// ou https:// complète.", "cookieDbLocked": "Impossible de lire les cookies — la base de données est verrouillée. Fermez le navigateur et réessayez.", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 3544149..c7ff08f 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1070,16 +1070,16 @@ "syncAll": { "title": "既存アイテムの同期を有効にする", "description": "同期されていないアイテムがあります。すべての同期を有効にしますか?", - "itemsList": "未同期アイテム: {{items}}", - "proxies": "{{count}}個のプロキシ", - "proxies_plural": "{{count}}個のプロキシ", - "groups": "{{count}}個のグループ", - "groups_plural": "{{count}}個のグループ", - "vpns": "{{count}}個のVPN", - "vpns_plural": "{{count}}個のVPN", "enableAll": "すべて有効にする", "skip": "スキップ", - "success": "すべてのアイテムの同期が有効になりました" + "success": "すべてのアイテムの同期が有効になりました", + "labels": { + "proxies": "プロキシ", + "vpns": "VPN", + "groups": "グループ", + "extensions": "拡張機能", + "extensionGroups": "拡張機能グループ" + } }, "crossOs": { "viewOnly": "このプロファイルは{{os}}で作成されたもので、このシステムではサポートされていません", @@ -1788,6 +1788,14 @@ "profileLocked": "プロファイルはロックされています。先にパスワードを入力してください。", "invalidProfileId": "無効なプロファイルIDです", "passwordTooShort": "パスワードは {{min}} 文字以上必要です", + "proxyNotFound": "プロキシが見つかりません", + "groupNotFound": "グループが見つかりません", + "vpnNotFound": "VPNが見つかりません", + "extensionNotFound": "拡張機能が見つかりません", + "extensionGroupNotFound": "拡張機能グループが見つかりません", + "cannotModifyCloudManagedProxy": "クラウド管理のプロキシの同期は変更できません", + "syncLockedByProfile": "同期済みプロファイルで使用中のため、同期を無効にできません", + "syncNotConfigured": "同期が設定されていません。サインインするか、セルフホストサーバーを設定してください。", "internal": "問題が発生しました: {{detail}}", "invalidLaunchHookUrl": "起動フックURLが無効です。完全な http:// または https:// URL を使用してください。", "cookieDbLocked": "Cookie を読み取れません — データベースがロックされています。ブラウザを閉じてから再試行してください。", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json new file mode 100644 index 0000000..bda1801 --- /dev/null +++ b/src/i18n/locales/ko.json @@ -0,0 +1,1916 @@ +{ + "common": { + "buttons": { + "save": "저장", + "cancel": "취소", + "close": "닫기", + "delete": "삭제", + "create": "생성", + "back": "뒤로", + "retry": "다시 시도", + "download": "다운로드", + "confirm": "확인", + "apply": "적용", + "reset": "초기화", + "add": "추가", + "edit": "편집", + "copy": "복사", + "clear": "지우기", + "search": "검색", + "select": "선택", + "grant": "허용", + "start": "시작", + "stop": "중지", + "enable": "사용", + "disable": "사용 안 함", + "import": "가져오기", + "export": "내보내기", + "refresh": "새로 고침", + "loading": "불러오는 중...", + "saveSettings": "설정 저장", + "moreInfo": "자세히", + "downloading": "다운로드 중...", + "minimize": "최소화", + "saving": "저장 중…", + "saved": "저장됨", + "copied": "복사됨" + }, + "status": { + "active": "활성", + "inactive": "비활성", + "running": "실행 중", + "stopped": "중지됨", + "enabled": "사용", + "disabled": "사용 안 함", + "granted": "허용됨", + "notGranted": "허용되지 않음", + "connected": "연결됨", + "disconnected": "연결 끊김", + "synced": "동기화됨", + "syncing": "동기화 중", + "pending": "대기 중", + "error": "오류" + }, + "labels": { + "name": "이름", + "type": "유형", + "status": "상태", + "actions": "작업", + "description": "설명", + "none": "없음", + "default": "기본값", + "custom": "사용자 지정", + "optional": "선택 사항", + "required": "필수", + "unknownProfile": "알 수 없음", + "mode": "모드", + "never": "안 함" + }, + "time": { + "days": "일", + "hours": "시간", + "minutes": "분", + "seconds": "초", + "remaining": "남음" + }, + "aria": { + "selectAll": "모두 선택", + "selectRow": "행 선택", + "selectProfile": "프로필 선택", + "copy": "클립보드에 복사", + "copied": "복사됨", + "showToken": "토큰 표시", + "hideToken": "토큰 숨기기" + }, + "keys": { + "escape": "Escape" + }, + "errors": { + "unknown": "알 수 없는 오류가 발생했습니다" + }, + "window": { + "minimize": "최소화" + }, + "commandPalette": { + "title": "명령 팔레트", + "description": "실행할 명령을 검색하세요..." + }, + "noResults": "결과를 찾을 수 없습니다.", + "srOnly": { + "copy": "복사", + "copied": "복사됨" + } + }, + "settings": { + "title": "설정", + "appearance": { + "title": "모양", + "theme": "테마", + "themeDescription": "원하는 테마를 선택하거나 시스템 설정을 따르세요. 사용자 지정 테마 변경 사항은 저장 시에만 적용됩니다.", + "themePreset": "테마 프리셋", + "customColors": "사용자 지정 색상", + "selectTheme": "테마 선택", + "selectThemePreset": "테마 프리셋 선택", + "yourOwn": "직접 설정", + "light": "라이트", + "dark": "다크", + "system": "시스템" + }, + "language": { + "title": "언어", + "description": "애플리케이션 인터페이스에 사용할 언어를 선택하세요.", + "systemDefault": "시스템 기본값", + "selectLanguage": "언어 선택", + "interface": "인터페이스 언어" + }, + "defaultBrowser": { + "title": "기본 브라우저", + "setAsDefault": "기본 브라우저로 설정", + "alreadyDefault": "이미 기본 브라우저입니다", + "description": "기본 브라우저로 설정하면 도넛 브라우저가 웹 링크를 처리하고 사용할 프로필을 선택할 수 있습니다." + }, + "permissions": { + "title": "시스템 권한", + "loading": "권한 불러오는 중...", + "description": "이 권한은 도넛 브라우저에서 실행된 브라우저가 시스템 리소스에 액세스할 수 있도록 합니다. 각 웹사이트는 여전히 개별적으로 권한을 요청합니다.", + "microphone": "마이크", + "microphoneDescription": "브라우저 애플리케이션의 마이크 액세스", + "camera": "카메라", + "cameraDescription": "브라우저 애플리케이션의 카메라 액세스", + "accessRequested": "{{permission}} 액세스가 요청되었습니다" + }, + "integrations": { + "title": "통합", + "description": "외부 도구 및 AI 어시스턴트와 통합하기 위해 로컬 API와 MCP(Model Context Protocol)를 구성합니다.", + "openSettings": "통합 설정 열기" + }, + "encryption": { + "title": "동기화 암호화", + "description": "E2E 암호화 동기화를 사용하려면 비밀번호를 설정하세요. 이 비밀번호를 잃어버리면 암호화된 프로필을 복구할 수 없습니다.", + "passwordSet": "활성", + "passwordSetDescription": "E2E 암호화 비밀번호가 설정되어 있습니다", + "noPassword": "비밀번호가 설정되지 않았습니다", + "passwordPlaceholder": "비밀번호 (8자 이상)", + "confirmPlaceholder": "비밀번호 확인", + "setPassword": "비밀번호 설정", + "changePassword": "비밀번호 변경", + "removePassword": "비밀번호 제거", + "removed": "암호화 비밀번호가 제거되었습니다", + "passwordSaved": "암호화 비밀번호가 설정되었습니다", + "passwordMismatch": "비밀번호가 일치하지 않습니다", + "passwordTooShort": "비밀번호는 8자 이상이어야 합니다", + "requiresProOrOwner": "프로필 암호화는 Pro 사용자 및 팀 소유자만 사용할 수 있습니다.", + "validatePassword": "확인", + "validateDialog": { + "title": "암호화 비밀번호 확인", + "description": "이 기기에 저장된 비밀번호와 일치하는지 확인하려면 암호화 비밀번호를 입력하세요.", + "submit": "확인", + "matchToast": "비밀번호가 일치합니다", + "mismatchToast": "비밀번호가 일치하지 않습니다" + } + }, + "commercial": { + "title": "상업용 라이선스", + "trialActive": "체험판: {{days}}일 {{hours}}시간 남음", + "trialActiveDescription": "체험 기간 동안 상업적 사용은 무료입니다. 체험이 종료되면 모든 기능은 계속 작동합니다 — 개인 사용은 무료로 유지되며 상업적 사용만 라이선스가 필요합니다.", + "trialExpired": "체험판이 만료되었습니다", + "trialExpiredDescription": "개인 사용은 무료로 유지됩니다. 상업적 사용에는 라이선스가 필요합니다.", + "subscriptionActive": "구독 중 — {{plan}} 플랜", + "subscriptionActiveDescription": "도넛 브라우저 구독이 활성 상태입니다. 플랜 기간 동안 상업적 사용이 라이선스됩니다." + }, + "advanced": { + "title": "고급", + "clearCache": "모든 버전 캐시 지우기", + "clearCacheDescription": "캐시된 모든 브라우저 버전 데이터를 지우고 모든 브라우저 버전을 소스에서 새로 고칩니다. 모든 브라우저의 버전 정보가 새로 다운로드됩니다.", + "clearCacheFailed": "캐시를 지우지 못했습니다", + "copyLogs": "로그 복사", + "openLogDir": "로그 폴더 열기", + "copyLogsSuccess": "로그가 클립보드에 복사되었습니다", + "copyLogsDescription": "최신 로그 파일(최대 5MB)을 클립보드에 묶어 버그 보고서에서 공유할 수 있도록 합니다." + }, + "disableAutoUpdates": "앱 자동 업데이트 사용 안 함", + "disableAutoUpdatesDescription": "도넛 브라우저 업데이트를 앱이 자동으로 확인하고 설치하지 않도록 합니다. 브라우저 업데이트는 영향을 받지 않습니다.", + "keepDecryptedProfilesInRam": "복호화된 프로필을 RAM에 유지", + "keepDecryptedProfilesInRamDescription": "비밀번호로 보호된 프로필의 복호화된 RAM 사본을 실행 사이에 유지하여 시작 속도를 높입니다. 디스크의 사본은 그대로 암호화된 상태로 유지됩니다." + }, + "header": { + "searchPlaceholder": "프로필 검색...", + "clearSearch": "검색 지우기", + "moreActions": "더 많은 작업", + "createProfile": "새 프로필 생성", + "menu": { + "settings": "설정", + "proxies": "프록시 및 VPN", + "groups": "그룹", + "syncService": "계정", + "integrations": "통합", + "importProfile": "프로필 가져오기", + "extensions": "확장 프로그램" + }, + "newProfile": "새로 만들기", + "donutLogo": "도넛 브라우저 로고", + "scrollGroupsLeft": "그룹 왼쪽으로 스크롤", + "scrollGroupsRight": "그룹 오른쪽으로 스크롤" + }, + "profiles": { + "title": "프로필", + "empty": "아직 프로필이 없습니다", + "emptyDescription": "첫 번째 브라우저 프로필을 생성하여 시작하세요.", + "createFirst": "프로필 생성", + "noResults": "프로필을 찾을 수 없습니다", + "noResultsDescription": "검색 조건과 일치하는 프로필이 없습니다.", + "table": { + "name": "이름", + "browser": "브라우저", + "status": "상태", + "actions": "작업", + "note": "메모", + "group": "그룹", + "proxy": "프록시 / VPN", + "lastLaunch": "마지막 실행", + "empty": "프로필을 찾을 수 없습니다.", + "notSelected": "선택 안 됨", + "ext": "확장", + "dns": "DNS", + "extDefault": "기본값", + "dnsLevel": "DNS 차단 목록: {{level}}", + "extSearch": "그룹 검색…", + "extEmpty": "확장 프로그램 그룹이 없습니다" + }, + "actions": { + "launch": "실행", + "stop": "중지", + "edit": "편집", + "delete": "삭제", + "copyCookies": "쿠키 복사", + "configure": "구성", + "clone": "프로필 복제", + "viewNetwork": "네트워크 보기", + "syncSettings": "동기화 설정", + "assignToGroup": "그룹에 할당", + "changeFingerprint": "핑거프린트 변경", + "copyCookiesToProfile": "프로필로 쿠키 복사", + "launchHook": "실행 후크 URL", + "setPassword": "비밀번호 설정", + "changePassword": "비밀번호 변경", + "removePassword": "비밀번호 제거" + }, + "synchronizer": { + "launchWithSync": "동기화 모드로 실행", + "stopLeader": "이 프로필과 모든 팔로워 중지", + "stopFollower": "{{leaderName}}의 작업을 따르는 중", + "desyncedTooltip": "{{url}}에서 동기화 실패", + "paidFeature": "동기화는 유료 기능입니다", + "wayfernOnly": "Wayfern 프로필만 동기화할 수 있습니다", + "selectFollowers": "팔로워 프로필 선택", + "selectFollowersDesc": "리더 프로필의 작업을 미러링할 프로필을 선택하세요. 중지된 Wayfern 프로필만 선택할 수 있습니다.", + "leader": "리더", + "follower": "팔로워", + "startSession": "동기화 세션 시작", + "noFollowers": "팔로워 프로필을 하나 이상 선택하세요", + "flakyBadge": "불안정", + "flakyTooltip": "이 프로필은 리더와 다른 화면 해상도를 가지고 있습니다. 페이지 레이아웃이 달라져 클릭과 상호작용이 잘못된 요소에 닿을 수 있습니다." + }, + "ephemeral": "임시", + "ephemeralDescription": "브라우저가 프로필 데이터를 디스크 대신 메모리에 강제로 기록합니다. 부하 상태에서 운영 체제가 메모리의 일부를 디스크로 스왑할 수 있으므로 세션의 흔적이 복구될 수 있습니다.", + "ephemeralBadge": "임시", + "bulkDelete": { + "title": "선택한 프로필 삭제", + "description": "이 작업은 취소할 수 없습니다. {{count}}개의 프로필과 관련된 모든 데이터가 영구적으로 삭제됩니다.", + "confirmButton": "{{count}}개 프로필 삭제" + }, + "note": { + "empty": "메모 없음", + "placeholder": "메모 추가..." + }, + "aria": { + "profileInfo": "프로필 정보" + }, + "delete": { + "title": "프로필 삭제", + "description": "이 작업은 취소할 수 없습니다. 프로필 \"{{profileName}}\"과 관련된 모든 데이터가 영구적으로 삭제됩니다.", + "confirmButton": "프로필 삭제" + }, + "actionBar": { + "assignToGroup": "그룹에 할당", + "assignProxy": "프록시 할당", + "assignExtensionGroup": "확장 프로그램 그룹 할당", + "copyCookies": "쿠키 복사" + }, + "passwordProtectedBadge": "비밀번호 보호됨", + "launchHook": { + "placeholder": "https://example.com/track-launch" + } + }, + "createProfile": { + "title": "새 프로필 생성", + "configureTitle": "새 {{browser}} 프로필 생성", + "antiDetect": { + "title": "안티-디텍트 브라우저", + "description": "안티-디텍트 기능을 갖춘 브라우저를 선택하세요", + "chromium": "Wayfern", + "firefox": "Camoufox", + "badge": "안티-디텍트 브라우저" + }, + "regular": { + "title": "일반 브라우저", + "description": "지원되는 일반 브라우저 중에서 선택하세요", + "badge": "일반 브라우저" + }, + "profileName": "프로필 이름", + "profileNamePlaceholder": "프로필 이름 입력", + "proxy": { + "title": "프록시 / VPN", + "addProxy": "프록시 추가", + "noProxy": "프록시 / VPN 없음", + "noProxiesAvailable": "사용할 수 있는 프록시 또는 VPN이 없습니다. 이 프로필의 트래픽을 라우팅하려면 추가하세요.", + "search": "프록시 또는 VPN 검색...", + "notFound": "프록시 또는 VPN을 찾을 수 없습니다.", + "searchWithCountries": "프록시, VPN 또는 국가 검색..." + }, + "launchHook": { + "label": "실행 후크 URL", + "placeholder": "https://example.com/hooks/profile-launch" + }, + "version": { + "fetching": "사용 가능한 버전을 가져오는 중...", + "fetchError": "브라우저 버전을 가져오지 못했습니다. 인터넷 연결을 확인하고 다시 시도하세요.", + "needsDownload": "{{browser}} 버전 ({{version}})을 다운로드해야 합니다", + "available": "{{browser}} 버전 ({{version}})을 사용할 수 있습니다", + "downloading": "{{browser}} 버전 ({{version}})을 다운로드하는 중...", + "latestNeedsDownload": "최신 버전 ({{version}})을 다운로드해야 합니다", + "latestAvailable": "최신 버전 ({{version}})을 사용할 수 있습니다", + "latestDownloading": "버전 ({{version}})을 다운로드하는 중..." + }, + "chromiumLabel": "크로미움", + "chromiumSubtitle": "Wayfern 기반", + "firefoxLabel": "파이어폭스", + "firefoxSubtitle": "Camoufox 기반", + "camoufoxWarning": "파이어폭스(Camoufox)는 제3자 조직에서 유지 관리합니다. 프로덕션 용도로는 크로미움을 사용하세요.", + "platformUnavailable": "{{browser}}는 아직 이 플랫폼에서 사용할 수 없습니다.", + "passwordProtect": { + "label": "이 프로필을 비밀번호로 보호", + "description": "디스크에 저장된 프로필 데이터를 암호화합니다. 실행하려면 필요합니다." + } + }, + "deleteDialog": { + "title": "프로필 삭제", + "description": "이 프로필을 정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.", + "profilesTitle": "프로필 삭제", + "profilesDescription": "선택한 프로필을 정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.", + "profilesToDelete": "삭제될 프로필:" + }, + "proxies": { + "title": "프록시", + "management": { + "description": "프로필 간에 재사용할 프록시 및 VPN 구성을 관리합니다", + "tabProxies": "프록시", + "tabVpns": "VPN", + "create": "생성", + "loading": "프록시 불러오는 중...", + "noneCreated": "아직 생성된 프록시가 없습니다. 위의 버튼을 사용하여 첫 번째 프록시를 생성하세요.", + "usage": "사용량", + "syncCol": "동기화", + "syncCannotDisable": "이 프록시가 동기화된 프로필에서 사용 중이므로 동기화를 비활성화할 수 없습니다", + "enableSync": "동기화 사용", + "disableSync": "동기화 사용 안 함", + "editProxy": "프록시 편집", + "deleteProxy": "프록시 삭제", + "cannotDelete_one": "삭제할 수 없습니다: {{count}}개 프로필에서 사용 중", + "cannotDelete_other": "삭제할 수 없습니다: {{count}}개 프로필에서 사용 중", + "syncEnabled": "동기화 사용됨", + "syncDisabled": "동기화 사용 안 함", + "updateSyncFailed": "동기화 업데이트 실패", + "deleteSuccess": "프록시가 삭제되었습니다", + "deleteFailed": "프록시 삭제 실패", + "deleteTitle": "프록시 삭제", + "deleteDescription": "이 작업은 취소할 수 없습니다. 프록시 \"{{name}}\"이(가) 영구적으로 삭제됩니다.", + "newProxy": "새 프록시", + "newVpn": "새 VPN", + "protocolCol": "프로토콜", + "title": "프록시 및 VPN" + }, + "add": "프록시 추가", + "edit": "프록시 편집", + "delete": "프록시 삭제", + "import": "가져오기", + "export": "내보내기", + "noProxies": "구성된 프록시가 없습니다", + "noProxiesDescription": "브라우저 트래픽을 라우팅할 프록시를 추가하세요.", + "form": { + "name": "이름", + "namePlaceholder": "프록시 이름 입력", + "type": "유형", + "host": "호스트", + "hostPlaceholder": "proxy.example.com", + "port": "포트", + "portPlaceholder": "8080", + "username": "사용자 이름", + "usernamePlaceholder": "선택 사항", + "password": "비밀번호", + "passwordPlaceholder": "선택 사항", + "cipher": "암호화", + "cipherPlaceholder": "aes-256-gcm", + "nameRequired": "프록시 이름은 필수입니다", + "hostPortRequired": "호스트와 포트는 필수입니다", + "ssCipherRequired": "Shadowsocks에는 암호화와 비밀번호가 필요합니다", + "selectType": "프록시 유형 선택", + "saveFailed": "프록시 저장 실패: {{error}}" + }, + "types": { + "http": "HTTP", + "https": "HTTPS", + "socks4": "SOCKS4", + "socks5": "SOCKS5", + "ss": "Shadowsocks" + }, + "tabs": { + "regular": "일반", + "dynamic": "동적" + }, + "dynamic": { + "description": "동적 프록시는 프로필이 실행될 때마다 URL에서 연결 세부 정보를 가져옵니다.", + "url": "프록시 URL", + "urlPlaceholder": "https://api.example.com/proxy", + "urlRequired": "동적 프록시 URL은 필수입니다", + "format": "응답 형식", + "formatJson": "JSON", + "formatText": "텍스트", + "formatJsonHint": "JSON 형식 기대: {\"ip\": \"...\", \"port\": ..., \"username\": \"...\", \"password\": \"...\"}", + "formatTextHint": "텍스트 형식 기대: host:port:username:password 또는 protocol://user:pass@host:port", + "testUrl": "URL 테스트", + "testing": "테스트 중...", + "testSuccess": "프록시 작동: {{host}}:{{port}}", + "testFailed": "프록시 테스트 실패: {{error}}", + "fetchFailed": "동적 프록시 가져오기 실패: {{error}}" + }, + "check": { + "checking": "프록시 확인 중...", + "valid": "프록시가 유효합니다", + "invalid": "프록시가 유효하지 않습니다", + "lastChecked": "마지막 확인: {{time}}" + }, + "sync": { + "enabled": "동기화 사용됨", + "disabled": "동기화 사용 안 함" + }, + "exportDialog": { + "title": "프록시 내보내기", + "description": "프록시 구성을 파일로 내보냅니다", + "format": "내보내기 형식", + "json": "JSON", + "txt": "TXT (URL 형식)", + "preview": "미리보기", + "noProxies": "내보낼 프록시가 없습니다", + "downloaded": "{{filename}} 다운로드됨", + "failed": "프록시 내보내기 실패", + "copied": "복사됨" + }, + "importDialog": { + "title": "프록시 가져오기", + "descDropzone": "JSON 또는 TXT 파일에서 프록시 가져오기", + "descPreview": "가져올 프록시 검토", + "descAmbiguous": "일부 프록시는 형식이 모호합니다. 올바른 형식을 선택하세요.", + "descResult": "가져오기가 완료되었습니다", + "dropzonePrompt": "프록시 구성 파일을 끌어다 놓으세요", + "dropzoneFormats": "(.json, .txt)", + "pasteHint": "{{modKey}}+V로 클립보드에서 붙여넣기", + "wrongFileType": ".json 또는 .txt 파일을 끌어다 놓으세요", + "fileReadError": "파일 읽기 실패", + "fileProcessError": "파일 처리 실패", + "noValidProxies": "파일에서 유효한 프록시를 찾을 수 없습니다", + "namePrefix": "이름 접두사", + "namePrefixDefault": "가져옴", + "namePrefixHint": "프록시 이름은 \"{{prefix}} Proxy 1\", \"{{prefix}} Proxy 2\" 등으로 지정됩니다.", + "proxiesToImport": "가져올 프록시 ({{count}})", + "invalidCount": "({{count}}개 유효하지 않음)", + "ambiguousIntro": "다음 프록시는 형식이 모호합니다. 각각에 대해 올바른 해석을 선택하세요.", + "imported": "가져옴:", + "skippedDuplicates": "건너뜀(중복):", + "errors": "오류", + "importButton": "{{count}}개 프록시 가져오기", + "continueButton": "계속", + "doneButton": "완료", + "failed": "프록시 가져오기 실패" + }, + "bulkDelete": { + "proxiesTitle": "선택한 프록시 삭제", + "proxiesDescription": "이 작업은 취소할 수 없습니다. {{count}}개의 프록시가 영구적으로 삭제됩니다: {{names}}.", + "vpnsTitle": "선택한 VPN 삭제", + "vpnsDescription": "이 작업은 취소할 수 없습니다. {{count}}개의 VPN이 영구적으로 삭제됩니다: {{names}}.", + "confirmButton": "{{count}}개 삭제" + } + }, + "groups": { + "title": "그룹", + "management": "그룹 관리", + "add": "그룹 추가", + "edit": "그룹 편집", + "delete": "그룹 삭제", + "moveToDefault": "그룹에서 프로필 제거", + "noGroupDescription": "그룹이 없는 프로필은 \"모두\" 필터에 표시됩니다.", + "assignSuccess": "{{count}}개 프로필을 {{group}}에 할당했습니다", + "noGroups": "생성된 그룹이 없습니다", + "noGroupsDescription": "프로필을 정리할 그룹을 생성하세요.", + "form": { + "name": "이름", + "namePlaceholder": "그룹 이름 입력" + }, + "profileCount": "{{count}}개 프로필", + "profileCount_plural": "{{count}}개 프로필", + "assignProfiles": "프로필 할당", + "sync": { + "enabled": "동기화 사용됨", + "disabled": "동기화 사용 안 함" + }, + "createTitle": "새 그룹 생성", + "createDescription": "브라우저 프로필을 정리할 새 그룹을 생성합니다.", + "editTitle": "그룹 편집", + "editDescription": "그룹 이름을 업데이트합니다.", + "createSuccess": "그룹이 생성되었습니다", + "createFailed": "그룹 생성 실패", + "updateSuccess": "그룹이 업데이트되었습니다", + "updateFailed": "그룹 업데이트 실패", + "deleteTitle": "그룹 삭제", + "deleteDescription": "이 작업은 취소할 수 없습니다. 그룹이 영구적으로 삭제됩니다.", + "deleteSuccess": "그룹이 삭제되었습니다", + "deleteFailed": "그룹 삭제 실패", + "loadingProfiles": "연결된 프로필 불러오는 중...", + "associatedProfiles": "연결된 프로필 ({{count}})", + "whatToDoWithProfiles": "이 프로필을 어떻게 처리할까요?", + "deleteAlongWithGroup": "그룹과 함께 프로필 삭제", + "noAssociatedProfiles": "이 그룹에는 연결된 프로필이 없습니다.", + "deleteGroup": "그룹 삭제", + "deleteGroupAndProfiles": "그룹 및 프로필 삭제", + "loadProfilesFailed": "프로필 불러오기 실패", + "unknownGroup": "알 수 없는 그룹", + "profileGroupsAriaLabel": "프로필 그룹", + "loading": "그룹 불러오는 중...", + "all": "모두", + "noGroup": "그룹 없음", + "pageTitle": "프로필 그룹", + "pageDescription": "프로필 그룹을 사용하면 클라이언트, 환경 또는 사용 사례별로 브라우저를 정리할 수 있습니다. 기기 간 그룹을 동기화하여 공유하세요." + }, + "sync": { + "mode": { + "title": "프로필 동기화", + "description": "\"{{name}}\"의 동기화 설정 관리", + "disabled": "사용 안 함", + "regular": "일반 동기화", + "encrypted": "E2E 암호화 동기화", + "disabledDescription": "이 프로필 동기화 없음", + "regularDescription": "빠른 동기화, 암호화되지 않음", + "encryptedDescription": "업로드 전에 암호화됩니다. 서버는 평문 데이터를 보지 않습니다.", + "noPasswordWarning": "E2E 비밀번호가 설정되지 않았습니다. 설정에서 비밀번호를 설정하세요.", + "passwordRequired": "E2E 비밀번호가 설정되지 않았습니다. 먼저 설정에서 비밀번호를 설정하세요.", + "enabledToast": "동기화 사용", + "disabledToast": "동기화 사용 안 함", + "syncQueued": "동기화 대기 중", + "syncNow": "지금 동기화", + "lastSynced": "마지막 동기화", + "notConfigured": "동기화 서비스가 구성되지 않았습니다.", + "configureService": "동기화 서비스 구성" + }, + "title": "계정", + "config": { + "serverUrlRequired": "서버 URL을 입력하세요", + "connectionSuccess": "연결 성공!", + "serverError": "서버가 오류로 응답했습니다", + "connectFailed": "서버에 연결하지 못했습니다", + "settingsSaved": "동기화 설정이 저장되었습니다", + "saveFailed": "설정 저장 실패", + "disconnected": "동기화 연결 끊김", + "disconnectFailed": "연결 해제 실패" + }, + "serverUrl": "서버 URL", + "serverUrlPlaceholder": "https://sync.example.com", + "token": "동기화 토큰", + "tokenPlaceholder": "동기화 토큰 입력", + "status": { + "connected": "연결됨", + "disconnected": "연결 끊김", + "syncing": "동기화 중...", + "error": "동기화 오류" + }, + "description": "기기 간 프로필, 프록시 및 그룹을 동기화하려면 동기화 서버에 연결하세요.", + "cloud": { + "tabLabel": "클라우드", + "selfHostedTabLabel": "자체 호스팅", + "email": "이메일", + "deviceLinkInstructions": "\"로그인\"을 클릭하여 브라우저에서 로그인 페이지를 엽니다. 로그인 후 표시된 코드를 복사하여 아래에 붙여넣으세요.", + "openLogin": "로그인", + "linkCodeLabel": "로그인 코드", + "linkCodePlaceholder": "웹사이트에서 받은 코드를 붙여넣으세요", + "signInTitle": "로그인", + "verifyAndLogin": "확인 및 로그인", + "loggingIn": "로그인 중...", + "connected": "연결됨", + "plan": "플랜", + "profiles": "프로필", + "profileUsage": "{{used}} / {{limit}}", + "manageAccount": "계정 관리", + "logout": "로그아웃", + "logoutConfirm": "정말 로그아웃하시겠습니까? 클라우드 동기화가 중지됩니다.", + "loginSuccess": "로그인 성공!", + "logoutSuccess": "로그아웃되었습니다." + }, + "team": { + "title": "팀", + "name": "팀 이름", + "role": "역할", + "roleOwner": "소유자", + "roleAdmin": "관리자", + "roleMember": "구성원", + "manageOnWeb": "웹 대시보드에서 팀 관리", + "profileLocked": "{{email}}이(가) 사용 중", + "profileLockedShort": "사용 중", + "cannotLaunchLocked": "실행할 수 없습니다 — 프로필을 {{email}}이(가) 사용 중", + "createdBy": "{{email}}이(가) 생성" + }, + "disabled": "사용 안 함", + "toast": { + "profileSynced": "프로필 '{{name}}'이(가) 동기화되었습니다", + "profileSyncFailed": "프로필 '{{name}}' 동기화 실패", + "profileSyncFailedWithError": "프로필 '{{name}}' 동기화 실패: {{error}}" + } + }, + "integrations": { + "title": "통합", + "api": { + "title": "로컬 API", + "description": "외부 통합을 위한 로컬 API 서버를 활성화합니다.", + "enabled": "API 사용", + "disabled": "API 사용 안 함", + "port": "포트", + "token": "API 토큰", + "copyToken": "토큰 복사", + "regenerateToken": "토큰 재생성" + }, + "mcp": { + "title": "MCP 서버", + "description": "AI 어시스턴트 통합을 위한 MCP(Model Context Protocol) 서버를 활성화합니다.", + "enabled": "MCP 사용", + "disabled": "MCP 사용 안 함", + "port": "포트", + "token": "인증 토큰", + "tokenCopied": "토큰 복사됨", + "url": "MCP 서버 URL", + "urlCopied": "URL 복사됨", + "config": "MCP 구성", + "copyConfig": "구성 복사", + "clientsLabel": "클라이언트", + "connected": "연결됨", + "add": "추가", + "addedToClient": "{{name}}에 추가됨", + "removedFromClient": "{{name}}에서 제거됨", + "removeAriaLabel": "{{name}}에서 제거", + "category": { + "desktopApp": "데스크탑 앱", + "cli": "CLI", + "editor": "에디터", + "editorExt": "에디터 확장" + } + }, + "tabApi": "로컬 API", + "tabMcp": "MCP (AI 어시스턴트)", + "apiEnableLabel": "로컬 API 서버 활성화", + "apiEnableDescription": "REST API로 프로필, 그룹 및 프록시를 관리할 수 있도록 허용합니다.", + "apiPortLabel": "포트", + "apiTokenLabel": "인증 토큰", + "apiTokenHint": "Authorization 헤더에 포함: Bearer {{tokenSlot}}", + "apiInvalidPort": "잘못된 포트", + "apiInvalidPortDescription": "포트는 1에서 65535 사이여야 합니다", + "apiPortInUse": "포트 {{port}}이(가) 이미 사용 중입니다", + "apiFallbackPort": "서버가 대체 포트 {{port}}에서 시작되었습니다", + "apiStarted": "API 서버가 포트 {{port}}에서 시작되었습니다", + "apiRunning": "API 서버가 포트 {{port}}에서 실행 중입니다", + "apiStopped": "API 서버가 중지되었습니다", + "apiToggleFailed": "API 서버 토글 실패", + "apiStartFailed": "API 서버 시작 실패", + "apiUnknownError": "알 수 없는 오류", + "tokenCopied": "토큰 복사됨", + "mcpEnableLabel": "MCP 서버 활성화 (Model Context Protocol)", + "mcpEnableDescription": "Claude Desktop과 같은 AI 어시스턴트가 브라우저를 제어할 수 있도록 허용합니다.", + "mcpAcceptTermsFirst": "(먼저 설정에서 Wayfern 약관에 동의하세요)", + "mcpStarted": "MCP 서버가 포트 {{port}}에서 시작되었습니다", + "mcpStopped": "MCP 서버가 중지되었습니다", + "mcpToggleFailed": "MCP 서버 토글 실패", + "openSettings": "통합 설정 열기", + "apiRunningOn": "실행 중", + "apiExampleRequest": "예시 요청" + }, + "import": { + "title": "프로필 가져오기", + "description": "시스템에서 기존 브라우저 프로필을 가져옵니다.", + "selectProfile": "가져올 프로필 선택", + "noProfiles": "감지된 프로필 없음", + "noProfilesDescription": "시스템에서 브라우저 프로필이 감지되지 않았습니다.", + "importing": "프로필 가져오는 중...", + "success": "프로필을 가져왔습니다", + "error": "프로필 가져오기 실패" + }, + "config": { + "camoufox": { + "title": "Camoufox 구성", + "fingerprint": { + "title": "핑거프린트", + "randomize": "실행 시 무작위화", + "randomizeDescription": "브라우저가 실행될 때마다 새 핑거프린트를 생성합니다.", + "osCpuPlaceholder": "예: Intel Mac OS X 10.15", + "webglRendererPlaceholder": "예: llvmpipe 또는 유사" + }, + "os": { + "title": "운영 체제", + "description": "핑거프린트 생성을 위해 에뮬레이트할 운영 체제입니다.", + "windows": "Windows", + "macos": "macOS", + "linux": "Linux" + }, + "screen": { + "title": "화면 크기", + "minWidth": "최소 너비", + "maxWidth": "최대 너비", + "minHeight": "최소 높이", + "maxHeight": "최대 높이" + }, + "geoip": { + "title": "GeoIP", + "auto": "자동 (프록시 기반)", + "manual": "수동", + "disabled": "사용 안 함" + }, + "blocking": { + "title": "차단", + "images": "이미지 차단", + "webrtc": "WebRTC 차단", + "webgl": "WebGL 차단" + } + }, + "wayfern": { + "title": "Wayfern 구성", + "fingerprint": { + "title": "핑거프린트", + "randomize": "실행 시 무작위화", + "randomizeDescription": "브라우저가 실행될 때마다 새 핑거프린트를 생성합니다.", + "platformPlaceholder": "예: Win32, MacIntel, Linux x86_64", + "timezoneOffsetPlaceholder": "예: EST의 경우 300 (UTC-5)", + "webglRendererPlaceholder": "예: Intel(R) HD Graphics" + }, + "os": { + "title": "운영 체제", + "description": "핑거프린트 생성을 위해 에뮬레이트할 운영 체제입니다.", + "windows": "Windows", + "macos": "macOS", + "linux": "Linux", + "android": "Android", + "ios": "iOS" + }, + "screen": { + "title": "화면 크기", + "minWidth": "최소 너비", + "maxWidth": "최대 너비", + "minHeight": "최소 높이", + "maxHeight": "최대 높이" + }, + "blocking": { + "title": "차단", + "webrtc": "WebRTC 차단", + "webgl": "WebGL 차단" + } + }, + "shared": { + "browserBehavior": "브라우저 동작", + "allowAddonsOpenTabs": "브라우저 확장 프로그램이 새 탭을 자동으로 열도록 허용" + } + }, + "cookies": { + "title": "쿠키", + "copy": { + "title": "쿠키 복사", + "description": "다른 프로필로 복사할 쿠키를 선택하세요.", + "selectSource": "원본 프로필 선택", + "selectTarget": "대상 프로필 선택", + "selectCookies": "쿠키 선택", + "allDomains": "모든 도메인", + "selectedCount": "{{count}}개 쿠키 선택됨", + "selectedCount_plural": "{{count}}개 쿠키 선택됨", + "dialogDescription_one": "원본 프로필의 쿠키를 선택한 {{count}}개 프로필로 복사합니다.", + "dialogDescription_other": "원본 프로필의 쿠키를 선택한 {{count}}개 프로필로 복사합니다.", + "sourceProfile": "원본 프로필", + "sourcePlaceholder": "쿠키를 복사할 원본 프로필 선택", + "running": "(실행 중)", + "targetProfiles": "대상 프로필 ({{count}})", + "noOtherTargets": "선택된 다른 Wayfern/Camoufox 프로필이 없습니다", + "selectSourceFirst": "먼저 원본 프로필을 선택하세요", + "selectionStatus": "({{total}}개 중 {{selected}}개 선택됨)", + "searchPlaceholder": "도메인 또는 쿠키 검색...", + "noMatching": "일치하는 쿠키가 없습니다", + "noFound": "쿠키를 찾을 수 없습니다", + "replaceNote": "이름과 도메인이 같은 기존 쿠키는 교체됩니다. 다른 쿠키는 유지됩니다.", + "cannotCopyRunningOne": "쿠키를 복사할 수 없습니다: {{names}}이(가) 아직 실행 중입니다", + "cannotCopyRunningMany": "쿠키를 복사할 수 없습니다: {{names}}이(가) 아직 실행 중입니다", + "someErrors": "일부 오류가 발생했습니다: {{errors}}", + "successMessage": "{{copied}}개 쿠키를 복사했습니다 ({{replaced}}개 교체됨)", + "failedMessage": "쿠키 복사 실패: {{error}}", + "copyButton_one": "{{count}}개 쿠키 복사", + "copyButton_other": "{{count}}개 쿠키 복사", + "copyButtonEmpty": "쿠키 복사" + }, + "success": "쿠키가 복사되었습니다", + "error": "쿠키 복사 실패", + "management": { + "title": "쿠키 관리", + "menuItem": "쿠키 관리", + "tabImport": "가져오기", + "tabExport": "내보내기", + "importDescription": "Netscape 또는 JSON 형식 파일에서 쿠키를 가져옵니다.", + "dropPrompt": "쿠키 파일을 선택하려면 클릭하세요", + "fileFormats": "(.txt, .cookies 또는 .json)", + "cookiesFound": "{{count}}개 쿠키 발견", + "importedSuccess": "{{imported}}개 쿠키를 가져왔습니다 ({{replaced}}개 교체됨)", + "linesSkipped": "{{count}}개 줄 건너뜀", + "fileReadError": "파일 읽기 실패", + "loadFailed": "쿠키 불러오기 실패: {{error}}", + "cookiesLabel": "쿠키", + "selectionStatus": "({{total}}개 중 {{selected}}개 선택됨)", + "selectAll": "모두 선택", + "deselectAll": "모두 선택 해제", + "noCookies": "이 프로필에 쿠키가 없습니다", + "doneButton": "완료", + "importButton": "가져오기", + "exportButton": "내보내기", + "backButton": "뒤로" + }, + "import": { + "title": "쿠키 가져오기", + "description": "Netscape 또는 JSON 형식 파일에서 쿠키를 가져옵니다.", + "selectFile": "파일 선택", + "preview": "{{count}}개 쿠키 발견", + "success": "{{imported}}개 쿠키를 가져왔습니다 ({{replaced}}개 교체됨)", + "error": "쿠키 가져오기 실패", + "proFeature": "쿠키 가져오기는 Pro 기능입니다" + }, + "export": { + "title": "쿠키 내보내기", + "description": "이 프로필의 쿠키를 내보냅니다.", + "formatLabel": "형식", + "netscape": "Netscape TXT", + "json": "JSON", + "success": "쿠키를 내보냈습니다", + "error": "쿠키 내보내기 실패" + } + }, + "toasts": { + "success": { + "profileCreated": "프로필이 생성되었습니다", + "profileDeleted": "프로필이 삭제되었습니다", + "profileUpdated": "프로필이 업데이트되었습니다", + "profileLaunched": "프로필이 실행되었습니다", + "proxyCreated": "프록시가 생성되었습니다", + "proxyDeleted": "프록시가 삭제되었습니다", + "proxyUpdated": "프록시가 업데이트되었습니다", + "groupCreated": "그룹이 생성되었습니다", + "groupDeleted": "그룹이 삭제되었습니다", + "groupUpdated": "그룹이 업데이트되었습니다", + "settingsSaved": "설정이 저장되었습니다", + "copied": "클립보드에 복사되었습니다", + "permissionRequested": "{{permission}} 액세스가 요청되었습니다", + "downloadComplete": "{{browser}} {{version}} 다운로드가 완료되었습니다!", + "importSuccess": "{{count}}개 항목을 가져왔습니다", + "exportSuccess": "{{count}}개 항목을 내보냈습니다", + "syncSuccess": "동기화가 완료되었습니다", + "profileSynced": "프로필 '{{name}}'이(가) 동기화되었습니다", + "cacheCleared": "캐시가 지워졌습니다" + }, + "error": { + "profileCreateFailed": "프로필 생성 실패", + "profileDeleteFailed": "프로필 삭제 실패", + "profileUpdateFailed": "프로필 업데이트 실패", + "profileLaunchFailed": "프로필 실행 실패", + "proxyCreateFailed": "프록시 생성 실패", + "proxyDeleteFailed": "프록시 삭제 실패", + "proxyUpdateFailed": "프록시 업데이트 실패", + "groupCreateFailed": "그룹 생성 실패", + "groupDeleteFailed": "그룹 삭제 실패", + "groupUpdateFailed": "그룹 업데이트 실패", + "settingsSaveFailed": "설정 저장 실패", + "copyFailed": "클립보드에 복사 실패", + "downloadFailed": "{{browser}} 다운로드 실패", + "importFailed": "가져오기 실패", + "exportFailed": "내보내기 실패", + "syncFailed": "동기화 실패", + "profileSyncFailed": "프로필 '{{name}}' 동기화 실패", + "cacheClearFailed": "캐시 지우기 실패", + "unknown": "알 수 없는 오류가 발생했습니다" + }, + "loading": { + "downloading": "{{browser}} {{version}} 다운로드 중", + "extracting": "{{browser}} {{version}} 압축 해제 중", + "verifying": "{{browser}} {{version}} 확인 중", + "syncing": "동기화 중...", + "syncingProfile": "프로필 '{{name}}' 동기화 중...", + "syncingProfileWithProgress": "{{count}}개 파일 ({{size}})", + "updatingVersions": "브라우저 버전 업데이트 중..." + } + }, + "errors": { + "required": "이 필드는 필수입니다", + "invalidUrl": "유효한 URL을 입력하세요", + "invalidPort": "유효한 포트 번호(1-65535)를 입력하세요", + "invalidEmail": "유효한 이메일 주소를 입력하세요", + "minLength": "최소 {{min}}자 이상이어야 합니다", + "maxLength": "최대 {{max}}자 이하여야 합니다", + "networkError": "네트워크 오류. 연결을 확인하세요.", + "serverError": "서버 오류. 나중에 다시 시도하세요.", + "unknownError": "알 수 없는 오류가 발생했습니다. 다시 시도하세요.", + "noProfilesForUrl": "사용할 수 있는 프로필이 없습니다. URL을 열기 전에 먼저 프로필을 생성하세요.", + "updateCamoufoxConfigFailed": "Camoufox 구성 업데이트 실패: {{error}}", + "updateWayfernConfigFailed": "Wayfern 구성 업데이트 실패: {{error}}", + "createProfileFailed": "프로필 생성 실패: {{error}}", + "launchBrowserFailed": "브라우저 실행 실패: {{error}}", + "cannotDeleteRunningProfile": "브라우저가 실행 중인 동안 프로필을 삭제할 수 없습니다. 먼저 브라우저를 중지하세요.", + "deleteProfileFailed": "프로필 삭제 실패: {{error}}", + "renameProfileFailed": "프로필 이름 변경 실패: {{error}}", + "killBrowserFailed": "브라우저 종료 실패: {{error}}", + "deleteSelectedProfilesFailed": "선택한 프로필 삭제 실패: {{error}}", + "cookieCopyUnsupportedBrowser": "쿠키 복사는 Wayfern 및 Camoufox 프로필에서만 작동합니다", + "updateSyncSettingsFailed": "동기화 설정 업데이트 실패", + "cloneProfileFailed": "프로필 복제 실패: {{error}}", + "loadSupportedBrowsersFailed": "지원되는 브라우저 불러오기 실패", + "setupExtensionListenersFailed": "확장 프로그램 이벤트 리스너 설정 실패: {{error}}", + "loadGroupsFailed": "그룹 불러오기 실패: {{error}}", + "setupGroupListenersFailed": "그룹 이벤트 리스너 설정 실패: {{error}}", + "loadProfilesFailed": "프로필 불러오기 실패: {{error}}", + "setupProfileListenersFailed": "프로필 이벤트 리스너 설정 실패: {{error}}", + "loadProxiesFailed": "프록시 불러오기 실패: {{error}}", + "setupProxyListenersFailed": "프록시 이벤트 리스너 설정 실패: {{error}}", + "loadVpnConfigsFailed": "VPN 구성 불러오기 실패: {{error}}", + "setupVpnListenersFailed": "VPN 이벤트 리스너 설정 실패: {{error}}", + "themeNotFound": "Tokyo Night 테마를 찾을 수 없습니다", + "setProfilePasswordFailed": "프로필 비밀번호 설정 실패: {{error}}" + }, + "browser": { + "camoufox": "Camoufox", + "wayfern": "Wayfern" + }, + "fingerprint": { + "crossOsWarning": "다른 운영 체제의 핑거프린트를 스푸핑하는 것은 모든 기본 구성 요소를 완벽하게 모방할 수 없기 때문에 신뢰성이 떨어집니다. 주의해서 사용하세요.", + "crossOsLimitations": "교차 OS 핑거프린팅에는 제한이 있습니다. 시스템 수준 API는 여전히 실제 운영 체제를 반영할 수 있으며 일부 기능은 성능이 저하될 수 있습니다.", + "osLabel": "운영 체제 핑거프린트", + "selectOSPlaceholder": "운영 체제 선택", + "generateRandomOnLaunch": "실행할 때마다 무작위 핑거프린트 생성", + "generateRandomDescription": "활성화하면 브라우저가 실행될 때마다 새 핑거프린트가 생성됩니다.", + "generateRandomDescriptionAuto": "활성화하면 브라우저가 실행될 때마다 새 핑거프린트가 생성됩니다. 생성된 핑거프린트는 참조용으로 저장됩니다.", + "autoLocationDescription": "프록시 구성을 기반으로 위치 정보를 자동으로 구성하거나 프록시가 제공되지 않은 경우 연결을 기반으로 합니다", + "editingDisabledRunning": "프로필이 현재 실행 중이므로 핑거프린트 편집을 사용할 수 없습니다. 변경하려면 프로필을 중지하세요.", + "editingDisabledRandomized": "무작위 핑거프린트 생성이 활성화되어 있으므로 핑거프린트 편집을 사용할 수 없습니다. 위의 옵션을 비활성화하여 핑거프린트 구성을 수동으로 편집하세요.", + "advancedWarning": "경고: 이 매개변수가 무엇인지 알고 있는 경우에만 편집하세요. 잘못된 값은 웹사이트를 중단시키고, 사용자를 감지하며, 디버그하기 어려운 버그를 일으킬 수 있습니다.", + "basicWarning": "경고: 이 매개변수가 무엇인지 알고 있는 경우에만 편집하세요.", + "automatic": "자동", + "manual": "수동", + "blockingOptions": "차단 옵션", + "blockImages": "이미지 차단", + "blockWebRTC": "WebRTC 차단", + "blockWebGL": "WebGL 차단", + "navigatorProperties": "네비게이터 속성", + "userAgent": "사용자 에이전트", + "userAgentAndPlatform": "사용자 에이전트 및 플랫폼", + "platform": "플랫폼", + "platformVersion": "플랫폼 버전", + "appVersion": "앱 버전", + "osCpu": "OS CPU", + "hardwareConcurrency": "하드웨어 동시성", + "maxTouchPoints": "최대 터치 포인트", + "doNotTrack": "추적 안 함", + "selectDntPlaceholder": "DNT 값 선택", + "dntAllowed": "0 (추적 허용)", + "dntNotAllowed": "1 (추적 허용 안 함)", + "dntUnspecified": "지정되지 않음", + "language": "언어", + "primaryLanguage": "기본 언어 (navigator.language)", + "languages": "언어 (JSON 배열)", + "languageAndLocale": "언어 및 로케일", + "screenProperties": "화면 속성", + "screenWidth": "화면 너비", + "screenHeight": "화면 높이", + "availableWidth": "사용 가능한 너비", + "availableHeight": "사용 가능한 높이", + "colorDepth": "색상 깊이", + "pixelDepth": "픽셀 깊이", + "devicePixelRatio": "기기 픽셀 비율", + "windowProperties": "창 속성", + "outerWidth": "외부 너비", + "outerHeight": "외부 높이", + "innerWidth": "내부 너비", + "innerHeight": "내부 높이", + "screenX": "화면 X", + "screenY": "화면 Y", + "geolocation": "지리적 위치", + "timezoneAndGeolocation": "시간대 및 지리적 위치", + "timezoneGeolocationDescription": "이 값은 브라우저의 시간대 및 지리적 위치 API를 재정의합니다.", + "latitude": "위도", + "longitude": "경도", + "timezone": "시간대", + "timezoneIana": "시간대 (IANA)", + "timezoneOffset": "오프셋 (UTC에서 분)", + "accuracy": "정확도 (미터)", + "locale": "로케일", + "region": "지역", + "script": "스크립트", + "webglProperties": "WebGL 속성", + "webglVendor": "WebGL 공급업체", + "webglRenderer": "WebGL 렌더러", + "webglParameters": "WebGL 매개변수", + "webglParametersJson": "WebGL 매개변수 (JSON)", + "webgl2Parameters": "WebGL2 매개변수", + "webglShaderPrecisionFormats": "WebGL 셰이더 정밀도 형식", + "webgl2ShaderPrecisionFormats": "WebGL2 셰이더 정밀도 형식", + "canvasFingerprint": "캔버스 핑거프린트", + "canvasNoiseSeed": "캔버스 노이즈 시드", + "canvasNoiseSeedDescription": "이 시드는 일관되지만 고유한 캔버스 핑거프린트를 생성하는 데 사용됩니다. 각 프로필은 서로 다른 시드를 가져야 합니다.", + "fonts": "글꼴", + "fontsJson": "글꼴 (JSON 배열)", + "battery": "배터리", + "charging": "충전 중", + "chargingTime": "충전 시간", + "dischargingTime": "방전 시간", + "batteryLevel": "수준 (0-1)", + "screenResolution": "화면 해상도", + "maxWidth": "최대 너비", + "maxHeight": "최대 높이", + "minWidth": "최소 너비", + "minHeight": "최소 높이", + "hardwareProperties": "하드웨어 속성", + "deviceMemory": "기기 메모리 (GB)", + "audioProperties": "오디오 속성", + "sampleRate": "샘플 속도", + "maxChannelCount": "최대 채널 수", + "vendorInfo": "공급업체 정보", + "vendor": "공급업체", + "vendorSub": "공급업체 보조", + "productSub": "제품 보조", + "brand": "브랜드", + "brandVersion": "브랜드 버전", + "proFeature": "이것은 Pro 기능입니다", + "generateFingerprint": "핑거프린트 생성", + "refreshFingerprint": "핑거프린트 새로 고침", + "canvasNoiseSeedPlaceholder": "캔버스 핑거프린트의 시드 문자열 입력", + "addFontsPlaceholder": "글꼴 추가...", + "enterAsJson": "{{title}}을(를) JSON으로 입력" + }, + "warnings": { + "windowResizeTitle": "사용자 지정 창 크기", + "windowResizeDescription": "브라우저 창 크기를 변경하면 브라우저 정보가 스푸핑된 것으로 웹사이트에서 감지될 가능성이 높아질 수 있습니다.", + "windowResizeCamoufoxTitle": "Camoufox에서 잠긴 뷰포트", + "windowResizeCamoufoxDescription": "Camoufox는 안티-핑거프린팅을 위해 뷰포트를 스푸핑된 화면 크기로 잠급니다. 창 크기를 조정하면 잘리거나 회색 영역이 발생할 수 있습니다. 이는 예상된 동작입니다.", + "dontShowAgain": "다시 표시하지 않음", + "continue": "계속", + "cancel": "취소" + }, + "syncAll": { + "title": "기존 항목에 대해 동기화 활성화", + "description": "동기화되지 않는 항목이 있습니다. 모든 항목에 대해 동기화를 활성화하시겠습니까?", + "enableAll": "모두 활성화", + "skip": "건너뛰기", + "success": "모든 항목에 대해 동기화가 활성화되었습니다", + "labels": { + "proxies": "프록시", + "vpns": "VPN", + "groups": "그룹", + "extensions": "확장 프로그램", + "extensionGroups": "확장 프로그램 그룹" + } + }, + "crossOs": { + "viewOnly": "이 프로필은 {{os}}에서 생성되었으며 이 시스템에서는 지원되지 않습니다", + "cannotLaunch": "이 프로필은 {{os}}에서 생성되었으며 이 시스템에서는 지원되지 않습니다", + "cannotModify": "교차 OS 프로필의 동기화 설정을 수정할 수 없습니다" + }, + "profileInfo": { + "title": "프로필 세부 정보", + "tabs": { + "info": "정보", + "settings": "설정" + }, + "fields": { + "profileId": "프로필 ID", + "browser": "브라우저", + "releaseType": "릴리스 유형", + "proxyVpn": "프록시 / VPN", + "launchHook": "실행 후크", + "group": "그룹", + "tags": "태그", + "note": "메모", + "syncStatus": "동기화 상태", + "lastLaunched": "마지막 실행", + "hostOs": "호스트 OS", + "ephemeral": "임시", + "extensionGroup": "확장 프로그램 그룹", + "totalSessions": "총 세션 수", + "syncMode": "동기화 모드", + "proxy": "프록시", + "vpn": "VPN", + "cookieCount": "저장된 쿠키", + "localDataTransfer": "로컬 데이터 전송" + }, + "values": { + "none": "없음", + "never": "안 함", + "copied": "복사됨!", + "yes": "예", + "activeNow": "지금 활성", + "direct": "직접", + "loading": "불러오는 중…" + }, + "network": { + "bypassRules": "프록시 우회 규칙", + "bypassRulesTitle": "프록시 우회 규칙", + "bypassRulesDescription": "이 규칙과 일치하는 요청은 프록시를 우회하여 직접 연결됩니다.", + "addRule": "규칙 추가", + "rulePlaceholder": "예: example.com, 192.168.1.*, .*\\.local", + "noRules": "구성된 우회 규칙이 없습니다.", + "ruleTypes": "호스트 이름, IP 주소 및 정규식 패턴을 지원합니다." + }, + "launchHook": { + "title": "실행 후크 URL", + "label": "실행 후크 URL", + "description": "도넛 브라우저는 프로필이 실행될 때마다 이 URL로 GET 요청을 보냅니다.", + "placeholder": "https://example.com/hooks/profile-launch", + "invalidUrlHint": "유효한 http:// 또는 https:// URL을 입력하세요." + }, + "actions": { + "manageCookies": "쿠키 관리", + "assignExtensionGroup": "확장 프로그램 그룹 할당" + }, + "clone": { + "title": "프로필 복제", + "description": "복제된 프로필의 이름을 입력하세요", + "namePlaceholder": "프로필 이름", + "button": "복제" + }, + "duplicate": "복제", + "breadcrumbRoot": "프로필", + "openDialog": "설정 열기", + "sections": { + "overview": "개요", + "fingerprint": "핑거프린트", + "network": "네트워크", + "cookies": "쿠키", + "extensions": "확장 프로그램", + "sync": "동기화", + "automation": "자동화", + "security": "보안", + "delete": "프로필 삭제", + "activity": "활동", + "launchHook": "실행 후크" + }, + "sectionDesc": { + "fingerprint": "이 프로필이 핑거프린팅 스크립트에 어떻게 표시되는지 구성합니다.", + "network": "이 프로필이 인터넷에 도달하는 데 사용하는 프록시 또는 VPN을 관리합니다.", + "cookies": "이 프로필의 쿠키를 가져오거나, 복사하거나, 지웁니다.", + "extensions": "이 프로필이 실행될 때 어떤 확장 프로그램이 로드될지 선택합니다.", + "sync": "이 프로필이 다른 기기에 어떻게 미러링되는지 구성합니다.", + "automation": "이 프로필이 실행될 때마다 명령 또는 스크립트를 실행합니다.", + "security": "비밀번호로 프로필 데이터를 암호화합니다.", + "launchHook": "프로필이 실행될 때마다 이 URL로 GET 요청을 보냅니다." + }, + "badges": { + "locked": "잠김", + "active": "활성" + }, + "cookies": { + "runningNotice": "브라우저가 실행 중일 때는 쿠키를 읽을 수 없습니다. 먼저 이 프로필을 닫으세요.", + "domainsHeader": "도메인 ({{count}})" + }, + "security": { + "protected": "이 프로필은 비밀번호로 암호화되어 있습니다.", + "unprotected": "이 프로필은 암호화되지 않았습니다. 데이터를 저장 시 암호화하려면 비밀번호를 설정하세요.", + "cannotWhileRunning": "비밀번호를 변경하기 전에 프로필을 중지하세요." + }, + "fingerprint": { + "notSupported": "핑거프린트 편집은 Camoufox 및 Wayfern 프로필에서만 사용할 수 있습니다." + } + }, + "extensions": { + "title": "확장 프로그램", + "description": "프로필의 브라우저 확장 프로그램 및 확장 프로그램 그룹을 관리합니다.", + "upload": "업로드", + "delete": "삭제", + "extensionsTab": "확장 프로그램", + "groupsTab": "그룹", + "managedNotice": "여기서 관리되는 확장 프로그램은 실행 시 프로필에 수동으로 설치된 확장 프로그램을 대체합니다.", + "proRequired": "확장 프로그램 관리는 Pro 기능입니다", + "empty": "아직 업로드된 확장 프로그램이 없습니다.", + "noGroups": "아직 생성된 확장 프로그램 그룹이 없습니다.", + "createGroup": "그룹 생성", + "newGroup": "새 그룹", + "addToGroup": "확장 프로그램 추가...", + "removeFromGroup": "그룹에서 제거", + "deleteGroup": "그룹 삭제", + "extensionGroup": "확장 프로그램 그룹", + "compatibility": { + "label": "호환성", + "chromium": "크로미움", + "firefox": "파이어폭스", + "both": "크로미움 및 파이어폭스" + }, + "selectedFile": "선택된 파일", + "namePlaceholder": "확장 프로그램 이름", + "groupNamePlaceholder": "그룹 이름", + "uploadSuccess": "확장 프로그램이 업로드되었습니다", + "deleteSuccess": "확장 프로그램이 삭제되었습니다", + "groupCreateSuccess": "확장 프로그램 그룹이 생성되었습니다", + "groupUpdateSuccess": "확장 프로그램 그룹이 업데이트되었습니다", + "groupDeleteSuccess": "확장 프로그램 그룹이 삭제되었습니다", + "deleteConfirmTitle": "확장 프로그램 삭제", + "deleteConfirmDescription": "\"{{name}}\"을(를) 정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.", + "deleteGroupConfirmTitle": "확장 프로그램 그룹 삭제", + "deleteGroupConfirmDescription": "그룹 \"{{name}}\"을(를) 정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.", + "invalidFileType": "잘못된 파일 유형입니다. .crx, .xpi 또는 .zip 파일을 업로드하세요.", + "readError": "확장 프로그램 파일 읽기 실패.", + "assignTitle": "확장 프로그램 그룹 할당", + "assignDescription": "선택한 {{count}}개 프로필을 확장 프로그램 그룹에 할당합니다.", + "noGroup": "없음 (확장 프로그램 그룹 없음)", + "assignSuccess": "확장 프로그램 그룹이 할당되었습니다", + "editExtension": "확장 프로그램 편집", + "updateSuccess": "확장 프로그램이 업데이트되었습니다", + "reupload": "다시 업로드", + "version": "버전", + "author": "작성자", + "homepage": "홈페이지", + "editGroup": "그룹 편집", + "editGroupDescription": "그룹 이름을 업데이트하고 포함된 확장 프로그램을 관리합니다.", + "groupExtensions": "이 그룹의 확장 프로그램", + "noExtensionsInGroup": "아직 추가된 확장 프로그램이 없습니다", + "editExtensionDescription": "확장 프로그램 이름을 업데이트하거나, 메타데이터를 보거나, 확장 프로그램 파일을 다시 업로드합니다.", + "metadata": "메타데이터", + "noMetadata": "manifest에서 사용할 수 있는 메타데이터가 없습니다.", + "selectFile": "파일 선택", + "syncEnabled": "동기화 사용됨", + "syncDisabled": "동기화 사용 안 함", + "syncEnableTooltip": "동기화 사용", + "syncDisableTooltip": "동기화 사용 안 함", + "loadGroupsFailed": "확장 프로그램 그룹 불러오기 실패", + "assignGroupFailed": "확장 프로그램 그룹 할당 실패", + "bulkDelete": { + "extensionsTitle": "확장 프로그램 삭제", + "extensionsDescription": "{{count}}개의 확장 프로그램을 삭제하시겠습니까? {{names}}", + "groupsTitle": "확장 프로그램 그룹 삭제", + "groupsDescription": "{{count}}개의 확장 프로그램 그룹을 삭제하시겠습니까? {{names}}", + "confirmButton": "삭제" + } + }, + "pro": { + "badge": "PRO", + "fingerprintLocked": "핑거프린트 편집은 Pro 기능입니다", + "cookieCopyLocked": "쿠키 복사는 Pro 기능입니다", + "cookieImportLocked": "쿠키 가져오기는 Pro 기능입니다", + "cookieExportLocked": "쿠키 내보내기는 Pro 기능입니다", + "cookieManagementLocked": "쿠키 관리는 Pro 기능입니다" + }, + "dnsBlocklist": { + "title": "DNS 차단 목록", + "none": "없음", + "light": "라이트", + "normal": "보통", + "pro": "Pro", + "proPlus": "Pro++", + "ultimate": "얼티밋", + "settingsDescription": "DNS 차단 목록은 프록시 수준에서 광고, 트래커 및 악성코드 도메인을 차단합니다. 목록은 12시간마다 자동으로 새로 고쳐집니다.", + "manageLists": "DNS 차단 목록 관리", + "refreshAll": "모든 목록 새로 고침", + "refreshFailed": "DNS 차단 목록 새로 고침 실패", + "domains": "도메인", + "fresh": "최신", + "stale": "오래됨", + "notCached": "캐시되지 않음" + }, + "vpns": { + "form": { + "titleEdit": "VPN 편집", + "titleCreate": "WireGuard VPN 생성", + "descEdit": "VPN 구성의 이름을 업데이트합니다.", + "descCreate": "WireGuard 인터페이스 및 피어 세부 정보를 입력하세요.", + "name": "이름", + "namePlaceholder": "예: Home WireGuard", + "privateKey": "개인 키", + "privateKeyPlaceholder": "Base64로 인코딩된 개인 키", + "address": "주소", + "addressPlaceholder": "예: 10.0.0.2/24", + "dnsOptional": "DNS (선택 사항)", + "dnsPlaceholder": "예: 1.1.1.1", + "mtuOptional": "MTU (선택 사항)", + "mtuPlaceholder": "예: 1420", + "peerPublicKey": "피어 공개 키", + "peerPublicKeyPlaceholder": "Base64로 인코딩된 피어 공개 키", + "peerEndpoint": "피어 엔드포인트", + "peerEndpointPlaceholder": "예: vpn.example.com:51820", + "allowedIps": "허용된 IP", + "allowedIpsPlaceholder": "예: 0.0.0.0/0, ::/0", + "keepaliveOptional": "지속 Keepalive (선택 사항)", + "keepalivePlaceholder": "예: 25", + "presharedKeyOptional": "사전 공유 키 (선택 사항)", + "presharedKeyPlaceholder": "Base64로 인코딩된 사전 공유 키", + "updateButton": "VPN 업데이트", + "createButton": "VPN 생성", + "nameRequired": "VPN 이름은 필수입니다", + "privateKeyRequired": "개인 키는 필수입니다", + "addressRequired": "주소는 필수입니다", + "peerPublicKeyRequired": "피어 공개 키는 필수입니다", + "peerEndpointRequired": "피어 엔드포인트는 필수입니다", + "updated": "VPN이 업데이트되었습니다", + "created": "WireGuard VPN이 생성되었습니다", + "updateFailed": "VPN 업데이트 실패: {{error}}", + "createFailed": "VPN 생성 실패: {{error}}" + }, + "import": { + "title": "VPN 구성 가져오기", + "descDropzone": "WireGuard (.conf) 구성 파일 가져오기", + "descPreview": "가져올 VPN 구성 검토", + "descResult": "VPN 가져오기가 완료되었습니다", + "dropzonePrompt": "WireGuard .conf 파일을 끌어다 놓거나 클릭하여 찾아보세요", + "pasteHint": "{{modKey}}+V로 클립보드에서 붙여넣기", + "invalidContent": "내용이 유효한 VPN 구성으로 보이지 않습니다", + "fileReadError": "파일 읽기 실패", + "wrongFileType": "WireGuard .conf 파일을 끌어다 놓으세요", + "configurationLabel": "{{type}} 구성", + "endpointLabel": "엔드포인트: {{endpoint}}", + "vpnNameLabel": "VPN 이름", + "vpnNamePlaceholder": "내 VPN", + "configPreview": "구성 미리보기", + "importedSuccess": "VPN을 성공적으로 가져왔습니다", + "importFailed": "가져오기 실패", + "importButton": "VPN 가져오기", + "doneButton": "완료", + "failedGeneric": "VPN 구성 가져오기 실패", + "defaultName": "{{type}} VPN" + }, + "management": { + "loading": "VPN 불러오는 중...", + "noneCreated": "아직 생성된 VPN 구성이 없습니다. 위의 버튼을 사용하여 가져오거나 생성하세요.", + "editVpn": "VPN 편집", + "deleteVpn": "VPN 삭제", + "cannotDelete_one": "삭제할 수 없습니다: {{count}}개 프로필에서 사용 중", + "cannotDelete_other": "삭제할 수 없습니다: {{count}}개 프로필에서 사용 중", + "syncCannotDisable": "이 VPN이 동기화된 프로필에서 사용 중이므로 동기화를 비활성화할 수 없습니다", + "deleteSuccess": "VPN이 삭제되었습니다", + "deleteFailed": "VPN 삭제 실패", + "deleteTitle": "VPN 삭제", + "deleteDescription": "이 작업은 취소할 수 없습니다. VPN \"{{name}}\"이(가) 영구적으로 삭제됩니다." + } + }, + "importProfile": { + "title": "브라우저 프로필 가져오기", + "autoDetect": "자동 감지", + "manualImport": "수동 가져오기", + "detectedProfilesTitle": "감지된 브라우저 프로필", + "scanning": "브라우저 프로필 검색 중...", + "noneFound": "시스템에서 브라우저 프로필을 찾을 수 없습니다.", + "noneFoundHint": "사용자 지정 위치에 프로필이 있는 경우 수동 가져오기 옵션을 시도해 보세요.", + "selectProfile": "프로필 선택:", + "selectProfilePlaceholder": "감지된 프로필 선택", + "pathLabel": "경로:", + "browserLabel": "브라우저:", + "newProfileName": "새 프로필 이름:", + "newProfileNamePlaceholder": "가져올 프로필의 이름을 입력하세요", + "manualTitle": "수동 프로필 가져오기", + "browserType": "브라우저 유형:", + "loadingBrowsers": "브라우저 불러오는 중...", + "selectBrowserType": "브라우저 유형 선택", + "profileFolderPath": "프로필 폴더 경로:", + "profileFolderPlaceholder": "프로필 폴더의 전체 경로 입력", + "browseFolderTitle": "폴더 찾아보기", + "examplePaths": "예시 경로:", + "selectFolderTitle": "브라우저 프로필 폴더 선택", + "folderDialogFailed": "폴더 대화상자 열기 실패", + "detectFailed": "기존 브라우저 프로필 감지 실패", + "fillFields": "모든 필드를 입력하세요", + "selectAndName": "프로필을 선택하고 이름을 제공하세요", + "profileNotFound": "선택한 프로필을 찾을 수 없습니다", + "importedSuccess": "프로필 \"{{name}}\"을(를) 가져왔습니다", + "notInstalled": "{{browser}}이(가) 설치되지 않았습니다. 메인 창에서 먼저 {{browser}}을(를) 다운로드한 후 다시 가져오기를 시도하세요.", + "importFailed": "프로필 가져오기 실패: {{error}}", + "proxyOptional": "프록시 (선택 사항)", + "noProxy": "프록시 없음", + "nextButton": "다음", + "importButton": "가져오기", + "importedAs": "이 프로필은 {{browser}} 프로필로 가져옵니다." + }, + "syncTooltips": { + "syncing": "동기화 중...", + "syncedAt": "{{time}}에 동기화됨", + "synced": "동기화됨", + "waiting": "동기화 대기 중", + "errorWith": "동기화 오류: {{error}}", + "error": "동기화 오류", + "notSynced": "동기화되지 않음", + "enable": "동기화 사용", + "disable": "동기화 사용 안 함", + "lockedInUse": "동기화된 프로필에서 사용 중인 동안 동기화가 잠겨 있습니다", + "bulkToggle": "동기화 전환" + }, + "groupManagement": { + "description": "프로필 그룹을 관리합니다", + "createGroup": "그룹 생성", + "noGroups": "아직 생성된 그룹이 없습니다. 위의 버튼을 사용하여 첫 번째 그룹을 생성하세요.", + "loading": "그룹 불러오는 중...", + "profileCount_one": "{{count}}개 프로필", + "profileCount_other": "{{count}}개 프로필", + "groupsLabel": "그룹", + "profilesCol": "프로필", + "syncCannotDisable": "이 그룹이 동기화된 프로필에서 사용 중이므로 동기화를 비활성화할 수 없습니다", + "editGroupTooltip": "그룹 편집", + "deleteGroupTooltip": "그룹 삭제", + "loadFailed": "그룹 불러오기 실패", + "bulkDelete": { + "title": "그룹 삭제", + "description": "{{count}}개의 그룹을 정말 삭제하시겠습니까? {{names}}. 프로필은 기본으로 이동됩니다.", + "description_one": "{{count}}개의 그룹을 정말 삭제하시겠습니까? {{names}}. 프로필은 기본으로 이동됩니다.", + "confirmButton": "그룹 삭제" + } + }, + "proxyAssignment": { + "title": "프록시 / VPN 할당", + "description_one": "선택한 {{count}}개 프로필에 프록시 또는 VPN을 할당합니다.", + "description_other": "선택한 {{count}}개 프로필에 프록시 또는 VPN을 할당합니다.", + "selectLabel": "프록시 / VPN", + "placeholder": "프록시 또는 VPN 선택", + "noProxy": "프록시 / VPN 없음", + "searchPlaceholder": "프록시 또는 VPN 검색...", + "notFound": "프록시 또는 VPN을 찾을 수 없습니다.", + "assignButton": "할당", + "success": "{{count}}개 프로필에 프록시/VPN을 할당했습니다", + "failed": "프록시/VPN 할당 실패", + "selectedProfilesLabel": "선택된 프로필:", + "assignProxyVpnLabel": "프록시 / VPN 할당:", + "noneOption": "없음", + "noValidProfiles": "유효한 프로필이 선택되지 않았습니다.", + "vpnGroupHeading": "VPN", + "failedFallback": "프로필에 프록시/VPN 할당 실패" + }, + "groupAssignment": { + "title": "그룹 할당", + "description_one": "선택한 {{count}}개 프로필에 그룹을 할당합니다.", + "description_other": "선택한 {{count}}개 프로필에 그룹을 할당합니다.", + "selectLabel": "그룹", + "placeholder": "그룹 선택", + "noGroup": "그룹 없음 (기본)", + "assignButton": "할당", + "success": "{{count}}개 프로필에 그룹을 할당했습니다", + "failed": "그룹 할당 실패", + "selectedProfilesLabel": "선택된 프로필:", + "assignGroupLabel": "그룹에 할당:", + "noValidProfiles": "유효한 프로필이 선택되지 않았습니다.", + "failedFallback": "프로필에 그룹 할당 실패" + }, + "profileSelector": { + "title": "프로필 선택", + "description": "이 URL로 실행할 프로필을 선택하세요", + "searchPlaceholder": "프로필 검색...", + "noProfiles": "사용 가능한 프로필이 없습니다", + "noResults": "검색과 일치하는 프로필이 없습니다", + "selectButton": "선택", + "launching": "실행 중...", + "chooseProfileTitle": "프로필 선택", + "openingUrl": "URL 여는 중:", + "urlCopied": "URL이 클립보드에 복사되었습니다!", + "selectProfileLabel": "프로필 선택:", + "noneAvailableShort": "사용 가능한 프로필이 없습니다. 먼저 프로필을 생성하세요.", + "noneAvailableLong": "이 대화상자를 닫고 메인 창에서 프로필을 생성하여 시작하세요.", + "chooseAProfile": "프로필 선택", + "badgeProxy": "프록시", + "badgeRunning": "실행 중", + "badgeUnavailable": "사용 불가", + "openButton": "열기" + }, + "locationProxy": { + "title": "빠른 위치 프록시", + "description": "이 프로필을 라우팅할 국가를 선택하세요. 프록시가 자동으로 생성됩니다.", + "country": "국가", + "selectCountry": "국가 선택", + "searchCountry": "국가 검색...", + "noCountriesFound": "국가를 찾을 수 없습니다.", + "apply": "적용", + "creating": "프록시 생성 중...", + "success": "위치 프록시가 적용되었습니다", + "failed": "위치 프록시 적용 실패", + "titleCreate": "위치 프록시 생성", + "descriptionCreate": "24시간 스티키 세션을 사용한 지역 기반 프록시 생성", + "countryLabel": "국가 (필수)", + "regionLabel": "지역 (선택 사항)", + "cityLabel": "도시 (선택 사항)", + "ispLabel": "ISP (선택 사항)", + "nameLabel": "이름", + "namePlaceholder": "프록시 이름", + "loadingCountries": "국가 불러오는 중...", + "selectCountryPh": "국가 선택", + "searchCountries": "국가 검색...", + "loadFailed": "국가 불러오기 실패", + "selectCountryFirst": "먼저 국가를 선택하세요", + "loadingRegions": "지역 불러오는 중...", + "noRegions": "사용 가능한 지역이 없습니다", + "selectRegion": "지역 선택", + "searchRegions": "지역 검색...", + "loadingCities": "도시 불러오는 중...", + "noCities": "사용 가능한 도시가 없습니다", + "selectCity": "도시 선택", + "searchCities": "도시 검색...", + "loadingIsps": "ISP 불러오는 중...", + "noIsps": "사용 가능한 ISP가 없습니다", + "selectIsp": "ISP 선택", + "searchIsps": "ISP 검색...", + "createSuccess": "위치 프록시가 생성되었습니다", + "createFailed": "위치 프록시 생성 실패", + "creatingButton": "생성 중...", + "createButton": "생성" + }, + "launchOnLogin": { + "title": "로그인 시 실행을 활성화하시겠습니까?", + "description": "백그라운드에서 실행하면 프록시와 브라우저를 계속 유지할 수 있습니다.", + "declineButton": "다시 묻지 않음", + "declining": "...", + "enableButton": "활성화", + "enableSuccess": "로그인 시 실행이 활성화되었습니다", + "enableFailed": "로그인 시 실행 활성화 실패", + "declineFailed": "환경 설정 저장 실패", + "tryAgain": "다시 시도하세요" + }, + "wayfernTerms": { + "title": "Wayfern 이용 약관", + "description": "도넛 브라우저를 사용하기 전에 Wayfern의 이용 약관을 읽고 동의해야 합니다.", + "reviewLabel": "다음 위치에서 이용 약관을 검토하세요:", + "agreeNotice": "\"동의함\"을 클릭하면 이 약관에 동의하는 것입니다.", + "acceptButton": "동의함", + "acceptSuccess": "약관에 동의했습니다", + "acceptFailed": "약관 동의 실패", + "tryAgain": "다시 시도하세요" + }, + "commercialTrial": { + "title": "상업용 체험판 만료됨", + "description": "2주 상업용 체험판 기간이 종료되었습니다.", + "body": "도넛 브라우저를 비즈니스 용도로 사용하는 경우 계속 사용하려면 상업용 라이선스를 구매해야 합니다. 개인 용도로는 계속 무료로 사용할 수 있습니다.", + "understandButton": "이해했습니다", + "failed": "확인 저장 실패", + "tryAgain": "다시 시도하세요" + }, + "permissionDialog": { + "titleMicrophone": "마이크 액세스가 필요합니다", + "titleCamera": "카메라 액세스가 필요합니다", + "descMicrophone": "도넛 브라우저는 웹 브라우저에서 마이크 기능을 활성화하기 위해 마이크에 액세스해야 합니다. 마이크를 사용하려는 각 웹사이트는 여전히 개별적으로 권한을 요청합니다.", + "descCamera": "도넛 브라우저는 웹 브라우저에서 카메라 기능을 활성화하기 위해 카메라에 액세스해야 합니다. 카메라를 사용하려는 각 웹사이트는 여전히 개별적으로 권한을 요청합니다.", + "grantedMicrophone": "권한이 허용되었습니다! 이제 도넛 브라우저에서 실행된 브라우저가 마이크에 액세스할 수 있습니다.", + "grantedCamera": "권한이 허용되었습니다! 이제 도넛 브라우저에서 실행된 브라우저가 카메라에 액세스할 수 있습니다.", + "notGrantedMicrophone": "권한이 허용되지 않았습니다. 아래 버튼을 클릭하여 마이크에 대한 액세스를 요청하세요.", + "notGrantedCamera": "권한이 허용되지 않았습니다. 아래 버튼을 클릭하여 카메라에 대한 액세스를 요청하세요.", + "doneButton": "완료", + "cancelButton": "취소", + "grantAccessButton": "액세스 허용", + "requestSuccessMicrophone": "마이크 액세스 권한이 요청되었습니다", + "requestSuccessCamera": "카메라 액세스 권한이 요청되었습니다", + "requestFailed": "권한 요청 실패", + "stillNotGrantedMicrophone": "마이크 액세스가 아직 허용되지 않았습니다. 시스템 설정 → 개인 정보 보호 및 보안 → 마이크에서 수동으로 활성화해야 할 수 있습니다.", + "stillNotGrantedCamera": "카메라 액세스가 아직 허용되지 않았습니다. 시스템 설정 → 개인 정보 보호 및 보안 → 카메라에서 수동으로 활성화해야 할 수 있습니다.", + "grantedToastMicrophone": "마이크 액세스가 허용되었습니다", + "grantedToastCamera": "카메라 액세스가 허용되었습니다" + }, + "traffic": { + "title": "트래픽 세부 정보", + "bandwidthOverTime": "시간별 대역폭", + "timePeriodPlaceholder": "기간", + "last1m": "최근 1분", + "last5m": "최근 5분", + "last30m": "최근 30분", + "last1h": "최근 1시간", + "last2h": "최근 2시간", + "last4h": "최근 4시간", + "last1d": "최근 1일", + "last7d": "최근 7일", + "last30d": "최근 30일", + "allTime": "전체 기간", + "allTimeShort": "전체 기간", + "totalSuffix": "총합", + "sentLabel": "보냄 ({{period}})", + "receivedLabel": "받음 ({{period}})", + "requestsLabel": "요청 ({{period}})", + "allTimeTraffic": "전체 기간 트래픽:", + "allTimeRequests": "전체 기간 요청:", + "proxyDisclaimer": "참고: 프록시, VPN 또는 유사한 서비스를 사용하는 경우 암호화 오버헤드 및 프로토콜 차이로 인해 제공자가 트래픽을 다르게 계산할 수 있습니다.", + "topByTraffic": "트래픽 기준 상위 도메인 ({{period}})", + "topByRequests": "요청 기준 상위 도메인 ({{period}})", + "columnDomain": "도메인", + "columnRequests": "요청", + "columnSent": "보냄", + "columnReceived": "받음", + "columnTotal": "총 트래픽", + "uniqueIps": "고유 IP ({{count}})", + "noData": "이 프로필에 사용할 수 있는 트래픽 데이터가 없습니다.", + "noDataHint": "프로필을 실행한 후 트래픽 데이터가 표시됩니다.", + "sentLegend": "보냄", + "receivedLegend": "받음", + "tooltipSent": "↑ 보냄: ", + "tooltipReceived": "↓ 받음: " + }, + "camoufoxDialog": { + "titleView": "핑거프린트 설정 보기 - {{name}} ({{browser}})", + "titleConfigure": "핑거프린트 설정 구성 - {{name}} ({{browser}})", + "invalidFingerprint": "잘못된 핑거프린트 구성", + "invalidFingerprintDescription": "핑거프린트 구성에 잘못된 JSON이 포함되어 있습니다. 고급 설정을 확인하세요.", + "saveFailed": "구성 저장 실패", + "unknownError": "알 수 없는 오류가 발생했습니다" + }, + "proxyCheck": { + "unknownLocation": "알 수 없음", + "locationToast": "프록시 위치:", + "failed": "프록시 확인 실패: {{error}}", + "tooltipChecking": "프록시 확인 중...", + "tooltipIp": "IP: {{ip}}", + "tooltipChecked": "{{time}}에 확인됨", + "tooltipFailed": "{{time}}에 실패", + "tooltipFailedTitle": "프록시 확인 실패", + "tooltipDefault": "프록시 유효성 확인" + }, + "vpnCheck": { + "valid": "VPN \"{{name}}\" 구성이 유효합니다", + "invalid": "VPN \"{{name}}\" 구성이 유효하지 않습니다", + "failed": "VPN 확인 실패: {{error}}", + "tooltipChecking": "VPN 구성 확인 중...", + "tooltipValid": "구성이 유효합니다", + "tooltipInvalid": "구성이 유효하지 않습니다", + "tooltipChecked": "{{time}}에 확인됨", + "tooltipDefault": "VPN 구성 유효성 확인" + }, + "profileTable": { + "syncTooltipDisabled": "동기화 사용 안 함", + "syncTooltipSyncing": "동기화 중...", + "syncTooltipSyncedAt": "{{time}}에 동기화됨", + "syncTooltipSynced": "동기화됨", + "syncTooltipWaiting": "동기화 대기 중", + "syncTooltipErrorWith": "동기화 오류: {{error}}", + "syncTooltipError": "동기화 오류", + "syncTooltipNotSynced": "동기화되지 않음", + "noTags": "태그 없음", + "syncTooltipCloseToSync": "동기화하려면 프로필을 닫으세요", + "syncTooltipDisabledWithLast": "동기화 사용 안 함, 마지막 동기화 {{time}}", + "addTagsPlaceholder": "태그 추가", + "tagsHeader": "태그", + "noteHeader": "메모", + "vpnsHeading": "VPN", + "createByCountryHeading": "국가별 생성" + }, + "releaseTypeSelector": { + "noReleaseTypes": "사용 가능한 릴리스 유형이 없습니다.", + "placeholder": "릴리스 유형 선택...", + "stable": "안정", + "nightly": "나이틀리", + "downloaded": "다운로드됨", + "downloadBrowser": "브라우저 다운로드", + "downloading": "다운로드 중..." + }, + "dataTableActionBar": { + "selected": "{{count}}개 선택됨", + "clearSelection": "선택 지우기" + }, + "appUpdate": { + "toast": { + "updateFailed": "도넛 브라우저 업데이트 실패", + "restartFailed": "재시작 실패", + "updateReady": "업데이트 준비됨, 적용하려면 재시작하세요", + "manualDownloadRequired": "수동 다운로드 필요", + "restartNow": "지금 재시작", + "viewRelease": "릴리스 보기", + "later": "나중에", + "uploading": "업로드 중", + "downloading": "다운로드 중" + } + }, + "browserDownload": { + "toast": { + "fetchVersionsFailed": "{{browser}} 버전 가져오기 실패", + "foundNewVersions": "{{count}}개의 새로운 {{browser}} 버전을 찾았습니다!", + "totalAvailableVersions": "총 사용 가능: {{count}}개 버전", + "downloadFailed": "{{browser}} {{version}} 다운로드 실패", + "calculating": "계산 중...", + "extractionFailed": "{{browser}} {{version}}: 압축 해제 실패", + "extractionFailedDescription": "손상된 파일이 삭제되었습니다. 다음 시도 시 다시 다운로드됩니다.", + "extracting": "브라우저 파일 압축 해제 중... 앱을 닫지 마세요.", + "verifying": "브라우저 파일 확인 중...", + "downloadingRolling": "롤링 릴리스 빌드 다운로드 중..." + } + }, + "versionUpdater": { + "toast": { + "alreadyAvailable": "{{browser}} {{version}}이(가) 이미 사용 가능합니다", + "updatingProfiles": "프로필 구성 업데이트 중...", + "updateCompleted": "{{browser}} 업데이트 완료", + "singleProfileUpdated": "프로필 \"{{name}}\"이(가) 버전 {{version}}으로 업데이트되었습니다. 이제 최신 버전으로 브라우저를 실행할 수 있습니다.", + "multipleProfilesUpdated": "{{count}}개 프로필이 버전 {{version}}으로 업데이트되었습니다. 이제 최신 버전으로 브라우저를 실행할 수 있습니다.", + "versionAvailable": "이제 버전 {{version}}을(를) 사용할 수 있습니다. 실행 중인 프로필은 재시작 시 새 버전을 사용합니다.", + "autoUpdateFailed": "{{browser}} 자동 업데이트 실패", + "updateWithErrors": "일부 오류와 함께 업데이트가 완료되었습니다", + "updateWithErrorsDescription": "{{newVersions}}개의 새 버전을 찾았고, {{failedUpdates}}개 브라우저 업데이트에 실패했습니다", + "updateSuccess": "브라우저 버전이 업데이트되었습니다", + "updateSuccessDescription": "{{successfulUpdates}}개 브라우저에서 {{newVersions}}개의 새 버전을 찾았습니다. 자동 다운로드가 곧 시작됩니다.", + "upToDate": "새 브라우저 버전이 없습니다", + "upToDateDescription": "모든 브라우저 버전이 최신입니다", + "updateAllFailed": "브라우저 버전 업데이트 실패" + } + }, + "profilePassword": { + "set": { + "title": "프로필 비밀번호 설정", + "description": "{{name}}의 디스크 데이터를 암호화합니다. 프로필을 실행할 때마다 이 비밀번호가 필요합니다.", + "button": "프로필 암호화" + }, + "unlock": { + "title": "프로필 잠금 해제", + "description": "{{name}}의 잠금을 해제하려면 비밀번호를 입력하세요.", + "button": "잠금 해제" + }, + "change": { + "title": "프로필 비밀번호 변경", + "description": "새 비밀번호로 {{name}}을(를) 다시 암호화합니다.", + "button": "비밀번호 변경" + }, + "remove": { + "title": "프로필 비밀번호 제거", + "description": "{{name}}의 디스크 데이터를 복호화합니다. 프로필이 더 이상 비밀번호로 보호되지 않습니다.", + "button": "비밀번호 제거" + }, + "fields": { + "password": "비밀번호", + "currentPassword": "현재 비밀번호", + "newPassword": "새 비밀번호", + "confirm": "비밀번호 확인", + "confirmPassword": "새 비밀번호 확인" + }, + "errors": { + "oldPasswordRequired": "현재 비밀번호는 필수입니다", + "passwordRequired": "비밀번호는 필수입니다", + "tooShort": "비밀번호는 8자 이상이어야 합니다", + "mismatch": "비밀번호가 일치하지 않습니다" + }, + "toasts": { + "set": "프로필이 이제 비밀번호로 보호됩니다", + "changed": "프로필 비밀번호가 변경되었습니다", + "removed": "프로필 비밀번호가 제거되었습니다" + }, + "warnings": { + "forgetWarningTitle": "중요: 이 비밀번호는 복구할 수 없습니다", + "forgetWarningBody": "도넛 브라우저는 이 비밀번호를 재설정, 복구 또는 우회할 수 없습니다. 잊어버리면 이 프로필의 데이터에 영구적으로 액세스할 수 없게 됩니다." + }, + "modes": { + "set": "설정", + "change": "변경", + "remove": "제거", + "validate": "확인" + }, + "verifyDialog": { + "title": "프로필 비밀번호 확인", + "description": "디스크에 저장된 비밀번호와 일치하는지 확인하려면 프로필 비밀번호를 입력하세요.", + "submit": "확인", + "matchToast": "비밀번호가 일치합니다" + } + }, + "backendErrors": { + "incorrectPassword": "잘못된 비밀번호", + "lockedOut": "잘못된 시도가 너무 많습니다. {{duration}} 후에 다시 시도하세요.", + "lockedOutDuration": { + "seconds": "{{seconds}}초", + "minutes": "{{minutes}}분", + "hours": "{{hours}}시간" + }, + "profileNotFound": "프로필을 찾을 수 없습니다", + "profileNotProtected": "프로필이 비밀번호로 보호되지 않습니다", + "profileAlreadyProtected": "프로필이 이미 비밀번호로 보호되어 있습니다", + "profileRunning": "프로필이 실행 중인 동안에는 이 작업을 수행할 수 없습니다", + "profileEphemeral": "임시 프로필은 비밀번호로 보호할 수 없습니다 — 종료 시 데이터가 삭제됩니다.", + "profileMissingSalt": "프로필에 암호화 솔트가 없습니다", + "profileLocked": "프로필이 잠겨 있습니다. 먼저 비밀번호를 입력하세요.", + "invalidProfileId": "잘못된 프로필 ID", + "passwordTooShort": "비밀번호는 {{min}}자 이상이어야 합니다", + "proxyNotFound": "프록시를 찾을 수 없습니다", + "groupNotFound": "그룹을 찾을 수 없습니다", + "vpnNotFound": "VPN을 찾을 수 없습니다", + "extensionNotFound": "확장 프로그램을 찾을 수 없습니다", + "extensionGroupNotFound": "확장 프로그램 그룹을 찾을 수 없습니다", + "cannotModifyCloudManagedProxy": "클라우드 관리 프록시의 동기화는 수정할 수 없습니다", + "syncLockedByProfile": "동기화된 프로필에서 사용 중인 동안에는 동기화를 비활성화할 수 없습니다", + "syncNotConfigured": "동기화가 구성되지 않았습니다. 먼저 로그인하거나 자체 호스팅 서버를 구성하세요.", + "internal": "오류가 발생했습니다: {{detail}}", + "invalidLaunchHookUrl": "잘못된 실행 후크 URL입니다. 전체 http:// 또는 https:// URL을 사용하세요.", + "cookieDbLocked": "쿠키를 읽을 수 없습니다 — 데이터베이스가 잠겨 있습니다. 브라우저를 닫고 다시 시도하세요.", + "cookieDbUnavailable": "쿠키를 읽을 수 없습니다 — 쿠키 저장소를 사용할 수 없습니다.", + "selfHostedRequiresLogout": "자체 호스팅 서버를 구성하기 전에 도넛 계정에서 로그아웃하세요." + }, + "rail": { + "profiles": "프로필", + "extensions": "확장 프로그램", + "groups": "그룹", + "settings": "설정", + "more": { + "label": "더 보기", + "closeAriaLabel": "메뉴 닫기", + "importProfile": "프로필 가져오기", + "importProfileHint": "다른 도구에서 프로필 가져오기", + "keyboardShortcuts": "키보드 단축키", + "keyboardShortcutsHint": "모든 단축키 보기" + }, + "network": "네트워크", + "integrations": "통합", + "account": "계정" + }, + "pageTitle": { + "proxies": "네트워크", + "extensions": "확장 프로그램", + "groups": "그룹", + "vpns": "네트워크", + "settings": "설정", + "integrations": "통합", + "account": "계정", + "import": "프로필 가져오기", + "shortcuts": "키보드 단축키" + }, + "encryption": { + "required": { + "title": "동기화 일시 중지 — 비밀번호 필요", + "description": "암호화된 데이터가 다운로드되었지만 이 기기에 E2E 비밀번호가 설정되지 않았습니다. 동기화를 재개하려면 설정 → 암호화를 열고 비밀번호를 입력하세요.", + "openSettings": "설정 열기" + }, + "rollover": { + "startedTitle": "데이터 재암호화 중", + "startedDescription": "동기화된 모든 항목을 새 비밀번호로 다시 업로드하고 있습니다. 프로필부터 시작하여 프록시, 그룹, VPN 및 확장 프로그램 순서입니다.", + "progressTitle": "{{stage}} 재암호화 중", + "progressDescription": "{{total}}개 중 {{done}}개", + "completedTitle": "재암호화 완료", + "completedDescription": "동기화된 모든 데이터가 새 비밀번호로 봉인되었습니다.", + "stage": { + "profiles": "프로필", + "proxies": "프록시", + "groups": "그룹", + "vpns": "VPN", + "extensions": "확장 프로그램", + "extension_groups": "확장 프로그램 그룹" + } + } + }, + "account": { + "refreshed": "계정이 새로 고쳐졌습니다", + "loggedOut": "로그아웃됨", + "signedOut": "로그아웃됨", + "signedOutDescription": "클라우드 동기화, 암호화된 프로필 및 팀 기능을 사용하려면 로그인하세요.", + "plan": "플랜: {{plan}} · {{period}}", + "refresh": "새로 고침", + "logout": "로그아웃", + "signIn": "로그인", + "fields": { + "plan": "플랜", + "status": "상태", + "teamRole": "팀 역할", + "period": "결제 기간" + }, + "tabs": { + "account": "계정", + "selfHosted": "자체 호스팅" + }, + "selfHosted": { + "title": "자체 호스팅 동기화 서버", + "description": "호스팅 클라우드를 사용하지 않고 프로필, 프록시, 그룹 및 확장 프로그램을 동기화하려면 도넛을 자체 donut-sync 서버로 연결하세요.", + "disabledWhileLoggedIn": "도넛 계정에 로그인되어 있는 동안에는 자체 호스팅 동기화를 사용할 수 없습니다. 사용자 지정 서버를 사용하려면 로그아웃하세요.", + "connectionStatus": "연결:", + "statusUnknown": "테스트 안 됨", + "testConnection": "연결 테스트", + "disconnect": "연결 해제" + } + }, + "shortcutsPage": { + "title": "키보드 단축키", + "description": "이 단축키로 작업 흐름을 빠르게 하세요." + }, + "commandPalette": { + "placeholder": "명령을 입력하거나 검색...", + "empty": "결과를 찾을 수 없습니다.", + "groups": { + "navigation": "탐색", + "profiles": "프로필", + "actions": "작업", + "profileGroups": "프로필 그룹" + }, + "actions": { + "launchProfile": "{{name}} 실행", + "stopProfile": "{{name}} 중지", + "profileInfo": "정보 — {{name}}" + } + }, + "shortcuts": { + "openPalette": "명령 팔레트 열기", + "openShortcuts": "키보드 단축키 보기", + "importProfile": "프로필 가져오기", + "goProfiles": "프로필로 이동", + "goProxies": "네트워크로 이동", + "goExtensions": "확장 프로그램으로 이동", + "goGroups": "그룹으로 이동", + "goIntegrations": "통합으로 이동", + "goAccount": "계정으로 이동", + "goSettings": "설정으로 이동" + } +} diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 9c62e80..ae38dff 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1070,16 +1070,16 @@ "syncAll": { "title": "Ativar sincronização para itens existentes", "description": "Você tem itens que não estão sendo sincronizados. Gostaria de ativar a sincronização para todos?", - "itemsList": "Itens não sincronizados: {{items}}", - "proxies": "{{count}} proxy", - "proxies_plural": "{{count}} proxies", - "groups": "{{count}} grupo", - "groups_plural": "{{count}} grupos", - "vpns": "{{count}} VPN", - "vpns_plural": "{{count}} VPNs", "enableAll": "Ativar todos", "skip": "Pular", - "success": "Sincronização ativada para todos os itens" + "success": "Sincronização ativada para todos os itens", + "labels": { + "proxies": "Proxies", + "vpns": "VPNs", + "groups": "Grupos", + "extensions": "Extensões", + "extensionGroups": "Grupos de extensões" + } }, "crossOs": { "viewOnly": "Este perfil foi criado em {{os}} e não é compatível com este sistema", @@ -1788,6 +1788,14 @@ "profileLocked": "O perfil está bloqueado. Digite a senha primeiro.", "invalidProfileId": "ID de perfil inválido", "passwordTooShort": "A senha deve ter pelo menos {{min}} caracteres", + "proxyNotFound": "Proxy não encontrado", + "groupNotFound": "Grupo não encontrado", + "vpnNotFound": "VPN não encontrada", + "extensionNotFound": "Extensão não encontrada", + "extensionGroupNotFound": "Grupo de extensões não encontrado", + "cannotModifyCloudManagedProxy": "Não é possível modificar a sincronização de um proxy gerenciado na nuvem", + "syncLockedByProfile": "A sincronização não pode ser desativada enquanto estiver em uso por perfis sincronizados", + "syncNotConfigured": "A sincronização não está configurada. Faça login ou configure um servidor auto-hospedado.", "internal": "Algo deu errado: {{detail}}", "invalidLaunchHookUrl": "URL do hook de inicialização inválida. Use uma URL completa http:// ou https://.", "cookieDbLocked": "Não foi possível ler os cookies — o banco de dados está bloqueado. Feche o navegador e tente novamente.", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index b07e2b8..530b61c 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1070,16 +1070,16 @@ "syncAll": { "title": "Включить синхронизацию для существующих элементов", "description": "У вас есть элементы, которые не синхронизируются. Хотите включить синхронизацию для всех?", - "itemsList": "Несинхронизированные элементы: {{items}}", - "proxies": "{{count}} прокси", - "proxies_plural": "{{count}} прокси", - "groups": "{{count}} группа", - "groups_plural": "{{count}} групп", - "vpns": "{{count}} VPN", - "vpns_plural": "{{count}} VPN", "enableAll": "Включить все", "skip": "Пропустить", - "success": "Синхронизация включена для всех элементов" + "success": "Синхронизация включена для всех элементов", + "labels": { + "proxies": "Прокси", + "vpns": "VPN", + "groups": "Группы", + "extensions": "Расширения", + "extensionGroups": "Группы расширений" + } }, "crossOs": { "viewOnly": "Этот профиль был создан на {{os}} и не поддерживается в этой системе", @@ -1788,6 +1788,14 @@ "profileLocked": "Профиль заблокирован. Сначала введите пароль.", "invalidProfileId": "Недействительный идентификатор профиля", "passwordTooShort": "Пароль должен быть не короче {{min}} символов", + "proxyNotFound": "Прокси не найден", + "groupNotFound": "Группа не найдена", + "vpnNotFound": "VPN не найден", + "extensionNotFound": "Расширение не найдено", + "extensionGroupNotFound": "Группа расширений не найдена", + "cannotModifyCloudManagedProxy": "Невозможно изменить синхронизацию для облачного прокси", + "syncLockedByProfile": "Невозможно отключить синхронизацию, пока используется синхронизированными профилями", + "syncNotConfigured": "Синхронизация не настроена. Войдите или настройте собственный сервер.", "internal": "Что-то пошло не так: {{detail}}", "invalidLaunchHookUrl": "Неверный URL хука запуска. Используйте полный URL http:// или https://.", "cookieDbLocked": "Не удалось прочитать куки — база данных заблокирована. Закройте браузер и попробуйте снова.", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 72dec3e..881aa9d 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1070,16 +1070,16 @@ "syncAll": { "title": "为现有项目启用同步", "description": "您有未同步的项目。是否要为所有项目启用同步?", - "itemsList": "未同步项目: {{items}}", - "proxies": "{{count}} 个代理", - "proxies_plural": "{{count}} 个代理", - "groups": "{{count}} 个分组", - "groups_plural": "{{count}} 个分组", - "vpns": "{{count}} 个 VPN", - "vpns_plural": "{{count}} 个 VPN", "enableAll": "全部启用", "skip": "跳过", - "success": "已为所有项目启用同步" + "success": "已为所有项目启用同步", + "labels": { + "proxies": "代理", + "vpns": "VPN", + "groups": "分组", + "extensions": "扩展", + "extensionGroups": "扩展分组" + } }, "crossOs": { "viewOnly": "此配置文件在 {{os}} 上创建,不受此系统支持", @@ -1788,6 +1788,14 @@ "profileLocked": "配置文件已锁定。请先输入密码。", "invalidProfileId": "配置文件 ID 无效", "passwordTooShort": "密码至少需要 {{min}} 个字符", + "proxyNotFound": "未找到代理", + "groupNotFound": "未找到分组", + "vpnNotFound": "未找到 VPN", + "extensionNotFound": "未找到扩展", + "extensionGroupNotFound": "未找到扩展分组", + "cannotModifyCloudManagedProxy": "无法修改云管理代理的同步", + "syncLockedByProfile": "在被已同步的配置文件使用时无法禁用同步", + "syncNotConfigured": "同步未配置。请先登录或配置自托管服务器。", "internal": "出现问题:{{detail}}", "invalidLaunchHookUrl": "启动钩子 URL 无效。请使用完整的 http:// 或 https:// URL。", "cookieDbLocked": "无法读取 Cookie — 数据库已锁定。请关闭浏览器后重试。", diff --git a/src/lib/backend-errors.ts b/src/lib/backend-errors.ts index cd2aeb8..a759c23 100644 --- a/src/lib/backend-errors.ts +++ b/src/lib/backend-errors.ts @@ -20,6 +20,14 @@ export type BackendErrorCode = | "COOKIE_DB_LOCKED" | "COOKIE_DB_UNAVAILABLE" | "SELF_HOSTED_REQUIRES_LOGOUT" + | "PROXY_NOT_FOUND" + | "GROUP_NOT_FOUND" + | "VPN_NOT_FOUND" + | "EXTENSION_NOT_FOUND" + | "EXTENSION_GROUP_NOT_FOUND" + | "CANNOT_MODIFY_CLOUD_MANAGED_PROXY" + | "SYNC_LOCKED_BY_PROFILE" + | "SYNC_NOT_CONFIGURED" | "INTERNAL_ERROR"; export interface BackendError { @@ -96,6 +104,22 @@ export function translateBackendError(t: TFunction, err: unknown): string { return t("backendErrors.cookieDbUnavailable"); case "SELF_HOSTED_REQUIRES_LOGOUT": return t("backendErrors.selfHostedRequiresLogout"); + case "PROXY_NOT_FOUND": + return t("backendErrors.proxyNotFound"); + case "GROUP_NOT_FOUND": + return t("backendErrors.groupNotFound"); + case "VPN_NOT_FOUND": + return t("backendErrors.vpnNotFound"); + case "EXTENSION_NOT_FOUND": + return t("backendErrors.extensionNotFound"); + case "EXTENSION_GROUP_NOT_FOUND": + return t("backendErrors.extensionGroupNotFound"); + case "CANNOT_MODIFY_CLOUD_MANAGED_PROXY": + return t("backendErrors.cannotModifyCloudManagedProxy"); + case "SYNC_LOCKED_BY_PROFILE": + return t("backendErrors.syncLockedByProfile"); + case "SYNC_NOT_CONFIGURED": + return t("backendErrors.syncNotConfigured"); case "INTERNAL_ERROR": return t("backendErrors.internal", { detail: parsed.params?.detail ?? "", diff --git a/src/lib/toast-utils.ts b/src/lib/toast-utils.ts index 86dd9d5..47c5296 100644 --- a/src/lib/toast-utils.ts +++ b/src/lib/toast-utils.ts @@ -1,3 +1,4 @@ +import { invoke } from "@tauri-apps/api/core"; import React from "react"; import { type ExternalToast, toast as sonnerToast } from "sonner"; import { UnifiedToast } from "@/components/custom-toast"; @@ -259,7 +260,7 @@ export function showSyncProgressToast( failed_count: number; phase: string; }, - options?: { id?: string }, + options?: { id?: string; profileId?: string }, ) { return showToast({ type: "sync-progress", @@ -268,6 +269,15 @@ export function showSyncProgressToast( id: options?.id, duration: Number.POSITIVE_INFINITY, onCancel: () => { + if (options?.profileId) { + // Fire-and-forget — backend flips the cancel flag for the in-flight + // upload/download loops to drain. + void invoke("cancel_profile_sync", { + profileId: options.profileId, + }).catch((err: unknown) => { + console.error("Failed to cancel sync:", err); + }); + } if (options?.id) { dismissToast(options.id); }