refactor: cleanup, korean translation

This commit is contained in:
zhom committed 2026-05-23 14:22:45 +04:00
1 parent 375530e358
commit 69da467ce0
31 files changed
+2573 -307

No files matched your search

+16
View File
@@ -87,6 +87,8 @@ pub struct UpdateProfileRequest {
pub tags: Option<Vec<String>>, pub tags: Option<Vec<String>>,
pub extension_group_id: Option<String>, pub extension_group_id: Option<String>,
pub proxy_bypass_rules: Option<Vec<String>>, pub proxy_bypass_rules: Option<Vec<String>>,
/// One of "Disabled", "Regular", "Encrypted".
pub sync_mode: Option<String>,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -397,10 +399,15 @@ impl ApiServer {
.route("/events", get(ws_handler)) .route("/events", get(ws_handler))
.with_state(ws_state); .with_state(ws_state);
let api_for_v1 = api.clone();
let app = Router::new() let app = Router::new()
.merge(v1_routes) .merge(v1_routes)
.nest("/ws", ws_routes) .nest("/ws", ws_routes)
.route("/openapi.json", get(move || async move { Json(api) })) .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 // Outermost layer: logs every request so customer reports show what
// their automation is actually calling, what the response status was, // their automation is actually calling, what the response status was,
// and how long it took. Never logs request bodies or auth headers. // 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 // Return updated profile
get_profile(Path(id), State(state)).await get_profile(Path(id), State(state)).await
} }
+36 -8
View File
@@ -287,7 +287,7 @@ impl CamoufoxManager {
} }
} }
let child = command let mut child = command
.spawn() .spawn()
.map_err(|e| format!("Failed to spawn Camoufox process: {e}"))?; .map_err(|e| format!("Failed to spawn Camoufox process: {e}"))?;
@@ -296,6 +296,34 @@ impl CamoufoxManager {
log::info!("Camoufox launched with PID: {:?}", process_id); 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 // Store the instance
let instance = CamoufoxInstance { let instance = CamoufoxInstance {
id: instance_id.clone(), id: instance_id.clone(),
@@ -557,28 +585,28 @@ impl CamoufoxManager {
for (id, instance) in inner.instances.iter() { for (id, instance) in inner.instances.iter() {
if let Some(process_id) = instance.process_id { if let Some(process_id) = instance.process_id {
// Check if the process is still alive
if !self.is_server_running(process_id).await { if !self.is_server_running(process_id).await {
// Process is dead log::info!(
// Camoufox instance is no longer running "Camoufox instance {} (PID {}) is no longer running; profile_path={:?}",
id,
process_id,
instance.profile_path
);
dead_instances.push(id.clone()); dead_instances.push(id.clone());
instances_to_remove.push(id.clone()); instances_to_remove.push(id.clone());
} }
} else { } else {
// No process_id means it's likely a dead instance log::info!("Camoufox instance {} has no PID, marking as dead", id);
// Camoufox instance has no PID, marking as dead
dead_instances.push(id.clone()); dead_instances.push(id.clone());
instances_to_remove.push(id.clone()); instances_to_remove.push(id.clone());
} }
} }
} }
// Remove dead instances
if !instances_to_remove.is_empty() { if !instances_to_remove.is_empty() {
let mut inner = self.inner.lock().await; let mut inner = self.inner.lock().await;
for id in &instances_to_remove { for id in &instances_to_remove {
inner.instances.remove(id); inner.instances.remove(id);
// Removed dead Camoufox instance
} }
} }
+2 -1
View File
@@ -99,7 +99,7 @@ use settings_manager::{
}; };
use sync::{ 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, 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, 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, set_e2e_password, set_extension_group_sync_enabled, set_extension_sync_enabled,
@@ -2057,6 +2057,7 @@ pub fn run() {
get_sync_settings, get_sync_settings,
save_sync_settings, save_sync_settings,
set_profile_sync_mode, set_profile_sync_mode,
cancel_profile_sync,
request_profile_sync, request_profile_sync,
set_proxy_sync_enabled, set_proxy_sync_enabled,
set_group_sync_enabled, set_group_sync_enabled,
+1 -1
View File
@@ -52,7 +52,7 @@ pub struct AppSettings {
#[serde(default)] #[serde(default)]
pub launch_on_login_declined: bool, // User permanently declined the launch-on-login prompt pub launch_on_login_declined: bool, // User permanently declined the launch-on-login prompt
#[serde(default)] #[serde(default)]
pub language: Option<String>, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ru", or None for system default pub language: Option<String>, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ko", "ru", or None for system default
#[serde(default)] #[serde(default)]
pub window_resize_warning_dismissed: bool, pub window_resize_warning_dismissed: bool,
#[serde(default)] #[serde(default)]
+241 -169
View File
@@ -10,11 +10,48 @@ use chrono::{DateTime, Utc};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc; use std::sync::{Arc, Mutex as StdMutex};
use std::time::Instant; use std::time::Instant;
use tokio::sync::{Mutex as TokioMutex, Semaphore}; use tokio::sync::{Mutex as TokioMutex, Semaphore};
lazy_static::lazy_static! {
static ref SYNC_CANCEL_FLAGS: StdMutex<HashMap<String, Arc<AtomicBool>>> =
StdMutex::new(HashMap::new());
}
fn register_sync_cancel(profile_id: &str) -> Arc<AtomicBool> {
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<bool, String> {
Ok(request_sync_cancel(&profile_id))
}
/// Upload/download concurrency limit /// Upload/download concurrency limit
const SYNC_CONCURRENCY: usize = 32; const SYNC_CONCURRENCY: usize = 32;
@@ -391,6 +428,9 @@ impl SyncEngine {
let profile_dir = profiles_dir.join(profile.id.to_string()); let profile_dir = profiles_dir.join(profile.id.to_string());
let profile_id = 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 // Determine team key prefix for team profiles
let key_prefix = Self::get_team_key_prefix(profile).await; let key_prefix = Self::get_team_key_prefix(profile).await;
@@ -514,10 +554,16 @@ impl SyncEngine {
&diff.files_to_upload, &diff.files_to_upload,
encryption_key.as_ref(), encryption_key.as_ref(),
&key_prefix, &key_prefix,
&cancel_flag,
) )
.await?; .await?;
} }
if cancel_flag.load(Ordering::Relaxed) {
log::info!("Sync cancelled for profile {} after uploads", profile_id);
return Err(SyncError::Cancelled);
}
// Perform downloads // Perform downloads
if !diff.files_to_download.is_empty() { if !diff.files_to_download.is_empty() {
self self
@@ -529,10 +575,16 @@ impl SyncEngine {
&diff.files_to_download, &diff.files_to_download,
encryption_key.as_ref(), encryption_key.as_ref(),
&key_prefix, &key_prefix,
&cancel_flag,
) )
.await?; .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) // Delete local files that don't exist remotely (when remote is newer)
for path in &diff.files_to_delete_local { for path in &diff.files_to_delete_local {
let file_path = profile_dir.join(path); let file_path = profile_dir.join(path);
@@ -823,6 +875,7 @@ impl SyncEngine {
files: &[super::manifest::ManifestFileEntry], files: &[super::manifest::ManifestFileEntry],
encryption_key: Option<&[u8; 32]>, encryption_key: Option<&[u8; 32]>,
key_prefix: &str, key_prefix: &str,
cancel_flag: &Arc<AtomicBool>,
) -> SyncResult<()> { ) -> SyncResult<()> {
if files.is_empty() { if files.is_empty() {
return Ok(()); return Ok(());
@@ -930,6 +983,13 @@ impl SyncEngine {
let save_counter = Arc::new(AtomicU64::new(0)); let save_counter = Arc::new(AtomicU64::new(0));
for file in &files_to_process { 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 sem = semaphore.clone();
let file_path = profile_dir.join(&file.path); let file_path = profile_dir.join(&file.path);
let relative_path = file.path.clone(); let relative_path = file.path.clone();
@@ -958,6 +1018,7 @@ impl SyncEngine {
let resume_state = resume_state.clone(); let resume_state = resume_state.clone();
let save_counter = save_counter.clone(); let save_counter = save_counter.clone();
let profile_dir_clone = profile_dir.clone(); let profile_dir_clone = profile_dir.clone();
let cancel_flag_task = cancel_flag.clone();
let content_type = mime_guess::from_path(&file.path) let content_type = mime_guess::from_path(&file.path)
.first() .first()
.map(|m| m.to_string()); .map(|m| m.to_string());
@@ -965,6 +1026,10 @@ impl SyncEngine {
handles.push(tokio::spawn(async move { handles.push(tokio::spawn(async move {
let _permit = sem.acquire().await.unwrap(); 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) { let data = match fs::read(&file_path) {
Ok(d) => d, Ok(d) => d,
Err(e) if e.kind() == std::io::ErrorKind::NotFound && !critical => { Err(e) if e.kind() == std::io::ErrorKind::NotFound && !critical => {
@@ -1095,6 +1160,7 @@ impl SyncEngine {
files: &[super::manifest::ManifestFileEntry], files: &[super::manifest::ManifestFileEntry],
encryption_key: Option<&[u8; 32]>, encryption_key: Option<&[u8; 32]>,
key_prefix: &str, key_prefix: &str,
cancel_flag: &Arc<AtomicBool>,
) -> SyncResult<()> { ) -> SyncResult<()> {
if files.is_empty() { if files.is_empty() {
return Ok(()); return Ok(());
@@ -1194,6 +1260,13 @@ impl SyncEngine {
let save_counter = Arc::new(AtomicU64::new(0)); let save_counter = Arc::new(AtomicU64::new(0));
for file in &files_to_process { 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 sem = semaphore.clone();
let file_path = profile_dir.join(&file.path); let file_path = profile_dir.join(&file.path);
let relative_path = file.path.clone(); let relative_path = file.path.clone();
@@ -1222,13 +1295,21 @@ impl SyncEngine {
let resume_state = resume_state.clone(); let resume_state = resume_state.clone();
let save_counter = save_counter.clone(); let save_counter = save_counter.clone();
let profile_dir_clone = profile_dir.clone(); let profile_dir_clone = profile_dir.clone();
let cancel_flag_task = cancel_flag.clone();
handles.push(tokio::spawn(async move { handles.push(tokio::spawn(async move {
let _permit = sem.acquire().await.unwrap(); 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 // Retry loop for network downloads
let mut last_err = String::new(); let mut last_err = String::new();
for attempt in 0..MAX_FILE_RETRIES { 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 { match client.download_bytes(&url).await {
Ok(data) => { Ok(data) => {
let write_data = if let Some(ref key) = enc_key { let write_data = if let Some(ref key) = enc_key {
@@ -2361,6 +2442,8 @@ impl SyncEngine {
); );
} }
if !manifest.files.is_empty() { if !manifest.files.is_empty() {
let cancel_flag = register_sync_cancel(profile_id);
let _cancel_guard = SyncCancelGuard(profile_id.to_string());
self self
.download_profile_files( .download_profile_files(
app_handle, app_handle,
@@ -2370,6 +2453,7 @@ impl SyncEngine {
&manifest.files, &manifest.files,
encryption_key.as_ref(), encryption_key.as_ref(),
key_prefix, key_prefix,
&cancel_flag,
) )
.await?; .await?;
} }
@@ -2506,8 +2590,46 @@ impl SyncEngine {
profiles_to_check.len() 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 { 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 match self
.download_profile_if_missing(app_handle, profile_id, key_prefix) .download_profile_if_missing(app_handle, profile_id, key_prefix)
.await .await
@@ -2571,6 +2693,24 @@ impl SyncEngine {
}; };
if has_personal_tombstone || has_team_tombstone { 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!( log::info!(
"Profile {} has remote tombstone, deleting locally (deleted on another device)", "Profile {} has remote tombstone, deleting locally (deleted on another device)",
pid 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()); 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 { if profile.ephemeral {
return Err("Cannot enable sync for an ephemeral profile".to_string()); 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", ()); 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 { if enabling {
let is_running = profile.process_id.is_some(); 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"); log::warn!("Scheduler not initialized, sync will not start");
} }
} else { } 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 { if old_mode != SyncMode::Disabled {
let profile_id_clone = profile_id.clone(); match SyncEngine::create_from_settings(&app_handle).await {
let app_handle_clone = app_handle.clone(); Ok(engine) => {
tokio::spawn(async move { if let Err(e) = engine.delete_profile(&profile_id).await {
match SyncEngine::create_from_settings(&app_handle_clone).await { log::warn!("Failed to delete profile {} from sync: {}", profile_id, e);
Ok(engine) => { } else {
if let Err(e) = engine.delete_profile(&profile_id_clone).await { log::info!("Profile {} deleted from sync service", profile_id);
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);
} }
} }
}); Err(e) => {
log::debug!("Sync not configured, skipping remote deletion: {}", e);
}
}
} }
let _ = events::emit( 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 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( pub async fn trigger_sync_for_profile(
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
profile_id: String, profile_id: String,
@@ -3222,43 +3402,29 @@ pub async fn set_proxy_sync_enabled(
let proxy = proxies let proxy = proxies
.iter() .iter()
.find(|p| p.id == proxy_id) .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 // Block modifying sync for cloud-managed proxies
if proxy.is_cloud_managed { 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 disabling, check if proxy is used by any synced profile
if !enabled && is_proxy_used_by_synced_profile(&proxy_id) { 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 enabling, check that sync settings are configured
if enabled { if enabled {
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await; ensure_sync_configured(&app_handle).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());
}
}
} }
let new_last_sync = if enabled { proxy.last_sync } else { None }; 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", ()); let _ = events::emit("stored-proxies-changed", ());
@@ -3299,36 +3465,18 @@ pub async fn set_group_sync_enabled(
groups groups
.iter() .iter()
.find(|g| g.id == group_id) .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() .clone()
}; };
// If disabling, check if group is used by any synced profile // If disabling, check if group is used by any synced profile
if !enabled && is_group_used_by_synced_profile(&group_id) { 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 enabling, check that sync settings are configured
if enabled { if enabled {
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await; ensure_sync_configured(&app_handle).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());
}
}
} }
let mut updated_group = group.clone(); 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(); let group_manager = crate::group_manager::GROUP_MANAGER.lock().unwrap();
if let Err(e) = group_manager.update_group_internal(&updated_group) { 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(); let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
storage storage
.load_config(&vpn_id) .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 disabling, check if VPN is used by any synced profile
if !enabled && is_vpn_used_by_synced_profile(&vpn_id) { 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 enabling, check that sync settings are configured
if enabled { if enabled {
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await; ensure_sync_configured(&app_handle).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());
}
}
} }
let last_sync = if enabled { vpn.last_sync } else { None }; 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(); let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
storage storage
.update_sync_fields(&vpn_id, enabled, last_sync) .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", ()); let _ = events::emit("vpn-configs-changed", ());
@@ -3526,48 +3662,10 @@ pub fn get_unsynced_entity_counts() -> Result<UnsyncedEntityCounts, String> {
#[tauri::command] #[tauri::command]
pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Result<(), String> { 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 // Intentionally excludes profiles: enabling profile sync uploads the entire
// groups/proxies/vpns syncing while their profiles stay local-only — the // browser data dir per profile, which is destructive if the user expected
// long-standing source of issue #352. Encrypted mode wins when an E2E // an opt-in. Profile sync stays under explicit per-profile control via
// password is already configured; otherwise we fall back to plain Regular. // set_profile_sync_mode. This command only touches metadata-sized entities.
{
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
);
}
}
}
// Enable sync for all unsynced proxies // 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(); let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
manager manager
.get_extension(&extension_id) .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 { if enabled {
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await; ensure_sync_configured(&app_handle).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());
}
}
} }
let mut updated_ext = ext; 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(); let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
manager manager
.update_extension_internal(&updated_ext) .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", ()); 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(); let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
manager manager
.get_group(&extension_group_id) .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 { if enabled {
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await; ensure_sync_configured(&app_handle).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());
}
}
} }
let mut updated_group = group; 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(); let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
manager manager.update_group_internal(&updated_group).map_err(|e| {
.update_group_internal(&updated_group) serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } })
.map_err(|e| format!("Failed to update extension group sync: {e}"))?; .to_string()
})?;
} }
let _ = events::emit("extensions-changed", ()); let _ = events::emit("extensions-changed", ());
+10
View File
@@ -35,6 +35,16 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
"**/startupCache/**", "**/startupCache/**",
"**/safebrowsing/**", "**/safebrowsing/**",
"**/storage/temporary/**", "**/storage/temporary/**",
"**/storage/default/*/cache/**",
"**/datareporting/**",
"**/saved-telemetry-pings/**",
"**/sessionstore-backups/**",
"**/sessions/**",
"**/serviceworker.txt",
"**/AlternateServices.bin",
"**/SiteSecurityServiceState.bin",
"**/favicons.sqlite",
"**/favicons.sqlite-*",
"**/crashes/**", "**/crashes/**",
"**/minidumps/**", "**/minidumps/**",
"*.tmp", "*.tmp",
+3 -3
View File
@@ -11,9 +11,9 @@ pub use encryption::{
check_has_e2e_password, delete_e2e_password, set_e2e_password, verify_e2e_password, check_has_e2e_password, delete_e2e_password, set_e2e_password, verify_e2e_password,
}; };
pub use engine::{ pub use engine::{
enable_extension_group_sync_if_needed, enable_group_sync_if_needed, enable_proxy_sync_if_needed, cancel_profile_sync, enable_extension_group_sync_if_needed, enable_group_sync_if_needed,
enable_sync_for_all_entities, enable_vpn_sync_if_needed, get_unsynced_entity_counts, enable_proxy_sync_if_needed, enable_sync_for_all_entities, enable_vpn_sync_if_needed,
is_group_in_use_by_synced_profile, is_group_used_by_synced_profile, 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_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, 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, rollover_encryption_for_all_entities, set_extension_group_sync_enabled,
+10 -3
View File
@@ -716,16 +716,18 @@ impl SyncScheduler {
match entity_type.as_str() { match entity_type.as_str() {
"profile" => { "profile" => {
let profile_manager = ProfileManager::instance(); let profile_manager = ProfileManager::instance();
let has_profile = { let local_sync_enabled = {
if let Ok(profiles) = profile_manager.list_profiles() { if let Ok(profiles) = profile_manager.list_profiles() {
let profile_uuid = uuid::Uuid::parse_str(&entity_id).ok(); 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 { } else {
false false
} }
}; };
if has_profile { if local_sync_enabled {
log::info!( log::info!(
"Profile {} was deleted remotely, deleting locally", "Profile {} was deleted remotely, deleting locally",
entity_id entity_id
@@ -733,6 +735,11 @@ impl SyncScheduler {
if let Err(e) = profile_manager.delete_profile_local_only(&entity_id) { if let Err(e) = profile_manager.delete_profile_local_only(&entity_id) {
log::warn!("Failed to delete tombstoned profile {}: {}", entity_id, e); 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" => { "proxy" => {
+2
View File
@@ -166,6 +166,7 @@ pub enum SyncError {
SerializationError(String), SerializationError(String),
ConflictError(String), ConflictError(String),
InvalidData(String), InvalidData(String),
Cancelled,
} }
impl std::fmt::Display for SyncError { 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::SerializationError(msg) => write!(f, "Serialization error: {msg}"),
SyncError::ConflictError(msg) => write!(f, "Conflict error: {msg}"), SyncError::ConflictError(msg) => write!(f, "Conflict error: {msg}"),
SyncError::InvalidData(msg) => write!(f, "Invalid data: {msg}"), SyncError::InvalidData(msg) => write!(f, "Invalid data: {msg}"),
SyncError::Cancelled => write!(f, "Sync cancelled by user"),
} }
} }
} }
+1 -1
View File
@@ -1174,7 +1174,7 @@ export default function Home() {
failed_count: payload.failed_count ?? 0, failed_count: payload.failed_count ?? 0,
phase: payload.phase, phase: payload.phase,
}, },
{ id: toastId }, { id: toastId, profileId: payload.profile_id },
); );
} }
}); });
@@ -42,7 +42,7 @@ export function DeleteConfirmationDialog({
return ( return (
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>{title}</DialogTitle> <DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription> <DialogDescription>{description}</DialogDescription>
+29 -8
View File
@@ -73,6 +73,7 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import type { Extension, ExtensionGroup } from "@/types"; import type { Extension, ExtensionGroup } from "@/types";
import { DeleteConfirmationDialog } from "./delete-confirmation-dialog"; import { DeleteConfirmationDialog } from "./delete-confirmation-dialog";
@@ -308,7 +309,11 @@ export function ExtensionManagementDialog({
); );
void loadData(); void loadData();
} catch (err) { } catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err)); showErrorToast(
parseBackendError(err)
? translateBackendError(t, err)
: t("proxies.management.updateSyncFailed"),
);
} finally { } finally {
setIsTogglingExtSync((prev) => ({ ...prev, [ext.id]: false })); setIsTogglingExtSync((prev) => ({ ...prev, [ext.id]: false }));
} }
@@ -331,7 +336,11 @@ export function ExtensionManagementDialog({
); );
void loadData(); void loadData();
} catch (err) { } catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err)); showErrorToast(
parseBackendError(err)
? translateBackendError(t, err)
: t("proxies.management.updateSyncFailed"),
);
} finally { } finally {
setIsTogglingGroupSync((prev) => ({ ...prev, [group.id]: false })); setIsTogglingGroupSync((prev) => ({ ...prev, [group.id]: false }));
} }
@@ -589,9 +598,15 @@ export function ExtensionManagementDialog({
}), }),
), ),
); );
const failed = results.filter((r) => r.status === "rejected").length; const firstRejection = results.find((r) => r.status === "rejected") as
if (failed > 0) { | PromiseRejectedResult
showErrorToast(t("proxies.management.updateSyncFailed")); | undefined;
if (firstRejection) {
showErrorToast(
parseBackendError(firstRejection.reason)
? translateBackendError(t, firstRejection.reason)
: t("proxies.management.updateSyncFailed"),
);
} else { } else {
showSuccessToast( showSuccessToast(
targetEnabled targetEnabled
@@ -614,9 +629,15 @@ export function ExtensionManagementDialog({
}), }),
), ),
); );
const failed = results.filter((r) => r.status === "rejected").length; const firstRejection = results.find((r) => r.status === "rejected") as
if (failed > 0) { | PromiseRejectedResult
showErrorToast(t("proxies.management.updateSyncFailed")); | undefined;
if (firstRejection) {
showErrorToast(
parseBackendError(firstRejection.reason)
? translateBackendError(t, firstRejection.reason)
: t("proxies.management.updateSyncFailed"),
);
} else { } else {
showSuccessToast( showSuccessToast(
targetEnabled targetEnabled
+12 -5
View File
@@ -57,6 +57,7 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import type { GroupWithCount, ProfileGroup } from "@/types"; import type { GroupWithCount, ProfileGroup } from "@/types";
import { RippleButton } from "./ui/ripple"; import { RippleButton } from "./ui/ripple";
@@ -262,8 +263,8 @@ export function GroupManagementDialog({
} catch (error) { } catch (error) {
console.error("Failed to toggle sync:", error); console.error("Failed to toggle sync:", error);
showErrorToast( showErrorToast(
error instanceof Error parseBackendError(error)
? error.message ? translateBackendError(t, error)
: t("proxies.management.updateSyncFailed"), : t("proxies.management.updateSyncFailed"),
); );
} finally { } finally {
@@ -529,9 +530,15 @@ export function GroupManagementDialog({
}), }),
), ),
); );
const failed = results.filter((r) => r.status === "rejected").length; const firstRejection = results.find((r) => r.status === "rejected") as
if (failed > 0) { | PromiseRejectedResult
showErrorToast(t("proxies.management.updateSyncFailed")); | undefined;
if (firstRejection) {
showErrorToast(
parseBackendError(firstRejection.reason)
? translateBackendError(t, firstRejection.reason)
: t("proxies.management.updateSyncFailed"),
);
} else { } else {
showSuccessToast( showSuccessToast(
targetEnabled targetEnabled
+15 -2
View File
@@ -120,6 +120,7 @@ export function IntegrationsDialog({
const [isMcpStarting, setIsMcpStarting] = useState(false); const [isMcpStarting, setIsMcpStarting] = useState(false);
const [agents, setAgents] = useState<McpAgentInfo[]>([]); const [agents, setAgents] = useState<McpAgentInfo[]>([]);
const [busyAgentIds, setBusyAgentIds] = useState<Set<string>>(new Set()); const [busyAgentIds, setBusyAgentIds] = useState<Set<string>>(new Set());
const [apiPortDraft, setApiPortDraft] = useState<string>("10108");
const { termsAccepted } = useWayfernTerms(); const { termsAccepted } = useWayfernTerms();
@@ -127,6 +128,7 @@ export function IntegrationsDialog({
try { try {
const loaded = await invoke<AppSettings>("get_app_settings"); const loaded = await invoke<AppSettings>("get_app_settings");
setSettings(loaded); setSettings(loaded);
setApiPortDraft(String(loaded.api_port ?? ""));
} catch (e) { } catch (e) {
console.error("Failed to load settings:", e); console.error("Failed to load settings:", e);
} }
@@ -370,13 +372,24 @@ export function IntegrationsDialog({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input
type="number" type="number"
value={settings.api_port} value={apiPortDraft}
onChange={(e) => { onChange={(e) => {
setApiPortDraft(e.target.value);
const val = Number.parseInt(e.target.value, 10); 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 }); 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" className="w-24 font-mono"
min={1} min={1}
max={65535} max={65535}
+1 -1
View File
@@ -12,7 +12,7 @@ type Props = ButtonProps & {
export const LoadingButton = ({ isLoading, className, ...props }: Props) => { export const LoadingButton = ({ isLoading, className, ...props }: Props) => {
return ( return (
<UIButton <UIButton
className={cn("grid place-items-center", className)} className={cn("inline-flex items-center justify-center", className)}
{...props} {...props}
disabled={props.disabled || isLoading} disabled={props.disabled || isLoading}
> >
+24 -5
View File
@@ -582,8 +582,9 @@ function ProfileInfoLayout({
const deleteAction = findAction("delete"); const deleteAction = findAction("delete");
const fingerprintAction = findAction("fingerprint"); const fingerprintAction = findAction("fingerprint");
const cookiesAction = const cookiesManageAction = findAction("manage cookies");
findAction("manage cookies") ?? findAction("copy cookies"); const cookiesCopyAction = findAction("copy cookies");
const cookiesAction = cookiesManageAction ?? cookiesCopyAction;
const extensionAction = findAction("extension"); const extensionAction = findAction("extension");
const syncAction = findAction("sync"); const syncAction = findAction("sync");
const _launchHookAction = findAction("hook") ?? findAction("launch hook"); const _launchHookAction = findAction("hook") ?? findAction("launch hook");
@@ -905,6 +906,7 @@ function ProfileInfoLayout({
profile={profile} profile={profile}
isRunning={isRunning} isRunning={isRunning}
isDisabled={isDisabled} isDisabled={isDisabled}
onCopyCookies={cookiesCopyAction?.onClick}
t={t} t={t}
/> />
)} )}
@@ -1435,11 +1437,14 @@ function ExtensionsSectionInline({
function CookiesSectionInline({ function CookiesSectionInline({
profile, profile,
isRunning, isRunning,
isDisabled,
onCopyCookies,
t, t,
}: { }: {
profile: BrowserProfile; profile: BrowserProfile;
isRunning: boolean; isRunning: boolean;
isDisabled: boolean; isDisabled: boolean;
onCopyCookies?: () => void;
t: (key: string, options?: Record<string, unknown>) => string; t: (key: string, options?: Record<string, unknown>) => string;
}) { }) {
type CookieStats = { type CookieStats = {
@@ -1483,9 +1488,23 @@ function CookiesSectionInline({
return ( return (
<div className="flex flex-col gap-3 min-h-0 flex-1"> <div className="flex flex-col gap-3 min-h-0 flex-1">
<div className="flex items-center gap-2 text-sm font-semibold"> <div className="flex items-center justify-between gap-2">
<LuCookie className="size-4" /> <div className="flex items-center gap-2 text-sm font-semibold">
{t("profileInfo.sections.cookies")} <LuCookie className="size-4" />
{t("profileInfo.sections.cookies")}
</div>
{onCopyCookies && (
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5"
disabled={isDisabled}
onClick={onCopyCookies}
>
<LuCopy className="size-3.5" />
{t("profiles.actions.copyCookies")}
</Button>
)}
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t("profileInfo.sectionDesc.cookies")} {t("profileInfo.sectionDesc.cookies")}
+26 -13
View File
@@ -67,6 +67,7 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { useProxyEvents } from "@/hooks/use-proxy-events"; import { useProxyEvents } from "@/hooks/use-proxy-events";
import { useVpnEvents } from "@/hooks/use-vpn-events"; import { useVpnEvents } from "@/hooks/use-vpn-events";
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ProxyCheckResult, StoredProxy, VpnConfig } from "@/types"; import type { ProxyCheckResult, StoredProxy, VpnConfig } from "@/types";
@@ -394,8 +395,8 @@ export function ProxyManagementDialog({
} catch (error) { } catch (error) {
console.error("Failed to toggle sync:", error); console.error("Failed to toggle sync:", error);
showErrorToast( showErrorToast(
error instanceof Error parseBackendError(error)
? error.message ? translateBackendError(t, error)
: t("proxies.management.updateSyncFailed"), : t("proxies.management.updateSyncFailed"),
); );
} finally { } finally {
@@ -458,8 +459,8 @@ export function ProxyManagementDialog({
} catch (error) { } catch (error) {
console.error("Failed to toggle VPN sync:", error); console.error("Failed to toggle VPN sync:", error);
showErrorToast( showErrorToast(
error instanceof Error parseBackendError(error)
? error.message ? translateBackendError(t, error)
: t("proxies.management.updateSyncFailed"), : t("proxies.management.updateSyncFailed"),
); );
} finally { } finally {
@@ -1010,9 +1011,15 @@ export function ProxyManagementDialog({
}), }),
), ),
); );
const failed = results.filter((r) => r.status === "rejected").length; const firstRejection = results.find((r) => r.status === "rejected") as
if (failed > 0) { | PromiseRejectedResult
showErrorToast(t("proxies.management.updateSyncFailed")); | undefined;
if (firstRejection) {
showErrorToast(
parseBackendError(firstRejection.reason)
? translateBackendError(t, firstRejection.reason)
: t("proxies.management.updateSyncFailed"),
);
} else { } else {
showSuccessToast( showSuccessToast(
targetEnabled targetEnabled
@@ -1039,9 +1046,15 @@ export function ProxyManagementDialog({
}), }),
), ),
); );
const failed = results.filter((r) => r.status === "rejected").length; const firstRejection = results.find((r) => r.status === "rejected") as
if (failed > 0) { | PromiseRejectedResult
showErrorToast(t("vpns.management.updateSyncFailed")); | undefined;
if (firstRejection) {
showErrorToast(
parseBackendError(firstRejection.reason)
? translateBackendError(t, firstRejection.reason)
: t("proxies.management.updateSyncFailed"),
);
} else { } else {
showSuccessToast( showSuccessToast(
targetEnabled targetEnabled
@@ -1055,7 +1068,7 @@ export function ProxyManagementDialog({
return ( return (
<> <>
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}> <Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-[min(95vw,1600px)] max-h-[90vh] flex flex-col"> <DialogContent className="max-w-4xl max-h-[85vh] flex flex-col">
{!subPage && ( {!subPage && (
<DialogHeader> <DialogHeader>
<DialogTitle>{t("proxies.management.title")}</DialogTitle> <DialogTitle>{t("proxies.management.title")}</DialogTitle>
@@ -1170,7 +1183,7 @@ export function ProxyManagementDialog({
} as React.CSSProperties } as React.CSSProperties
} }
> >
<Table className="min-w-max"> <Table className="w-full">
<TableHeader className="sticky top-0 z-10 bg-background"> <TableHeader className="sticky top-0 z-10 bg-background">
{proxiesTable.getHeaderGroups().map((headerGroup) => ( {proxiesTable.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}> <TableRow key={headerGroup.id}>
@@ -1251,7 +1264,7 @@ export function ProxyManagementDialog({
} as React.CSSProperties } as React.CSSProperties
} }
> >
<Table className="min-w-max"> <Table className="w-full">
<TableHeader className="sticky top-0 z-10 bg-background"> <TableHeader className="sticky top-0 z-10 bg-background">
{vpnsTable.getHeaderGroups().map((headerGroup) => ( {vpnsTable.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}> <TableRow key={headerGroup.id}>
+1
View File
@@ -464,6 +464,7 @@ export function SettingsDialog({
| "fr" | "fr"
| "zh" | "zh"
| "ja" | "ja"
| "ko"
| "ru"), | "ru"),
); );
setOriginalLanguage(selectedLanguage); setOriginalLanguage(selectedLanguage);
+69 -20
View File
@@ -1,9 +1,12 @@
"use client"; "use client";
import { invoke } from "@tauri-apps/api/core"; 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 { 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 { LoadingButton } from "@/components/loading-button";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -19,6 +22,8 @@ interface UnsyncedEntityCounts {
proxies: number; proxies: number;
groups: number; groups: number;
vpns: number; vpns: number;
extensions: number;
extension_groups: number;
} }
interface SyncAllDialogProps { interface SyncAllDialogProps {
@@ -67,27 +72,55 @@ export function SyncAllDialog({ isOpen, onClose }: SyncAllDialogProps) {
} }
}, [onClose, t]); }, [onClose, t]);
const totalCount = const items = useMemo(() => {
(counts?.proxies ?? 0) + (counts?.groups ?? 0) + (counts?.vpns ?? 0); 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) { if (!isLoading && totalCount === 0) {
return null; 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 ( return (
<Dialog open={isOpen && totalCount > 0} onOpenChange={onClose}> <Dialog
open={isOpen && (isLoading || totalCount > 0)}
onOpenChange={onClose}
>
<DialogContent className="max-w-md"> <DialogContent className="max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>{t("syncAll.title")}</DialogTitle> <DialogTitle>{t("syncAll.title")}</DialogTitle>
@@ -99,10 +132,26 @@ export function SyncAllDialog({ isOpen, onClose }: SyncAllDialogProps) {
<div className="size-6 rounded-full border-2 border-current animate-spin border-t-transparent" /> <div className="size-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
</div> </div>
) : ( ) : (
<div className="py-4"> <div className="grid grid-cols-2 gap-2 py-2">
<p className="text-sm text-muted-foreground"> {items.map(({ key, count, label, Icon }) => (
{t("syncAll.itemsList", { items: parts.join(", ") })} <div
</p> key={key}
className="flex items-center gap-3 rounded-lg border border-border/60 bg-card/50 p-3 transition-colors hover:bg-card"
>
<div className="flex size-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
<Icon className="size-4" />
</div>
<div className="min-w-0 flex-1 text-sm font-medium truncate">
{label}
</div>
<Badge
variant="secondary"
className="shrink-0 tabular-nums px-2"
>
{count}
</Badge>
</div>
))}
</div> </div>
)} )}
+7 -9
View File
@@ -11,19 +11,18 @@ const MotionThumb = motion.create(SwitchPrimitive.Thumb);
type AnimatedSwitchProps = React.ComponentProps<typeof SwitchPrimitive.Root>; type AnimatedSwitchProps = React.ComponentProps<typeof SwitchPrimitive.Root>;
/** /**
* Toggle switch with a thumb that slides between the off (left) and on * Switch whose thumb actually slides between off and on. The Root flips
* (right) positions and squashes wider while pressed. Animated via Framer * its flex alignment on `data-state=checked`, which moves the Thumb's
* Motion — no layout shift when the parent's width changes, and the * layout box; Framer Motion's `layout` prop tweens between the two
* pressed state is purely visual so external onCheckedChange semantics * positions. The thumb also squashes wider while pressed.
* stay identical to a Radix Switch.
*/ */
function AnimatedSwitch({ className, ...props }: AnimatedSwitchProps) { function AnimatedSwitch({ className, ...props }: AnimatedSwitchProps) {
return ( return (
<SwitchPrimitive.Root <SwitchPrimitive.Root
data-slot="animated-switch" data-slot="animated-switch"
className={cn( className={cn(
"peer relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent", "peer relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center justify-start rounded-full border border-transparent px-[2px]",
"bg-input data-[state=checked]:bg-primary", "bg-input data-[state=checked]:bg-primary data-[state=checked]:justify-end",
"transition-colors duration-200 ease-out", "transition-colors duration-200 ease-out",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-50", "disabled:cursor-not-allowed disabled:opacity-50",
@@ -39,8 +38,7 @@ function AnimatedSwitch({ className, ...props }: AnimatedSwitchProps) {
)} )}
layout layout
transition={{ type: "spring", stiffness: 700, damping: 32, mass: 0.5 }} transition={{ type: "spring", stiffness: 700, damping: 32, mass: 0.5 }}
whileTap={{ width: 22 }} whileTap={{ width: 20 }}
style={{ marginLeft: 2, marginRight: 2 }}
/> />
</SwitchPrimitive.Root> </SwitchPrimitive.Root>
); );
+3
View File
@@ -5,6 +5,7 @@ import en from "./locales/en.json";
import es from "./locales/es.json"; import es from "./locales/es.json";
import fr from "./locales/fr.json"; import fr from "./locales/fr.json";
import ja from "./locales/ja.json"; import ja from "./locales/ja.json";
import ko from "./locales/ko.json";
import pt from "./locales/pt.json"; import pt from "./locales/pt.json";
import ru from "./locales/ru.json"; import ru from "./locales/ru.json";
import zh from "./locales/zh.json"; import zh from "./locales/zh.json";
@@ -16,6 +17,7 @@ export const SUPPORTED_LANGUAGES = [
{ code: "fr", name: "French", nativeName: "Français" }, { code: "fr", name: "French", nativeName: "Français" },
{ code: "zh", name: "Chinese", nativeName: "中文" }, { code: "zh", name: "Chinese", nativeName: "中文" },
{ code: "ja", name: "Japanese", nativeName: "日本語" }, { code: "ja", name: "Japanese", nativeName: "日本語" },
{ code: "ko", name: "Korean", nativeName: "한국어" },
{ code: "ru", name: "Russian", nativeName: "Русский" }, { code: "ru", name: "Russian", nativeName: "Русский" },
] as const; ] as const;
@@ -61,6 +63,7 @@ const resources = {
fr: { translation: fr }, fr: { translation: fr },
zh: { translation: zh }, zh: { translation: zh },
ja: { translation: ja }, ja: { translation: ja },
ko: { translation: ko },
ru: { translation: ru }, ru: { translation: ru },
}; };
+16 -8
View File
@@ -1070,16 +1070,16 @@
"syncAll": { "syncAll": {
"title": "Enable Sync for Existing Items", "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?", "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", "enableAll": "Enable All",
"skip": "Skip", "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": { "crossOs": {
"viewOnly": "This profile was created on {{os}} and is not supported on this system", "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.", "profileLocked": "Profile is locked. Enter the password first.",
"invalidProfileId": "Invalid profile id", "invalidProfileId": "Invalid profile id",
"passwordTooShort": "Password must be at least {{min}} characters", "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}}", "internal": "Something went wrong: {{detail}}",
"invalidLaunchHookUrl": "Invalid launch hook URL. Use a full http:// or https:// URL.", "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.", "cookieDbLocked": "Could not read cookies — the database is locked. Close the browser and try again.",
+16 -8
View File
@@ -1070,16 +1070,16 @@
"syncAll": { "syncAll": {
"title": "Activar sincronización para elementos existentes", "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?", "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", "enableAll": "Activar todos",
"skip": "Omitir", "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": { "crossOs": {
"viewOnly": "Este perfil fue creado en {{os}} y no es compatible con este sistema", "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.", "profileLocked": "El perfil está bloqueado. Introduce la contraseña primero.",
"invalidProfileId": "ID de perfil no válido", "invalidProfileId": "ID de perfil no válido",
"passwordTooShort": "La contraseña debe tener al menos {{min}} caracteres", "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}}", "internal": "Algo salió mal: {{detail}}",
"invalidLaunchHookUrl": "URL del hook de inicio no válida. Usa una URL completa http:// o https://.", "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.", "cookieDbLocked": "No se pudieron leer las cookies — la base de datos está bloqueada. Cierra el navegador e inténtalo de nuevo.",
+16 -8
View File
@@ -1070,16 +1070,16 @@
"syncAll": { "syncAll": {
"title": "Activer la synchronisation pour les éléments existants", "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 ?", "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", "enableAll": "Tout activer",
"skip": "Ignorer", "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": { "crossOs": {
"viewOnly": "Ce profil a été créé sur {{os}} et n'est pas pris en charge sur ce système", "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.", "profileLocked": "Le profil est verrouillé. Entrez d'abord le mot de passe.",
"invalidProfileId": "Identifiant de profil non valide", "invalidProfileId": "Identifiant de profil non valide",
"passwordTooShort": "Le mot de passe doit comporter au moins {{min}} caractères", "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}}", "internal": "Une erreur s'est produite : {{detail}}",
"invalidLaunchHookUrl": "URL du hook de lancement invalide. Utilisez une URL http:// ou https:// complète.", "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.", "cookieDbLocked": "Impossible de lire les cookies — la base de données est verrouillée. Fermez le navigateur et réessayez.",
+16 -8
View File
@@ -1070,16 +1070,16 @@
"syncAll": { "syncAll": {
"title": "既存アイテムの同期を有効にする", "title": "既存アイテムの同期を有効にする",
"description": "同期されていないアイテムがあります。すべての同期を有効にしますか?", "description": "同期されていないアイテムがあります。すべての同期を有効にしますか?",
"itemsList": "未同期アイテム: {{items}}",
"proxies": "{{count}}個のプロキシ",
"proxies_plural": "{{count}}個のプロキシ",
"groups": "{{count}}個のグループ",
"groups_plural": "{{count}}個のグループ",
"vpns": "{{count}}個のVPN",
"vpns_plural": "{{count}}個のVPN",
"enableAll": "すべて有効にする", "enableAll": "すべて有効にする",
"skip": "スキップ", "skip": "スキップ",
"success": "すべてのアイテムの同期が有効になりました" "success": "すべてのアイテムの同期が有効になりました",
"labels": {
"proxies": "プロキシ",
"vpns": "VPN",
"groups": "グループ",
"extensions": "拡張機能",
"extensionGroups": "拡張機能グループ"
}
}, },
"crossOs": { "crossOs": {
"viewOnly": "このプロファイルは{{os}}で作成されたもので、このシステムではサポートされていません", "viewOnly": "このプロファイルは{{os}}で作成されたもので、このシステムではサポートされていません",
@@ -1788,6 +1788,14 @@
"profileLocked": "プロファイルはロックされています。先にパスワードを入力してください。", "profileLocked": "プロファイルはロックされています。先にパスワードを入力してください。",
"invalidProfileId": "無効なプロファイルIDです", "invalidProfileId": "無効なプロファイルIDです",
"passwordTooShort": "パスワードは {{min}} 文字以上必要です", "passwordTooShort": "パスワードは {{min}} 文字以上必要です",
"proxyNotFound": "プロキシが見つかりません",
"groupNotFound": "グループが見つかりません",
"vpnNotFound": "VPNが見つかりません",
"extensionNotFound": "拡張機能が見つかりません",
"extensionGroupNotFound": "拡張機能グループが見つかりません",
"cannotModifyCloudManagedProxy": "クラウド管理のプロキシの同期は変更できません",
"syncLockedByProfile": "同期済みプロファイルで使用中のため、同期を無効にできません",
"syncNotConfigured": "同期が設定されていません。サインインするか、セルフホストサーバーを設定してください。",
"internal": "問題が発生しました: {{detail}}", "internal": "問題が発生しました: {{detail}}",
"invalidLaunchHookUrl": "起動フックURLが無効です。完全な http:// または https:// URL を使用してください。", "invalidLaunchHookUrl": "起動フックURLが無効です。完全な http:// または https:// URL を使用してください。",
"cookieDbLocked": "Cookie を読み取れません — データベースがロックされています。ブラウザを閉じてから再試行してください。", "cookieDbLocked": "Cookie を読み取れません — データベースがロックされています。ブラウザを閉じてから再試行してください。",
File diff suppressed because it is too large. Load diff
+16 -8
View File
@@ -1070,16 +1070,16 @@
"syncAll": { "syncAll": {
"title": "Ativar sincronização para itens existentes", "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?", "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", "enableAll": "Ativar todos",
"skip": "Pular", "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": { "crossOs": {
"viewOnly": "Este perfil foi criado em {{os}} e não é compatível com este sistema", "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.", "profileLocked": "O perfil está bloqueado. Digite a senha primeiro.",
"invalidProfileId": "ID de perfil inválido", "invalidProfileId": "ID de perfil inválido",
"passwordTooShort": "A senha deve ter pelo menos {{min}} caracteres", "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}}", "internal": "Algo deu errado: {{detail}}",
"invalidLaunchHookUrl": "URL do hook de inicialização inválida. Use uma URL completa http:// ou https://.", "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.", "cookieDbLocked": "Não foi possível ler os cookies — o banco de dados está bloqueado. Feche o navegador e tente novamente.",
+16 -8
View File
@@ -1070,16 +1070,16 @@
"syncAll": { "syncAll": {
"title": "Включить синхронизацию для существующих элементов", "title": "Включить синхронизацию для существующих элементов",
"description": "У вас есть элементы, которые не синхронизируются. Хотите включить синхронизацию для всех?", "description": "У вас есть элементы, которые не синхронизируются. Хотите включить синхронизацию для всех?",
"itemsList": "Несинхронизированные элементы: {{items}}",
"proxies": "{{count}} прокси",
"proxies_plural": "{{count}} прокси",
"groups": "{{count}} группа",
"groups_plural": "{{count}} групп",
"vpns": "{{count}} VPN",
"vpns_plural": "{{count}} VPN",
"enableAll": "Включить все", "enableAll": "Включить все",
"skip": "Пропустить", "skip": "Пропустить",
"success": "Синхронизация включена для всех элементов" "success": "Синхронизация включена для всех элементов",
"labels": {
"proxies": "Прокси",
"vpns": "VPN",
"groups": "Группы",
"extensions": "Расширения",
"extensionGroups": "Группы расширений"
}
}, },
"crossOs": { "crossOs": {
"viewOnly": "Этот профиль был создан на {{os}} и не поддерживается в этой системе", "viewOnly": "Этот профиль был создан на {{os}} и не поддерживается в этой системе",
@@ -1788,6 +1788,14 @@
"profileLocked": "Профиль заблокирован. Сначала введите пароль.", "profileLocked": "Профиль заблокирован. Сначала введите пароль.",
"invalidProfileId": "Недействительный идентификатор профиля", "invalidProfileId": "Недействительный идентификатор профиля",
"passwordTooShort": "Пароль должен быть не короче {{min}} символов", "passwordTooShort": "Пароль должен быть не короче {{min}} символов",
"proxyNotFound": "Прокси не найден",
"groupNotFound": "Группа не найдена",
"vpnNotFound": "VPN не найден",
"extensionNotFound": "Расширение не найдено",
"extensionGroupNotFound": "Группа расширений не найдена",
"cannotModifyCloudManagedProxy": "Невозможно изменить синхронизацию для облачного прокси",
"syncLockedByProfile": "Невозможно отключить синхронизацию, пока используется синхронизированными профилями",
"syncNotConfigured": "Синхронизация не настроена. Войдите или настройте собственный сервер.",
"internal": "Что-то пошло не так: {{detail}}", "internal": "Что-то пошло не так: {{detail}}",
"invalidLaunchHookUrl": "Неверный URL хука запуска. Используйте полный URL http:// или https://.", "invalidLaunchHookUrl": "Неверный URL хука запуска. Используйте полный URL http:// или https://.",
"cookieDbLocked": "Не удалось прочитать куки — база данных заблокирована. Закройте браузер и попробуйте снова.", "cookieDbLocked": "Не удалось прочитать куки — база данных заблокирована. Закройте браузер и попробуйте снова.",
+16 -8
View File
@@ -1070,16 +1070,16 @@
"syncAll": { "syncAll": {
"title": "为现有项目启用同步", "title": "为现有项目启用同步",
"description": "您有未同步的项目。是否要为所有项目启用同步?", "description": "您有未同步的项目。是否要为所有项目启用同步?",
"itemsList": "未同步项目: {{items}}",
"proxies": "{{count}} 个代理",
"proxies_plural": "{{count}} 个代理",
"groups": "{{count}} 个分组",
"groups_plural": "{{count}} 个分组",
"vpns": "{{count}} 个 VPN",
"vpns_plural": "{{count}} 个 VPN",
"enableAll": "全部启用", "enableAll": "全部启用",
"skip": "跳过", "skip": "跳过",
"success": "已为所有项目启用同步" "success": "已为所有项目启用同步",
"labels": {
"proxies": "代理",
"vpns": "VPN",
"groups": "分组",
"extensions": "扩展",
"extensionGroups": "扩展分组"
}
}, },
"crossOs": { "crossOs": {
"viewOnly": "此配置文件在 {{os}} 上创建,不受此系统支持", "viewOnly": "此配置文件在 {{os}} 上创建,不受此系统支持",
@@ -1788,6 +1788,14 @@
"profileLocked": "配置文件已锁定。请先输入密码。", "profileLocked": "配置文件已锁定。请先输入密码。",
"invalidProfileId": "配置文件 ID 无效", "invalidProfileId": "配置文件 ID 无效",
"passwordTooShort": "密码至少需要 {{min}} 个字符", "passwordTooShort": "密码至少需要 {{min}} 个字符",
"proxyNotFound": "未找到代理",
"groupNotFound": "未找到分组",
"vpnNotFound": "未找到 VPN",
"extensionNotFound": "未找到扩展",
"extensionGroupNotFound": "未找到扩展分组",
"cannotModifyCloudManagedProxy": "无法修改云管理代理的同步",
"syncLockedByProfile": "在被已同步的配置文件使用时无法禁用同步",
"syncNotConfigured": "同步未配置。请先登录或配置自托管服务器。",
"internal": "出现问题:{{detail}}", "internal": "出现问题:{{detail}}",
"invalidLaunchHookUrl": "启动钩子 URL 无效。请使用完整的 http:// 或 https:// URL。", "invalidLaunchHookUrl": "启动钩子 URL 无效。请使用完整的 http:// 或 https:// URL。",
"cookieDbLocked": "无法读取 Cookie — 数据库已锁定。请关闭浏览器后重试。", "cookieDbLocked": "无法读取 Cookie — 数据库已锁定。请关闭浏览器后重试。",
+24
View File
@@ -20,6 +20,14 @@ export type BackendErrorCode =
| "COOKIE_DB_LOCKED" | "COOKIE_DB_LOCKED"
| "COOKIE_DB_UNAVAILABLE" | "COOKIE_DB_UNAVAILABLE"
| "SELF_HOSTED_REQUIRES_LOGOUT" | "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"; | "INTERNAL_ERROR";
export interface BackendError { export interface BackendError {
@@ -96,6 +104,22 @@ export function translateBackendError(t: TFunction, err: unknown): string {
return t("backendErrors.cookieDbUnavailable"); return t("backendErrors.cookieDbUnavailable");
case "SELF_HOSTED_REQUIRES_LOGOUT": case "SELF_HOSTED_REQUIRES_LOGOUT":
return t("backendErrors.selfHostedRequiresLogout"); 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": case "INTERNAL_ERROR":
return t("backendErrors.internal", { return t("backendErrors.internal", {
detail: parsed.params?.detail ?? "", detail: parsed.params?.detail ?? "",
+11 -1
View File
@@ -1,3 +1,4 @@
import { invoke } from "@tauri-apps/api/core";
import React from "react"; import React from "react";
import { type ExternalToast, toast as sonnerToast } from "sonner"; import { type ExternalToast, toast as sonnerToast } from "sonner";
import { UnifiedToast } from "@/components/custom-toast"; import { UnifiedToast } from "@/components/custom-toast";
@@ -259,7 +260,7 @@ export function showSyncProgressToast(
failed_count: number; failed_count: number;
phase: string; phase: string;
}, },
options?: { id?: string }, options?: { id?: string; profileId?: string },
) { ) {
return showToast({ return showToast({
type: "sync-progress", type: "sync-progress",
@@ -268,6 +269,15 @@ export function showSyncProgressToast(
id: options?.id, id: options?.id,
duration: Number.POSITIVE_INFINITY, duration: Number.POSITIVE_INFINITY,
onCancel: () => { 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) { if (options?.id) {
dismissToast(options.id); dismissToast(options.id);
} }