mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-05-26 01:57:48 +02:00
refactor: cleanup, korean translation
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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", ());
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -464,6 +464,7 @@ export function SettingsDialog({
|
||||
| "fr"
|
||||
| "zh"
|
||||
| "ja"
|
||||
| "ko"
|
||||
| "ru"),
|
||||
);
|
||||
setOriginalLanguage(selectedLanguage);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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": "Не удалось прочитать куки — база данных заблокирована. Закройте браузер и попробуйте снова.",
|
||||
|
||||
@@ -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 — 数据库已锁定。请关闭浏览器后重试。",
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user