refactor: cleanup, korean translation

This commit is contained in:
zhom
2026-05-23 14:05:00 +04:00
parent 375530e358
commit 69da467ce0
31 changed files with 2573 additions and 307 deletions
+16
View File
@@ -87,6 +87,8 @@ pub struct UpdateProfileRequest {
pub tags: Option<Vec<String>>,
pub extension_group_id: Option<String>,
pub proxy_bypass_rules: Option<Vec<String>>,
/// One of "Disabled", "Regular", "Encrypted".
pub sync_mode: Option<String>,
}
#[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
}
+36 -8
View File
@@ -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
}
}
+2 -1
View File
@@ -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,
+1 -1
View File
@@ -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<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)]
pub window_resize_warning_dismissed: bool,
#[serde(default)]
+241 -169
View File
@@ -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<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
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<AtomicBool>,
) -> 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<AtomicBool>,
) -> 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<UnsyncedEntityCounts, String> {
#[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", ());
+10
View File
@@ -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",
+3 -3
View File
@@ -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,
+10 -3
View File
@@ -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" => {
+2
View File
@@ -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"),
}
}
}
+1 -1
View File
@@ -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 },
);
}
});
@@ -42,7 +42,7 @@ export function DeleteConfirmationDialog({
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
+29 -8
View File
@@ -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
+12 -5
View File
@@ -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
+15 -2
View File
@@ -120,6 +120,7 @@ export function IntegrationsDialog({
const [isMcpStarting, setIsMcpStarting] = useState(false);
const [agents, setAgents] = useState<McpAgentInfo[]>([]);
const [busyAgentIds, setBusyAgentIds] = useState<Set<string>>(new Set());
const [apiPortDraft, setApiPortDraft] = useState<string>("10108");
const { termsAccepted } = useWayfernTerms();
@@ -127,6 +128,7 @@ export function IntegrationsDialog({
try {
const loaded = await invoke<AppSettings>("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({
<div className="flex items-center gap-2">
<Input
type="number"
value={settings.api_port}
value={apiPortDraft}
onChange={(e) => {
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}
+1 -1
View File
@@ -12,7 +12,7 @@ type Props = ButtonProps & {
export const LoadingButton = ({ isLoading, className, ...props }: Props) => {
return (
<UIButton
className={cn("grid place-items-center", className)}
className={cn("inline-flex items-center justify-center", className)}
{...props}
disabled={props.disabled || isLoading}
>
+24 -5
View File
@@ -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, unknown>) => string;
}) {
type CookieStats = {
@@ -1483,9 +1488,23 @@ function CookiesSectionInline({
return (
<div className="flex flex-col gap-3 min-h-0 flex-1">
<div className="flex items-center gap-2 text-sm font-semibold">
<LuCookie className="size-4" />
{t("profileInfo.sections.cookies")}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm font-semibold">
<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>
<p className="text-xs text-muted-foreground">
{t("profileInfo.sectionDesc.cookies")}
+26 -13
View File
@@ -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 (
<>
<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 && (
<DialogHeader>
<DialogTitle>{t("proxies.management.title")}</DialogTitle>
@@ -1170,7 +1183,7 @@ export function ProxyManagementDialog({
} as React.CSSProperties
}
>
<Table className="min-w-max">
<Table className="w-full">
<TableHeader className="sticky top-0 z-10 bg-background">
{proxiesTable.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
@@ -1251,7 +1264,7 @@ export function ProxyManagementDialog({
} as React.CSSProperties
}
>
<Table className="min-w-max">
<Table className="w-full">
<TableHeader className="sticky top-0 z-10 bg-background">
{vpnsTable.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
+1
View File
@@ -464,6 +464,7 @@ export function SettingsDialog({
| "fr"
| "zh"
| "ja"
| "ko"
| "ru"),
);
setOriginalLanguage(selectedLanguage);
+69 -20
View File
@@ -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 (
<Dialog open={isOpen && totalCount > 0} onOpenChange={onClose}>
<Dialog
open={isOpen && (isLoading || totalCount > 0)}
onOpenChange={onClose}
>
<DialogContent className="max-w-md">
<DialogHeader>
<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>
) : (
<div className="py-4">
<p className="text-sm text-muted-foreground">
{t("syncAll.itemsList", { items: parts.join(", ") })}
</p>
<div className="grid grid-cols-2 gap-2 py-2">
{items.map(({ key, count, label, Icon }) => (
<div
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>
)}
+7 -9
View File
@@ -11,19 +11,18 @@ const MotionThumb = motion.create(SwitchPrimitive.Thumb);
type AnimatedSwitchProps = React.ComponentProps<typeof SwitchPrimitive.Root>;
/**
* 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 (
<SwitchPrimitive.Root
data-slot="animated-switch"
className={cn(
"peer relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent",
"bg-input data-[state=checked]:bg-primary",
"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 data-[state=checked]:justify-end",
"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",
"disabled:cursor-not-allowed disabled:opacity-50",
@@ -39,8 +38,7 @@ function AnimatedSwitch({ className, ...props }: AnimatedSwitchProps) {
)}
layout
transition={{ type: "spring", stiffness: 700, damping: 32, mass: 0.5 }}
whileTap={{ width: 22 }}
style={{ marginLeft: 2, marginRight: 2 }}
whileTap={{ width: 20 }}
/>
</SwitchPrimitive.Root>
);
+3
View File
@@ -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 },
};
+16 -8
View File
@@ -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.",
+16 -8
View File
@@ -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.",
+16 -8
View File
@@ -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.",
+16 -8
View File
@@ -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 を読み取れません — データベースがロックされています。ブラウザを閉じてから再試行してください。",
File diff suppressed because it is too large Load Diff
+16 -8
View File
@@ -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.",
+16 -8
View File
@@ -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": "Не удалось прочитать куки — база данных заблокирована. Закройте браузер и попробуйте снова.",
+16 -8
View File
@@ -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 — 数据库已锁定。请关闭浏览器后重试。",
+24
View File
@@ -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 ?? "",
+11 -1
View File
@@ -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);
}