From e6cb4e6082febbcb9eb1489728360d4bb03897ad Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Tue, 24 Feb 2026 05:51:48 +0400 Subject: [PATCH] feat: e2e encrypted sync --- next-env.d.ts | 2 +- src-tauri/Cargo.lock | 47 +- src-tauri/Cargo.toml | 5 +- src-tauri/src/auto_updater.rs | 12 +- src-tauri/src/bin/donut_daemon.rs | 55 +- src-tauri/src/browser_runner.rs | 11 - src-tauri/src/camoufox_manager.rs | 28 +- src-tauri/src/cloud_auth.rs | 6 + src-tauri/src/daemon/tray.rs | 95 +- src-tauri/src/daemon_spawn.rs | 36 +- src-tauri/src/downloader.rs | 34 +- src-tauri/src/ephemeral_dirs.rs | 214 ++- src-tauri/src/extraction.rs | 9 + src-tauri/src/group_manager.rs | 31 +- src-tauri/src/lib.rs | 139 +- src-tauri/src/mcp_server.rs | 248 ++- src-tauri/src/platform_browser.rs | 3 + src-tauri/src/profile/manager.rs | 24 +- src-tauri/src/profile/types.rs | 22 +- src-tauri/src/profile_importer.rs | 3 +- src-tauri/src/proxy_manager.rs | 25 +- src-tauri/src/proxy_runner.rs | 3 + src-tauri/src/settings_manager.rs | 34 + src-tauri/src/sync/encryption.rs | 351 ++++ src-tauri/src/sync/engine.rs | 324 +++- src-tauri/src/sync/manifest.rs | 24 + src-tauri/src/sync/mod.rs | 9 +- src-tauri/src/sync/scheduler.rs | 98 +- src-tauri/src/vpn/openvpn.rs | 8 +- src-tauri/src/vpn/openvpn_socks5.rs | 8 +- src-tauri/src/vpn/storage.rs | 4 +- src-tauri/src/vpn_worker_runner.rs | 3 + src-tauri/src/wayfern_manager.rs | 123 +- src/app/page.tsx | 89 +- src/components/camoufox-config-dialog.tsx | 21 +- src/components/cookie-export-dialog.tsx | 129 -- src/components/cookie-import-dialog.tsx | 212 --- src/components/cookie-management-dialog.tsx | 649 +++++++ src/components/create-profile-dialog.tsx | 120 +- src/components/profile-data-table.tsx | 164 +- src/components/profile-sync-dialog.tsx | 185 +- src/components/settings-dialog.tsx | 153 ++ .../shared-camoufox-config-form.tsx | 1640 ++++++++-------- src/components/sync-config-dialog.tsx | 55 +- src/components/wayfern-config-form.tsx | 1659 +++++++++-------- .../window-resize-warning-dialog.tsx | 80 + src/hooks/use-browser-state.ts | 42 +- src/i18n/locales/en.json | 160 +- src/i18n/locales/es.json | 160 +- src/i18n/locales/fr.json | 160 +- src/i18n/locales/ja.json | 160 +- src/i18n/locales/pt.json | 160 +- src/i18n/locales/ru.json | 160 +- src/i18n/locales/zh.json | 160 +- src/lib/browser-utils.ts | 15 +- src/types.ts | 9 +- 56 files changed, 5831 insertions(+), 2549 deletions(-) create mode 100644 src-tauri/src/sync/encryption.rs delete mode 100644 src/components/cookie-export-dialog.tsx delete mode 100644 src/components/cookie-import-dialog.tsx create mode 100644 src/components/cookie-management-dialog.tsx create mode 100644 src/components/window-resize-warning-dialog.tsx diff --git a/next-env.d.ts b/next-env.d.ts index b87975d..9edff1c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./dist/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1b882b1..7c0b7fc 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1636,7 +1636,6 @@ dependencies = [ "serde_json", "serde_yaml", "serial_test", - "single-instance", "smoltcp", "sys-locale", "sysinfo", @@ -1962,7 +1961,7 @@ version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" dependencies = [ - "memoffset 0.9.1", + "memoffset", "rustc_version", ] @@ -3577,15 +3576,6 @@ dependencies = [ "libc", ] -[[package]] -name = "memoffset" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" -dependencies = [ - "autocfg", -] - [[package]] name = "memoffset" version = "0.9.1" @@ -3741,19 +3731,6 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" -[[package]] -name = "nix" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f3790c00a0150112de0f4cd161e3d7fc4b2d8a5542ffc35f099a2562aecb35c" -dependencies = [ - "bitflags 1.3.2", - "cc", - "cfg-if", - "libc", - "memoffset 0.6.5", -] - [[package]] name = "nix" version = "0.25.1" @@ -5926,19 +5903,6 @@ dependencies = [ "log", ] -[[package]] -name = "single-instance" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4637485391f8545c9d3dbf60f9d9aab27a90c789a700999677583bcb17c8795d" -dependencies = [ - "libc", - "nix 0.23.2", - "thiserror 1.0.69", - "widestring", - "winapi", -] - [[package]] name = "siphasher" version = "0.3.11" @@ -6592,7 +6556,6 @@ dependencies = [ "serde", "serde_json", "tauri", - "tauri-plugin-deep-link", "thiserror 2.0.18", "tracing", "windows-sys 0.60.2", @@ -7221,7 +7184,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" dependencies = [ - "memoffset 0.9.1", + "memoffset", "tempfile", "winapi", ] @@ -7786,12 +7749,6 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" -[[package]] -name = "widestring" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c168940144dd21fd8046987c16a46a33d5fc84eec29ef9dcddc2ac9e31526b7c" - [[package]] name = "winapi" version = "0.3.9" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0f4fb24..34a2b74 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -40,6 +40,7 @@ tauri-plugin-opener = "2" tauri-plugin-fs = "2" tauri-plugin-shell = "2" tauri-plugin-deep-link = "2" +tauri-plugin-single-instance = "2" tauri-plugin-dialog = "2" tauri-plugin-macos-permissions = "2" tauri-plugin-log = "2" @@ -106,15 +107,11 @@ smoltcp = { version = "0.11", default-features = false, features = ["std", "medi tray-icon = "0.21" muda = "0.17" tao = "0.34" -single-instance = "0.3" image = "0.25" dirs = "6" crossbeam-channel = "0.5" sys-locale = "0.3" -[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies] -tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } - [target.'cfg(unix)'.dependencies] nix = { version = "0.31", features = ["signal", "process"] } diff --git a/src-tauri/src/auto_updater.rs b/src-tauri/src/auto_updater.rs index 97f0d30..1b735e5 100644 --- a/src-tauri/src/auto_updater.rs +++ b/src-tauri/src/auto_updater.rs @@ -362,15 +362,6 @@ impl AutoUpdater { Ok(updated_profiles) } - /// Check if browser is disabled due to ongoing update - pub fn is_browser_disabled( - &self, - browser: &str, - ) -> Result> { - let state = self.load_auto_update_state()?; - Ok(state.disabled_browsers.contains(browser)) - } - /// Dismiss update notification pub fn dismiss_update_notification( &self, @@ -519,7 +510,8 @@ mod tests { group_id: None, tags: Vec::new(), note: None, - sync_enabled: false, + sync_mode: crate::profile::types::SyncMode::Disabled, + encryption_salt: None, last_sync: None, host_os: None, ephemeral: false, diff --git a/src-tauri/src/bin/donut_daemon.rs b/src-tauri/src/bin/donut_daemon.rs index a3d1267..ea30d4c 100644 --- a/src-tauri/src/bin/donut_daemon.rs +++ b/src-tauri/src/bin/donut_daemon.rs @@ -16,12 +16,33 @@ use serde::{Deserialize, Serialize}; use tao::event::{Event, StartCause}; use tao::event_loop::{ControlFlow, EventLoopBuilder}; use tokio::runtime::Runtime; -use tray_icon::{MouseButton, TrayIcon, TrayIconEvent}; +use tray_icon::TrayIcon; +#[cfg(not(target_os = "macos"))] +use tray_icon::{MouseButton, TrayIconEvent}; use donutbrowser_lib::daemon::{autostart, services, tray}; static SHOULD_QUIT: AtomicBool = AtomicBool::new(false); +#[cfg(windows)] +fn win_process_exists(pid: u32) -> bool { + use std::ptr; + const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; + + extern "system" { + fn OpenProcess(dwDesiredAccess: u32, bInheritHandles: i32, dwProcessId: u32) -> *mut (); + fn CloseHandle(hObject: *mut ()) -> i32; + } + + let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; + if handle.is_null() || handle == ptr::null_mut() { + false + } else { + unsafe { CloseHandle(handle) }; + true + } +} + enum ServiceStatus { Ready { api_port: Option, @@ -257,15 +278,15 @@ fn run_daemon() { // Process menu events while let Ok(event) = menu_channel.try_recv() { - if event.id == tray_menu.open_item.id() { - tray::open_gui(); - } else if event.id == tray_menu.quit_item.id() { + if event.id == tray_menu.quit_item.id() { log::info!("[daemon] Quit requested"); SHOULD_QUIT.store(true, Ordering::SeqCst); } } // Handle tray icon click (left-click opens the app) + // On macOS, left-click already shows the menu, so don't also launch the GUI. + #[cfg(not(target_os = "macos"))] while let Ok(event) = TrayIconEvent::receiver().try_recv() { if let TrayIconEvent::Click { button: MouseButton::Left, @@ -278,15 +299,25 @@ fn run_daemon() { // Use swap to only run cleanup once if SHOULD_QUIT.swap(false, Ordering::SeqCst) { + // Remove tray icon from status bar immediately so the UI feels responsive + tray_icon = None; + tray::quit_gui(); let mut state = read_state(); state.daemon_pid = None; let _ = write_state(&state); log::info!("[daemon] Exiting"); - *control_flow = ControlFlow::Exit; + + // Use process::exit for immediate termination instead of ControlFlow::Exit. + // ControlFlow::Exit can delay because tao's macOS event loop defers exit, + // and dropping the tokio runtime blocks until all spawned tasks finish. + process::exit(0); } } + Event::Reopen { .. } => { + tray::open_gui(); + } _ => {} } @@ -305,7 +336,9 @@ fn stop_daemon() { // On Windows, taskkill /F kills instantly with no handler, so kill GUI first #[cfg(windows)] { + use std::os::windows::process::CommandExt; use std::process::Command; + const CREATE_NO_WINDOW: u32 = 0x08000000; let state_path = get_state_path(); if let Ok(content) = fs::read_to_string(&state_path) { @@ -313,6 +346,7 @@ fn stop_daemon() { if let Some(gui_pid) = val.get("gui_pid").and_then(|v| v.as_u64()) { let _ = Command::new("taskkill") .args(["/PID", &gui_pid.to_string(), "/F"]) + .creation_flags(CREATE_NO_WINDOW) .output(); } } @@ -320,6 +354,7 @@ fn stop_daemon() { let _ = Command::new("taskkill") .args(["/PID", &pid.to_string(), "/F"]) + .creation_flags(CREATE_NO_WINDOW) .output(); eprintln!("Sent stop signal to daemon (PID {})", pid); } @@ -344,15 +379,7 @@ fn show_status() { let is_running = unsafe { libc::kill(pid as i32, 0) == 0 }; #[cfg(windows)] - let is_running = { - use std::process::Command; - let output = Command::new("tasklist") - .args(["/FI", &format!("PID eq {}", pid)]) - .output(); - output - .map(|o| String::from_utf8_lossy(&o.stdout).contains(&pid.to_string())) - .unwrap_or(false) - }; + let is_running = win_process_exists(pid); #[cfg(not(any(unix, windows)))] let is_running = false; diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index c1ff3cb..5f88edd 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -80,17 +80,6 @@ impl BrowserRunner { remote_debugging_port: Option, headless: bool, ) -> Result> { - // Check if browser is disabled due to ongoing update - if self.auto_updater.is_browser_disabled(&profile.browser)? { - return Err( - format!( - "{} is currently being updated. Please wait for the update to complete.", - profile.browser - ) - .into(), - ); - } - // Handle Camoufox profiles using CamoufoxManager if profile.browser == "camoufox" { // Get or create camoufox config diff --git a/src-tauri/src/camoufox_manager.rs b/src-tauri/src/camoufox_manager.rs index 823f581..38beb78 100644 --- a/src-tauri/src/camoufox_manager.rs +++ b/src-tauri/src/camoufox_manager.rs @@ -366,8 +366,11 @@ impl CamoufoxManager { #[cfg(windows)] { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; let result = std::process::Command::new("taskkill") .args(["/PID", &pid.to_string(), "/T"]) + .creation_flags(CREATE_NO_WINDOW) .status(); match result { @@ -602,18 +605,23 @@ impl CamoufoxManager { // Clean up any dead instances before launching let _ = self.cleanup_dead_instances().await; - // For ephemeral profiles, write Firefox prefs to keep all data inside the profile dir + // For ephemeral profiles, write Firefox prefs to minimize disk writes if override_profile_path.is_some() { - let cache_dir = profile_path.join("cache2"); let user_js_path = profile_path.join("user.js"); - let prefs = format!( - concat!( - "user_pref(\"browser.cache.disk.parent_directory\", \"{}\");\n", - "user_pref(\"browser.cache.disk.enable\", false);\n", - "user_pref(\"browser.cache.memory.enable\", true);\n", - "user_pref(\"browser.privatebrowsing.autostart\", true);\n", - ), - cache_dir.to_string_lossy().replace('\\', "\\\\"), + let prefs = concat!( + "user_pref(\"browser.cache.disk.enable\", false);\n", + "user_pref(\"browser.cache.memory.enable\", true);\n", + "user_pref(\"browser.sessionstore.resume_from_crash\", false);\n", + "user_pref(\"browser.sessionstore.max_tabs_undo\", 0);\n", + "user_pref(\"browser.sessionstore.max_windows_undo\", 0);\n", + "user_pref(\"places.history.enabled\", false);\n", + "user_pref(\"browser.formfill.enable\", false);\n", + "user_pref(\"signon.rememberSignons\", false);\n", + "user_pref(\"browser.bookmarks.max_backups\", 0);\n", + "user_pref(\"browser.shell.checkDefaultBrowser\", false);\n", + "user_pref(\"toolkit.crashreporter.enabled\", false);\n", + "user_pref(\"browser.pagethumbnails.capturing_disabled\", true);\n", + "user_pref(\"browser.download.manager.addToRecentDocs\", false);\n", ); if let Err(e) = std::fs::write(&user_js_path, prefs) { log::warn!("Failed to write ephemeral user.js: {e}"); diff --git a/src-tauri/src/cloud_auth.rs b/src-tauri/src/cloud_auth.rs index 6af5c03..878acb7 100644 --- a/src-tauri/src/cloud_auth.rs +++ b/src-tauri/src/cloud_auth.rs @@ -1099,6 +1099,12 @@ pub async fn restart_sync_service(app_handle: tauri::AppHandle) -> Result<(), St { log::warn!("Failed to check for missing profiles: {}", e); } + if let Err(e) = engine + .check_for_missing_synced_entities(&app_handle_sync) + .await + { + log::warn!("Failed to check for missing entities: {}", e); + } } Err(e) => { log::debug!("Sync not configured, skipping missing profile check: {}", e); diff --git a/src-tauri/src/daemon/tray.rs b/src-tauri/src/daemon/tray.rs index 43781b1..b528858 100644 --- a/src-tauri/src/daemon/tray.rs +++ b/src-tauri/src/daemon/tray.rs @@ -1,9 +1,25 @@ -use muda::{Menu, MenuItem, PredefinedMenuItem}; +use muda::{Menu, MenuItem}; use std::process::Command; -use std::sync::atomic::{AtomicBool, Ordering}; use tray_icon::{Icon, TrayIcon, TrayIconBuilder}; -static GUI_RUNNING: AtomicBool = AtomicBool::new(false); +#[cfg(windows)] +fn win_process_exists(pid: u32) -> bool { + use std::ptr; + const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; + + extern "system" { + fn OpenProcess(dwDesiredAccess: u32, bInheritHandles: i32, dwProcessId: u32) -> *mut (); + fn CloseHandle(hObject: *mut ()) -> i32; + } + + let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; + if handle.is_null() || handle == ptr::null_mut() { + false + } else { + unsafe { CloseHandle(handle) }; + true + } +} pub fn load_icon() -> Icon { // On Windows, use the full-color icon so it renders well on dark taskbars. @@ -25,7 +41,6 @@ pub fn load_icon() -> Icon { pub struct TrayMenu { pub menu: Menu, - pub open_item: MenuItem, pub quit_item: MenuItem, } @@ -39,19 +54,11 @@ impl TrayMenu { pub fn new() -> Self { let menu = Menu::new(); - let open_item = MenuItem::new("Open Donut Browser", true, None); - let separator = PredefinedMenuItem::separator(); let quit_item = MenuItem::new("Quit Donut Browser", true, None); - menu.append(&open_item).unwrap(); - menu.append(&separator).unwrap(); menu.append(&quit_item).unwrap(); - Self { - menu, - open_item, - quit_item, - } + Self { menu, quit_item } } } @@ -68,25 +75,41 @@ pub fn create_tray_icon(icon: Icon, menu: &Menu) -> TrayIcon { builder.build().expect("Failed to create tray icon") } -pub fn open_gui() { - if GUI_RUNNING.load(Ordering::SeqCst) { - log::info!("GUI already running, activating..."); - activate_gui(); - return; +/// Resolve the .app bundle path from the current daemon executable. +/// In production the daemon is at `Donut.app/Contents/MacOS/donut-daemon`. +#[cfg(target_os = "macos")] +fn get_app_bundle_path() -> Option { + let exe = std::env::current_exe().ok()?; + let macos_dir = exe.parent()?; + let contents_dir = macos_dir.parent()?; + let app_dir = contents_dir.parent()?; + if app_dir.extension().and_then(|e| e.to_str()) == Some("app") { + Some(app_dir.to_path_buf()) + } else { + None } +} +pub fn open_gui() { log::info!("Opening GUI..."); + // On macOS, use `open` WITHOUT `-n`. The daemon runs with Accessory + // activation policy so macOS won't confuse it with the GUI process. + // `open` will either activate the existing GUI or launch a new one. + // Using `-n` would bypass the single-instance plugin entirely. #[cfg(target_os = "macos")] { - let _ = Command::new("open").arg("-a").arg("Donut Browser").spawn(); + if let Some(app_bundle) = get_app_bundle_path() { + let _ = Command::new("open").arg(&app_bundle).spawn(); + } else { + let _ = Command::new("open").arg("-a").arg("Donut").spawn(); + } } #[cfg(target_os = "windows")] { use std::path::PathBuf; - // In dev mode, find the main exe next to the daemon binary if let Ok(current_exe) = std::env::current_exe() { if let Some(exe_dir) = current_exe.parent() { let app_path = exe_dir.join("donutbrowser.exe"); @@ -118,15 +141,6 @@ pub fn open_gui() { } } -pub fn activate_gui() { - #[cfg(target_os = "macos")] - { - let _ = Command::new("osascript") - .args(["-e", "tell application \"Donut Browser\" to activate"]) - .spawn(); - } -} - fn read_gui_pid() -> Option { let path = super::autostart::get_data_dir()?.join("daemon-state.json"); let content = std::fs::read_to_string(path).ok()?; @@ -147,8 +161,11 @@ fn kill_gui_by_pid() -> bool { #[cfg(windows)] { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; Command::new("taskkill") .args(["/PID", &pid.to_string(), "/F"]) + .creation_flags(CREATE_NO_WINDOW) .output() .map(|o| o.status.success()) .unwrap_or(false) @@ -172,27 +189,29 @@ pub fn quit_gui() { #[cfg(target_os = "macos")] { + // Use spawn() instead of output() to avoid blocking the event loop. + // AppleScript has a ~2 minute default timeout that would freeze the tray icon. let _ = Command::new("osascript") - .args(["-e", "tell application \"Donut Browser\" to quit"]) - .output(); + .args(["-e", "tell application \"Donut\" to quit"]) + .spawn(); } #[cfg(target_os = "windows")] { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; let _ = Command::new("taskkill") .args(["/IM", "Donut.exe", "/F"]) - .output(); + .creation_flags(CREATE_NO_WINDOW) + .spawn(); let _ = Command::new("taskkill") .args(["/IM", "donutbrowser.exe", "/F"]) - .output(); + .creation_flags(CREATE_NO_WINDOW) + .spawn(); } #[cfg(target_os = "linux")] { - let _ = Command::new("pkill").args(["-x", "donutbrowser"]).output(); + let _ = Command::new("pkill").args(["-x", "donutbrowser"]).spawn(); } } - -pub fn set_gui_running(running: bool) { - GUI_RUNNING.store(running, Ordering::SeqCst); -} diff --git a/src-tauri/src/daemon_spawn.rs b/src-tauri/src/daemon_spawn.rs index 8cbde60..e55bc7e 100644 --- a/src-tauri/src/daemon_spawn.rs +++ b/src-tauri/src/daemon_spawn.rs @@ -9,6 +9,27 @@ use std::time::Duration; use crate::daemon::autostart; +/// Check if a process with the given PID exists using the Windows API. +/// This avoids spawning tasklist.exe which causes a visible conhost window flash. +#[cfg(windows)] +fn win_process_exists(pid: u32) -> bool { + use std::ptr; + const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; + + extern "system" { + fn OpenProcess(dwDesiredAccess: u32, bInheritHandles: i32, dwProcessId: u32) -> *mut (); + fn CloseHandle(hObject: *mut ()) -> i32; + } + + let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; + if handle.is_null() || handle == ptr::null_mut() { + false + } else { + unsafe { CloseHandle(handle) }; + true + } +} + #[derive(Debug, Deserialize, Default)] struct DaemonState { daemon_pid: Option, @@ -43,12 +64,7 @@ pub fn is_daemon_running() -> bool { #[cfg(windows)] { - let output = Command::new("tasklist") - .args(["/FI", &format!("PID eq {}", pid)]) - .output(); - output - .map(|o| String::from_utf8_lossy(&o.stdout).contains(&pid.to_string())) - .unwrap_or(false) + win_process_exists(pid) } #[cfg(not(any(unix, windows)))] @@ -113,7 +129,13 @@ fn get_daemon_path() -> Option { // Try to find it in PATH #[cfg(target_os = "windows")] { - if let Ok(output) = Command::new("where").arg("donut-daemon").output() { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + if let Ok(output) = Command::new("where") + .arg("donut-daemon") + .creation_flags(CREATE_NO_WINDOW) + .output() + { if output.status.success() { let path = String::from_utf8_lossy(&output.stdout); let path = path.lines().next()?.trim(); diff --git a/src-tauri/src/downloader.rs b/src-tauri/src/downloader.rs index d9db2f9..9e8f968 100644 --- a/src-tauri/src/downloader.rs +++ b/src-tauri/src/downloader.rs @@ -1033,8 +1033,8 @@ pub async fn cancel_download(browser_str: String, version: String) -> Result<(), } } -/// Clean up the fake "None" search engine from Camoufox policies.json so that -/// Camoufox's built-in fallback (DuckDuckGo when nothing else is configured) can work. +/// Set DuckDuckGo as the default search engine in Camoufox policies.json. +/// Removes the fake "None" search engine and explicitly sets DuckDuckGo as default. /// Called both at download time and at launch time to cover existing installations. pub fn configure_camoufox_search_engine( browser_dir: &Path, @@ -1055,45 +1055,35 @@ pub fn configure_camoufox_search_engine( .and_then(|d| d.as_str()) .unwrap_or(""); - if current_default != "None" { + if current_default == "DuckDuckGo" { return Ok(()); } - let mut changed = false; - if let Some(policies_obj) = policies.get_mut("policies") { if let Some(se) = policies_obj.get_mut("SearchEngines") { - // Remove the fake "None" default so Camoufox uses its built-in fallback + // Set DuckDuckGo as the explicit default if let Some(obj) = se.as_object_mut() { - obj.remove("Default"); - changed = true; + obj.insert( + "Default".to_string(), + serde_json::Value::String("DuckDuckGo".to_string()), + ); } // Remove the fake "None" search engine entry from Add if let Some(add_arr) = se.get_mut("Add").and_then(|a| a.as_array_mut()) { - let before = add_arr.len(); add_arr.retain(|entry| entry.get("Name").and_then(|n| n.as_str()) != Some("None")); - if add_arr.len() != before { - changed = true; - } } - // Ensure DuckDuckGo is not in the Remove list so it's available as fallback + // Ensure DuckDuckGo is not in the Remove list if let Some(remove_arr) = se.get_mut("Remove").and_then(|r| r.as_array_mut()) { - let before = remove_arr.len(); remove_arr.retain(|v| v.as_str() != Some("DuckDuckGo")); - if remove_arr.len() != before { - changed = true; - } } } } - if changed { - let updated = serde_json::to_string_pretty(&policies)?; - std::fs::write(&policies_path, updated)?; - log::info!("Cleaned up fake 'None' search engine from Camoufox policies.json"); - } + let updated = serde_json::to_string_pretty(&policies)?; + std::fs::write(&policies_path, updated)?; + log::info!("Set DuckDuckGo as default search engine in Camoufox policies.json"); Ok(()) } diff --git a/src-tauri/src/ephemeral_dirs.rs b/src-tauri/src/ephemeral_dirs.rs index 19e5305..74c7e8e 100644 --- a/src-tauri/src/ephemeral_dirs.rs +++ b/src-tauri/src/ephemeral_dirs.rs @@ -8,9 +8,132 @@ lazy_static::lazy_static! { static ref EPHEMERAL_DIRS: Mutex> = Mutex::new(HashMap::new()); } +/// Get or create the RAM-backed base directory for ephemeral profiles. +/// Linux: /dev/shm (always tmpfs). macOS: RAM disk via hdiutil. Windows: imdisk RAM disk. +fn get_ephemeral_base_dir() -> Result { + #[cfg(target_os = "linux")] + { + let base = PathBuf::from("/dev/shm/donut-ephemeral"); + std::fs::create_dir_all(&base) + .map_err(|e| format!("Failed to create ephemeral base in /dev/shm: {e}"))?; + return Ok(base); + } + + #[cfg(target_os = "macos")] + { + if let Ok(mount) = get_or_create_macos_ramdisk() { + return Ok(mount); + } + log::warn!("Failed to create macOS RAM disk, ephemeral profiles may use disk"); + } + + #[cfg(target_os = "windows")] + { + if let Ok(mount) = get_or_create_windows_ramdisk() { + return Ok(mount); + } + log::warn!("Failed to create Windows RAM disk, ephemeral profiles may use disk"); + } + + // Fallback + let base = std::env::temp_dir().join("donut-ephemeral"); + std::fs::create_dir_all(&base) + .map_err(|e| format!("Failed to create ephemeral base dir: {e}"))?; + Ok(base) +} + +#[cfg(target_os = "macos")] +fn get_or_create_macos_ramdisk() -> Result { + let mount_point = PathBuf::from("/Volumes/DonutEphemeral"); + + // Reuse existing RAM disk from a previous session + if mount_point.exists() && mount_point.is_dir() { + return Ok(mount_point); + } + + // 256 MB in 512-byte sectors + let sectors = 256 * 2048; + let output = std::process::Command::new("hdiutil") + .args(["attach", "-nomount", &format!("ram://{sectors}")]) + .output() + .map_err(|e| format!("hdiutil attach failed: {e}"))?; + + if !output.status.success() { + return Err(format!( + "hdiutil attach failed: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + + let dev = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + let fmt = std::process::Command::new("diskutil") + .args(["erasevolume", "HFS+", "DonutEphemeral", &dev]) + .output() + .map_err(|e| format!("diskutil erasevolume failed: {e}"))?; + + if !fmt.status.success() { + let _ = std::process::Command::new("hdiutil") + .args(["detach", &dev]) + .output(); + return Err(format!( + "diskutil erasevolume failed: {}", + String::from_utf8_lossy(&fmt.stderr) + )); + } + + log::info!("Created macOS RAM disk at {}", mount_point.display()); + Ok(mount_point) +} + +#[cfg(target_os = "windows")] +fn get_or_create_windows_ramdisk() -> Result { + // Check if a previous RAM disk with our directory already exists + for letter in ['R', 'Q', 'P', 'O'] { + let base = PathBuf::from(format!("{}:\\DonutEphemeral", letter)); + if base.exists() && base.is_dir() { + return Ok(base); + } + } + + // Try to create a RAM disk using imdisk (open-source RAM disk driver) + for letter in ['R', 'Q', 'P', 'O'] { + let drive = format!("{}:", letter); + if PathBuf::from(format!("{}\\", drive)).exists() { + continue; + } + + let output = std::process::Command::new("imdisk") + .args(["-a", "-s", "256M", "-m", &drive, "-p", "/fs:ntfs /q /y"]) + .output(); + + match output { + Ok(out) if out.status.success() => { + let base = PathBuf::from(format!("{}\\DonutEphemeral", drive)); + std::fs::create_dir_all(&base) + .map_err(|e| format!("Failed to create dir on RAM disk: {e}"))?; + log::info!("Created Windows RAM disk at {}", base.display()); + return Ok(base); + } + Ok(out) => { + log::debug!( + "imdisk failed for drive {}: {}", + drive, + String::from_utf8_lossy(&out.stderr) + ); + } + Err(e) => { + return Err(format!("imdisk not available: {e}")); + } + } + } + + Err("Could not create Windows RAM disk".to_string()) +} + pub fn create_ephemeral_dir(profile_id: &str) -> Result { - let dir_name = format!("donut-ephemeral-{profile_id}"); - let dir_path = std::env::temp_dir().join(dir_name); + let base = get_ephemeral_base_dir()?; + let dir_path = base.join(profile_id); std::fs::create_dir_all(&dir_path).map_err(|e| format!("Failed to create ephemeral dir: {e}"))?; @@ -53,26 +176,60 @@ pub fn remove_ephemeral_dir(profile_id: &str) { } } -pub fn cleanup_stale_dirs() { +/// Recover ephemeral dir mappings on startup by scanning the RAM-backed base dir. +/// Dir names are profile UUIDs, so we re-populate the in-memory HashMap. +/// Also cleans up old disk-based dirs from previous versions. +pub fn recover_ephemeral_dirs() { + cleanup_legacy_dirs(); + + let base = match get_ephemeral_base_dir() { + Ok(base) => base, + Err(e) => { + log::warn!("Cannot recover ephemeral dirs: {e}"); + return; + } + }; + + let entries = match std::fs::read_dir(&base) { + Ok(entries) => entries, + Err(_) => return, + }; + + let mut dirs = match EPHEMERAL_DIRS.lock() { + Ok(dirs) => dirs, + Err(_) => return, + }; + + for entry in entries.flatten() { + if entry.path().is_dir() { + if let Some(name) = entry.file_name().to_str() { + if uuid::Uuid::parse_str(name).is_ok() { + dirs.insert(name.to_string(), entry.path()); + log::info!("Recovered ephemeral dir for profile {}", name); + } + } + } + } +} + +/// Remove old-format ephemeral dirs from /tmp (pre-tmpfs migration). +fn cleanup_legacy_dirs() { let temp_dir = std::env::temp_dir(); let entries = match std::fs::read_dir(&temp_dir) { Ok(entries) => entries, - Err(e) => { - log::warn!("Failed to read temp dir for ephemeral cleanup: {e}"); - return; - } + Err(_) => return, }; for entry in entries.flatten() { if let Some(name) = entry.file_name().to_str() { if name.starts_with("donut-ephemeral-") && entry.path().is_dir() { if let Err(e) = std::fs::remove_dir_all(entry.path()) { - log::warn!( - "Failed to clean up stale ephemeral dir {}: {e}", + log::warn!("Failed to clean up legacy ephemeral dir: {e}"); + } else { + log::info!( + "Cleaned up legacy ephemeral dir: {}", entry.path().display() ); - } else { - log::info!("Cleaned up stale ephemeral dir: {}", entry.path().display()); } } } @@ -108,7 +265,8 @@ mod tests { group_id: None, tags: Vec::new(), note: None, - sync_enabled: false, + sync_mode: crate::profile::types::SyncMode::Disabled, + encryption_salt: None, last_sync: None, host_os: None, ephemeral, @@ -117,17 +275,13 @@ mod tests { #[test] fn test_ephemeral_dir_lifecycle() { - // Test create, get, effective path, remove, and cleanup all in sequence - // to avoid race conditions between parallel tests. - - // 1. Create and get let profile_id = uuid::Uuid::new_v4(); let id_str = profile_id.to_string(); + let dir = create_ephemeral_dir(&id_str).unwrap(); assert!(dir.is_dir()); assert_eq!(get_ephemeral_dir(&id_str), Some(dir.clone())); - // 2. Effective path for ephemeral profile returns ephemeral dir let ephemeral_profile = make_test_profile(profile_id, true); let profiles_dir = std::env::temp_dir().join("test_profiles_ephemeral"); assert_eq!( @@ -135,25 +289,33 @@ mod tests { dir ); - // 3. Remove cleans up dir and map entry remove_ephemeral_dir(&id_str); assert!(!dir.exists()); assert!(get_ephemeral_dir(&id_str).is_none()); - // 4. Effective path for persistent profile returns normal path let persistent_profile = make_test_profile(uuid::Uuid::new_v4(), false); let expected = persistent_profile.get_profile_data_path(&profiles_dir); assert_eq!( get_effective_profile_path(&persistent_profile, &profiles_dir), expected ); + } - // 5. Cleanup stale dirs - let stale_id = uuid::Uuid::new_v4().to_string(); - let stale_dir = std::env::temp_dir().join(format!("donut-ephemeral-{stale_id}")); - std::fs::create_dir_all(&stale_dir).unwrap(); - assert!(stale_dir.exists()); - cleanup_stale_dirs(); - assert!(!stale_dir.exists()); + #[test] + fn test_recover_ephemeral_dirs() { + let base = get_ephemeral_base_dir().unwrap(); + let test_id = uuid::Uuid::new_v4().to_string(); + let test_dir = base.join(&test_id); + std::fs::create_dir_all(&test_dir).unwrap(); + + // Clear the HashMap so recovery has something to find + EPHEMERAL_DIRS.lock().unwrap().remove(&test_id); + assert!(get_ephemeral_dir(&test_id).is_none()); + + recover_ephemeral_dirs(); + assert_eq!(get_ephemeral_dir(&test_id), Some(test_dir.clone())); + + // Clean up + remove_ephemeral_dir(&test_id); } } diff --git a/src-tauri/src/extraction.rs b/src-tauri/src/extraction.rs index f268c83..aa964a0 100644 --- a/src-tauri/src/extraction.rs +++ b/src-tauri/src/extraction.rs @@ -816,6 +816,15 @@ impl Extractor { if dirs.len() == 1 && !has_non_archive_files { let single_dir = &dirs[0]; + + if single_dir.extension().is_some_and(|ext| ext == "app") { + log::info!( + "Skipping flatten: {} is a macOS app bundle", + single_dir.display() + ); + return Ok(()); + } + log::info!( "Flattening single-directory archive: moving contents of {} to {}", single_dir.display(), diff --git a/src-tauri/src/group_manager.rs b/src-tauri/src/group_manager.rs index 625bcc6..f0128f7 100644 --- a/src-tauri/src/group_manager.rs +++ b/src-tauri/src/group_manager.rs @@ -84,7 +84,7 @@ impl GroupManager { return Err(format!("Group with name '{name}' already exists").into()); } - let sync_enabled = crate::cloud_auth::CLOUD_AUTH.has_active_paid_subscription_sync(); + let sync_enabled = crate::sync::is_sync_configured(); let group = ProfileGroup { id: uuid::Uuid::new_v4().to_string(), name, @@ -100,6 +100,15 @@ impl GroupManager { log::error!("Failed to emit groups-changed event: {e}"); } + if group.sync_enabled { + if let Some(scheduler) = crate::sync::get_global_scheduler() { + let id = group.id.clone(); + tauri::async_runtime::spawn(async move { + scheduler.queue_group_sync(id).await; + }); + } + } + Ok(group) } @@ -136,6 +145,15 @@ impl GroupManager { log::error!("Failed to emit groups-changed event: {e}"); } + if updated_group.sync_enabled { + if let Some(scheduler) = crate::sync::get_global_scheduler() { + let id = updated_group.id.clone(); + tauri::async_runtime::spawn(async move { + scheduler.queue_group_sync(id).await; + }); + } + } + Ok(updated_group) } @@ -173,6 +191,17 @@ impl GroupManager { Ok(()) } + pub fn delete_group_internal(&self, id: &str) -> Result<(), Box> { + let mut groups_data = self.load_groups_data()?; + let initial_len = groups_data.groups.len(); + groups_data.groups.retain(|g| g.id != id); + if groups_data.groups.len() == initial_len { + return Err(format!("Group with id '{id}' not found").into()); + } + self.save_groups_data(&groups_data)?; + Ok(()) + } + pub fn delete_group( &self, app_handle: &tauri::AppHandle, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 22f066f..24306d8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -78,15 +78,17 @@ use downloaded_browsers_registry::{ use downloader::{cancel_download, download_browser}; use settings_manager::{ - decline_launch_on_login, enable_launch_on_login, get_app_settings, get_sync_settings, - get_system_language, get_table_sorting_settings, save_app_settings, save_sync_settings, + decline_launch_on_login, dismiss_window_resize_warning, enable_launch_on_login, get_app_settings, + get_sync_settings, get_system_language, get_table_sorting_settings, + get_window_resize_warning_dismissed, save_app_settings, save_sync_settings, save_table_sorting_settings, should_show_launch_on_login_prompt, }; use sync::{ - 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, - set_group_sync_enabled, set_profile_sync_enabled, set_proxy_sync_enabled, set_vpn_sync_enabled, + 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, set_e2e_password, set_group_sync_enabled, + set_profile_sync_mode, set_proxy_sync_enabled, set_vpn_sync_enabled, }; use tag_manager::get_all_tags; @@ -466,13 +468,23 @@ async fn import_vpn_config( .map_err(|e| format!("Failed to lock VPN storage: {e}"))?; match storage.import_config(&content, &filename, name.clone()) { - Ok(config) => Ok(vpn::VpnImportResult { - success: true, - vpn_id: Some(config.id), - vpn_type: Some(config.vpn_type), - name: config.name, - error: None, - }), + Ok(config) => { + if config.sync_enabled { + if let Some(scheduler) = sync::get_global_scheduler() { + let id = config.id.clone(); + tauri::async_runtime::spawn(async move { + scheduler.queue_vpn_sync(id).await; + }); + } + } + Ok(vpn::VpnImportResult { + success: true, + vpn_id: Some(config.id), + vpn_type: Some(config.vpn_type), + name: config.name, + error: None, + }) + } Err(e) => Ok(vpn::VpnImportResult { success: false, vpn_id: None, @@ -563,24 +575,50 @@ async fn create_vpn_config_manual( vpn_type: vpn::VpnType, config_data: String, ) -> Result { - let storage = vpn::VPN_STORAGE - .lock() - .map_err(|e| format!("Failed to lock VPN storage: {e}"))?; + let config = { + let storage = vpn::VPN_STORAGE + .lock() + .map_err(|e| format!("Failed to lock VPN storage: {e}"))?; - storage - .create_config_manual(&name, vpn_type, &config_data) - .map_err(|e| format!("Failed to create VPN config: {e}")) + storage + .create_config_manual(&name, vpn_type, &config_data) + .map_err(|e| format!("Failed to create VPN config: {e}"))? + }; + + if config.sync_enabled { + if let Some(scheduler) = sync::get_global_scheduler() { + let id = config.id.clone(); + tauri::async_runtime::spawn(async move { + scheduler.queue_vpn_sync(id).await; + }); + } + } + + Ok(config) } #[tauri::command] async fn update_vpn_config(vpn_id: String, name: String) -> Result { - let storage = vpn::VPN_STORAGE - .lock() - .map_err(|e| format!("Failed to lock VPN storage: {e}"))?; + let config = { + let storage = vpn::VPN_STORAGE + .lock() + .map_err(|e| format!("Failed to lock VPN storage: {e}"))?; - storage - .update_config_name(&vpn_id, &name) - .map_err(|e| format!("Failed to update VPN config: {e}")) + storage + .update_config_name(&vpn_id, &name) + .map_err(|e| format!("Failed to update VPN config: {e}"))? + }; + + if config.sync_enabled { + if let Some(scheduler) = sync::get_global_scheduler() { + let id = config.id.clone(); + tauri::async_runtime::spawn(async move { + scheduler.queue_vpn_sync(id).await; + }); + } + } + + Ok(config) } #[tauri::command] @@ -750,9 +788,16 @@ pub fn run() { }) .build(), ) - .plugin(tauri_plugin_single_instance::init(|_, args, _cwd| { - log::info!("Single instance triggered with args: {args:?}"); - })) + .plugin(tauri_plugin_single_instance::init( + |app_handle, args, _cwd| { + log::info!("Single instance triggered with args: {args:?}"); + if let Some(window) = app_handle.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + let _ = window.unminimize(); + } + }, + )) .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_opener::init()) @@ -760,8 +805,8 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_macos_permissions::init()) .setup(|app| { - // Clean up stale ephemeral profile dirs from previous sessions - ephemeral_dirs::cleanup_stale_dirs(); + // Recover ephemeral dir mappings from RAM-backed storage (tmpfs/ramdisk) + ephemeral_dirs::recover_ephemeral_dirs(); // Start the daemon for tray icon if let Err(e) = daemon_spawn::ensure_daemon_running() { @@ -772,7 +817,6 @@ pub fn run() { daemon_spawn::register_gui_pid(); // Monitor daemon health - quit GUI if daemon dies - let app_handle_daemon = app.handle().clone(); tauri::async_runtime::spawn(async move { // Give the daemon time to fully start tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; @@ -788,9 +832,11 @@ pub fn run() { .unwrap_or(false); if !is_running { - log::warn!("Daemon is no longer running, quitting GUI"); - app_handle_daemon.exit(0); - break; + log::warn!("Daemon is no longer running, quitting GUI immediately"); + // Use process::exit for immediate termination. Tauri's exit() + // triggers a slow graceful shutdown that can take over a minute + // waiting for async tasks (sync, version updater, etc.) to finish. + std::process::exit(0); } } }); @@ -1225,6 +1271,12 @@ pub fn run() { { log::warn!("Failed to check for missing profiles: {}", e); } + if let Err(e) = engine + .check_for_missing_synced_entities(&app_handle_sync) + .await + { + log::warn!("Failed to check for missing entities: {}", e); + } } Err(e) => { log::debug!("Sync not configured, skipping missing profile check: {}", e); @@ -1288,6 +1340,8 @@ pub fn run() { get_table_sorting_settings, save_table_sorting_settings, get_system_language, + dismiss_window_resize_warning, + get_window_resize_warning_dismissed, clear_all_version_cache_and_refetch, is_default_browser, open_url_with_profile, @@ -1336,7 +1390,7 @@ pub fn run() { get_traffic_stats_for_period, get_sync_settings, save_sync_settings, - set_profile_sync_enabled, + set_profile_sync_mode, request_profile_sync, set_proxy_sync_enabled, set_group_sync_enabled, @@ -1346,6 +1400,9 @@ pub fn run() { is_vpn_in_use_by_synced_profile, get_unsynced_entity_counts, enable_sync_for_all_entities, + set_e2e_password, + check_has_e2e_password, + delete_e2e_password, read_profile_cookies, copy_profile_cookies, import_cookies_from_file, @@ -1385,8 +1442,17 @@ pub fn run() { cloud_auth::create_cloud_location_proxy, cloud_auth::restart_sync_service ]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); + .build(tauri::generate_context!()) + .expect("error while building tauri application") + .run(|app_handle, event| { + if let tauri::RunEvent::Reopen { .. } = event { + if let Some(window) = app_handle.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + let _ = window.unminimize(); + } + } + }); } #[cfg(test)] @@ -1412,6 +1478,7 @@ mod tests { "get_vpn_status", "get_vpn_config", "list_active_vpn_connections", + "export_profile_cookies", ]; // Extract command names from the generate_handler! macro in this file diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 92a9315..172b52f 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -18,6 +18,7 @@ use tokio::net::TcpListener; use tokio::sync::Mutex as AsyncMutex; use crate::browser::ProxySettings; +use crate::cloud_auth::CLOUD_AUTH; use crate::group_manager::GROUP_MANAGER; use crate::profile::{BrowserProfile, ProfileManager}; use crate::proxy_manager::PROXY_MANAGER; @@ -679,6 +680,51 @@ impl McpServer { "required": ["vpn_id"] }), }, + // Fingerprint management tools + McpTool { + name: "get_profile_fingerprint".to_string(), + description: "Get the fingerprint configuration for a Wayfern or Camoufox profile" + .to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "profile_id": { + "type": "string", + "description": "The UUID of the profile" + } + }, + "required": ["profile_id"] + }), + }, + McpTool { + name: "update_profile_fingerprint".to_string(), + description: + "Update the fingerprint configuration for a Wayfern or Camoufox profile. Requires an active Pro subscription." + .to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "profile_id": { + "type": "string", + "description": "The UUID of the profile to update" + }, + "fingerprint": { + "type": "string", + "description": "JSON string of the fingerprint configuration, or null to clear" + }, + "os": { + "type": "string", + "enum": ["windows", "macos", "linux"], + "description": "Operating system for fingerprint generation" + }, + "randomize_fingerprint_on_launch": { + "type": "boolean", + "description": "Whether to generate a new fingerprint on every launch" + } + }, + "required": ["profile_id"] + }), + }, ] } @@ -777,6 +823,9 @@ impl McpServer { "connect_vpn" => self.handle_connect_vpn(&arguments).await, "disconnect_vpn" => self.handle_disconnect_vpn(&arguments).await, "get_vpn_status" => self.handle_get_vpn_status(&arguments).await, + // Fingerprint management + "get_profile_fingerprint" => self.handle_get_profile_fingerprint(&arguments).await, + "update_profile_fingerprint" => self.handle_update_profile_fingerprint(&arguments).await, _ => Err(McpError { code: -32602, message: format!("Unknown tool: {tool_name}"), @@ -1825,6 +1874,198 @@ impl McpServer { }] })) } + + // Fingerprint management handlers + async fn handle_get_profile_fingerprint( + &self, + arguments: &serde_json::Value, + ) -> Result { + let profile_id = arguments + .get("profile_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError { + code: -32602, + message: "Missing profile_id".to_string(), + })?; + + let profiles = ProfileManager::instance() + .list_profiles() + .map_err(|e| McpError { + code: -32000, + message: format!("Failed to list profiles: {e}"), + })?; + + let profile = profiles + .iter() + .find(|p| p.id.to_string() == profile_id) + .ok_or_else(|| McpError { + code: -32000, + message: format!("Profile not found: {profile_id}"), + })?; + + let fingerprint_info = match profile.browser.as_str() { + "camoufox" => { + let config = profile + .camoufox_config + .as_ref() + .cloned() + .unwrap_or_default(); + serde_json::json!({ + "browser": "camoufox", + "fingerprint": config.fingerprint, + "os": config.os, + "randomize_fingerprint_on_launch": config.randomize_fingerprint_on_launch, + "screen_max_width": config.screen_max_width, + "screen_max_height": config.screen_max_height, + "screen_min_width": config.screen_min_width, + "screen_min_height": config.screen_min_height, + }) + } + "wayfern" => { + let config = profile.wayfern_config.as_ref().cloned().unwrap_or_default(); + serde_json::json!({ + "browser": "wayfern", + "fingerprint": config.fingerprint, + "os": config.os, + "randomize_fingerprint_on_launch": config.randomize_fingerprint_on_launch, + "screen_max_width": config.screen_max_width, + "screen_max_height": config.screen_max_height, + "screen_min_width": config.screen_min_width, + "screen_min_height": config.screen_min_height, + }) + } + _ => { + return Err(McpError { + code: -32000, + message: "MCP only supports Wayfern and Camoufox profiles".to_string(), + }) + } + }; + + Ok(serde_json::json!({ + "content": [{ + "type": "text", + "text": serde_json::to_string_pretty(&fingerprint_info).unwrap_or_default() + }] + })) + } + + async fn handle_update_profile_fingerprint( + &self, + arguments: &serde_json::Value, + ) -> Result { + if !CLOUD_AUTH.has_active_paid_subscription().await { + return Err(McpError { + code: -32000, + message: "Fingerprint editing requires an active Pro subscription".to_string(), + }); + } + + let profile_id = arguments + .get("profile_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError { + code: -32602, + message: "Missing profile_id".to_string(), + })?; + + let fingerprint = arguments.get("fingerprint").and_then(|v| v.as_str()); + let os = arguments.get("os").and_then(|v| v.as_str()); + let randomize = arguments + .get("randomize_fingerprint_on_launch") + .and_then(|v| v.as_bool()); + + if let Some(os_val) = os { + if !CLOUD_AUTH.is_fingerprint_os_allowed(Some(os_val)).await { + return Err(McpError { + code: -32000, + message: format!( + "OS spoofing to '{}' requires an active Pro subscription", + os_val + ), + }); + } + } + + let profiles = ProfileManager::instance() + .list_profiles() + .map_err(|e| McpError { + code: -32000, + message: format!("Failed to list profiles: {e}"), + })?; + + let profile = profiles + .iter() + .find(|p| p.id.to_string() == profile_id) + .ok_or_else(|| McpError { + code: -32000, + message: format!("Profile not found: {profile_id}"), + })?; + + let inner = self.inner.lock().await; + let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError { + code: -32000, + message: "MCP server not properly initialized".to_string(), + })?; + + match profile.browser.as_str() { + "camoufox" => { + let mut config = profile + .camoufox_config + .as_ref() + .cloned() + .unwrap_or_default(); + if let Some(fp) = fingerprint { + config.fingerprint = Some(fp.to_string()); + } + if let Some(os_val) = os { + config.os = Some(os_val.to_string()); + } + if let Some(r) = randomize { + config.randomize_fingerprint_on_launch = Some(r); + } + ProfileManager::instance() + .update_camoufox_config(app_handle.clone(), profile_id, config) + .await + .map_err(|e| McpError { + code: -32000, + message: format!("Failed to update camoufox config: {e}"), + })?; + } + "wayfern" => { + let mut config = profile.wayfern_config.as_ref().cloned().unwrap_or_default(); + if let Some(fp) = fingerprint { + config.fingerprint = Some(fp.to_string()); + } + if let Some(os_val) = os { + config.os = Some(os_val.to_string()); + } + if let Some(r) = randomize { + config.randomize_fingerprint_on_launch = Some(r); + } + ProfileManager::instance() + .update_wayfern_config(app_handle.clone(), profile_id, config) + .await + .map_err(|e| McpError { + code: -32000, + message: format!("Failed to update wayfern config: {e}"), + })?; + } + _ => { + return Err(McpError { + code: -32000, + message: "MCP only supports Wayfern and Camoufox profiles".to_string(), + }) + } + } + + Ok(serde_json::json!({ + "content": [{ + "type": "text", + "text": format!("Fingerprint configuration updated for profile '{}'", profile.name) + }] + })) + } } lazy_static::lazy_static! { @@ -1840,8 +2081,8 @@ mod tests { let server = McpServer::new(); let tools = server.get_tools(); - // Should have at least 24 tools (18 + 6 VPN tools) - assert!(tools.len() >= 24); + // Should have at least 26 tools (24 + 2 fingerprint tools) + assert!(tools.len() >= 26); // Check tool names let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect(); @@ -1874,6 +2115,9 @@ mod tests { assert!(tool_names.contains(&"connect_vpn")); assert!(tool_names.contains(&"disconnect_vpn")); assert!(tool_names.contains(&"get_vpn_status")); + // Fingerprint tools + assert!(tool_names.contains(&"get_profile_fingerprint")); + assert!(tool_names.contains(&"update_profile_fingerprint")); } #[test] diff --git a/src-tauri/src/platform_browser.rs b/src-tauri/src/platform_browser.rs index fcda02e..200cb45 100644 --- a/src-tauri/src/platform_browser.rs +++ b/src-tauri/src/platform_browser.rs @@ -651,8 +651,11 @@ pub mod windows { use std::process::Command; // Try taskkill command as fallback + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; let output = Command::new("taskkill") .args(["/F", "/PID", &pid.to_string()]) + .creation_flags(CREATE_NO_WINDOW) .output(); match output { diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index 521e9a4..83279d9 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -3,7 +3,7 @@ use crate::browser::{create_browser, BrowserType, ProxySettings}; use crate::camoufox_manager::CamoufoxConfig; use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry; use crate::events; -use crate::profile::types::{get_host_os, BrowserProfile}; +use crate::profile::types::{get_host_os, BrowserProfile, SyncMode}; use crate::proxy_manager::PROXY_MANAGER; use crate::wayfern_manager::WayfernConfig; use std::fs::{self, create_dir_all}; @@ -162,7 +162,8 @@ impl ProfileManager { group_id: group_id.clone(), tags: Vec::new(), note: None, - sync_enabled: false, + sync_mode: SyncMode::Disabled, + encryption_salt: None, last_sync: None, host_os: None, ephemeral: false, @@ -278,7 +279,8 @@ impl ProfileManager { group_id: group_id.clone(), tags: Vec::new(), note: None, - sync_enabled: false, + sync_mode: SyncMode::Disabled, + encryption_salt: None, last_sync: None, host_os: None, ephemeral: false, @@ -326,7 +328,8 @@ impl ProfileManager { group_id: group_id.clone(), tags: Vec::new(), note: None, - sync_enabled: false, + sync_mode: SyncMode::Disabled, + encryption_salt: None, last_sync: None, host_os: Some(get_host_os()), ephemeral, @@ -475,8 +478,8 @@ impl ProfileManager { ); } - // Remember sync_enabled before deleting local files - let was_sync_enabled = profile.sync_enabled; + // Remember sync mode before deleting local files + let was_sync_enabled = profile.is_sync_enabled(); let profiles_dir = self.get_profiles_dir(); let profile_uuid_dir = profiles_dir.join(profile.id.to_string()); @@ -622,7 +625,7 @@ impl ProfileManager { self.save_profile(&profile)?; // Auto-enable sync for new group if profile has sync enabled - if profile.sync_enabled { + if profile.is_sync_enabled() { if let Some(ref new_group_id) = group_id { let group_id_clone = new_group_id.clone(); let app_handle_clone = app_handle.clone(); @@ -747,7 +750,7 @@ impl ProfileManager { } // Track sync-enabled profiles for remote deletion - if profile.sync_enabled { + if profile.is_sync_enabled() { sync_enabled_ids.push(profile_id.clone()); } @@ -848,7 +851,8 @@ impl ProfileManager { group_id: source.group_id, tags: source.tags, note: source.note, - sync_enabled: false, + sync_mode: SyncMode::Disabled, + encryption_salt: None, last_sync: None, host_os: Some(get_host_os()), ephemeral: false, @@ -1024,7 +1028,7 @@ impl ProfileManager { })?; // Auto-enable sync for new proxy if profile has sync enabled - if profile.sync_enabled { + if profile.is_sync_enabled() { if let Some(ref new_proxy_id) = proxy_id { let _ = crate::sync::enable_proxy_sync_if_needed(new_proxy_id, &app_handle).await; if let Some(scheduler) = crate::sync::get_global_scheduler() { diff --git a/src-tauri/src/profile/types.rs b/src-tauri/src/profile/types.rs index a982a58..9f7fa22 100644 --- a/src-tauri/src/profile/types.rs +++ b/src-tauri/src/profile/types.rs @@ -13,6 +13,14 @@ pub enum SyncStatus { Error, } +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)] +pub enum SyncMode { + #[default] + Disabled, + Regular, + Encrypted, +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct BrowserProfile { pub id: uuid::Uuid, @@ -40,7 +48,9 @@ pub struct BrowserProfile { #[serde(default)] pub note: Option, // User note #[serde(default)] - pub sync_enabled: bool, // Whether sync is enabled for this profile + pub sync_mode: SyncMode, + #[serde(default)] + pub encryption_salt: Option, #[serde(default)] pub last_sync: Option, // Timestamp of last successful sync (epoch seconds) #[serde(default)] @@ -77,4 +87,14 @@ impl BrowserProfile { None => false, } } + + /// Returns true if sync is enabled (either Regular or Encrypted mode). + pub fn is_sync_enabled(&self) -> bool { + self.sync_mode != SyncMode::Disabled + } + + /// Returns true if sync uses E2E encryption. + pub fn is_encrypted_sync(&self) -> bool { + self.sync_mode == SyncMode::Encrypted + } } diff --git a/src-tauri/src/profile_importer.rs b/src-tauri/src/profile_importer.rs index 7bd9098..6c3dea0 100644 --- a/src-tauri/src/profile_importer.rs +++ b/src-tauri/src/profile_importer.rs @@ -554,7 +554,8 @@ impl ProfileImporter { group_id: None, tags: Vec::new(), note: None, - sync_enabled: false, + sync_mode: crate::profile::types::SyncMode::Disabled, + encryption_salt: None, last_sync: None, host_os: Some(crate::profile::types::get_host_os()), ephemeral: false, diff --git a/src-tauri/src/proxy_manager.rs b/src-tauri/src/proxy_manager.rs index 98fbf76..b035469 100644 --- a/src-tauri/src/proxy_manager.rs +++ b/src-tauri/src/proxy_manager.rs @@ -116,7 +116,7 @@ pub struct StoredProxy { impl StoredProxy { pub fn new(name: String, proxy_settings: ProxySettings) -> Self { - let sync_enabled = crate::cloud_auth::CLOUD_AUTH.has_active_paid_subscription_sync(); + let sync_enabled = crate::sync::is_sync_configured(); Self { id: uuid::Uuid::new_v4().to_string(), name, @@ -390,6 +390,15 @@ impl ProxyManager { log::error!("Failed to emit proxies-changed event: {e}"); } + if stored_proxy.sync_enabled { + if let Some(scheduler) = crate::sync::get_global_scheduler() { + let id = stored_proxy.id.clone(); + tauri::async_runtime::spawn(async move { + scheduler.queue_proxy_sync(id).await; + }); + } + } + Ok(stored_proxy) } @@ -608,6 +617,11 @@ impl ProxyManager { } } + pub fn remove_from_memory(&self, proxy_id: &str) { + let mut stored_proxies = self.stored_proxies.lock().unwrap(); + stored_proxies.remove(proxy_id); + } + // Get all stored proxies pub fn get_stored_proxies(&self) -> Vec { let stored_proxies = self.stored_proxies.lock().unwrap(); @@ -680,6 +694,15 @@ impl ProxyManager { log::error!("Failed to emit proxies-changed event: {e}"); } + if updated_proxy.sync_enabled { + if let Some(scheduler) = crate::sync::get_global_scheduler() { + let id = updated_proxy.id.clone(); + tauri::async_runtime::spawn(async move { + scheduler.queue_proxy_sync(id).await; + }); + } + } + Ok(updated_proxy) } diff --git a/src-tauri/src/proxy_runner.rs b/src-tauri/src/proxy_runner.rs index 6640184..c54aac7 100644 --- a/src-tauri/src/proxy_runner.rs +++ b/src-tauri/src/proxy_runner.rs @@ -254,9 +254,12 @@ pub async fn stop_proxy_process(id: &str) -> Result, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ru", or None for system default + #[serde(default)] + pub window_resize_warning_dismissed: bool, } #[derive(Debug, Serialize, Deserialize, Clone, Default)] @@ -86,6 +88,7 @@ impl Default for AppSettings { mcp_token: None, launch_on_login_declined: false, language: None, + window_resize_warning_dismissed: false, } } } @@ -781,6 +784,15 @@ pub async fn save_app_settings( settings.mcp_token = None; } + // Preserve server-managed flags that the frontend may not have up-to-date. + // Read directly from file to avoid load_settings' save-on-load behavior. + if let Ok(content) = std::fs::read_to_string(manager.get_settings_file()) { + if let Ok(current) = serde_json::from_str::(&content) { + settings.window_resize_warning_dismissed = current.window_resize_warning_dismissed; + settings.launch_on_login_declined = current.launch_on_login_declined; + } + } + let mut persist_settings = settings.clone(); persist_settings.api_token = None; persist_settings.mcp_token = None; @@ -898,6 +910,27 @@ pub async fn save_sync_settings( }) } +#[tauri::command] +pub async fn dismiss_window_resize_warning() -> Result<(), String> { + let manager = SettingsManager::instance(); + let mut settings = manager + .load_settings() + .map_err(|e| format!("Failed to load settings: {e}"))?; + settings.window_resize_warning_dismissed = true; + manager + .save_settings(&settings) + .map_err(|e| format!("Failed to save settings: {e}")) +} + +#[tauri::command] +pub async fn get_window_resize_warning_dismissed() -> Result { + let manager = SettingsManager::instance(); + let settings = manager + .load_settings() + .map_err(|e| format!("Failed to load settings: {e}"))?; + Ok(settings.window_resize_warning_dismissed) +} + #[tauri::command] pub fn get_system_language() -> String { sys_locale::get_locale() @@ -999,6 +1032,7 @@ mod tests { mcp_token: None, launch_on_login_declined: false, language: None, + window_resize_warning_dismissed: false, }; let save_result = manager.save_settings(&test_settings); diff --git a/src-tauri/src/sync/encryption.rs b/src-tauri/src/sync/encryption.rs new file mode 100644 index 0000000..69fe603 --- /dev/null +++ b/src-tauri/src/sync/encryption.rs @@ -0,0 +1,351 @@ +use aes_gcm::{ + aead::{Aead, AeadCore, KeyInit, OsRng}, + Aes256Gcm, Key, +}; +use argon2::{password_hash::SaltString, Argon2, PasswordHasher}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; + +const E2E_FILE_HEADER: &[u8] = b"DBE2E"; +const E2E_FILE_VERSION: u8 = 1; + +fn get_e2e_password_path() -> std::path::PathBuf { + crate::app_dirs::settings_dir().join("e2e_password.dat") +} + +fn get_vault_password() -> String { + env!("DONUT_BROWSER_VAULT_PASSWORD").to_string() +} + +pub fn store_e2e_password(password: &str) -> Result<(), String> { + let file_path = get_e2e_password_path(); + + if let Some(parent) = file_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {e}"))?; + } + + let vault_password = get_vault_password(); + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let password_hash = argon2 + .hash_password(vault_password.as_bytes(), &salt) + .map_err(|e| format!("Argon2 key derivation failed: {e}"))?; + let hash_value = password_hash.hash.unwrap(); + let hash_bytes = hash_value.as_bytes(); + + let key_bytes: [u8; 32] = hash_bytes[..32] + .try_into() + .map_err(|_| "Invalid key length")?; + let key = Key::::from(key_bytes); + let cipher = Aes256Gcm::new(&key); + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + + let ciphertext = cipher + .encrypt(&nonce, password.as_bytes()) + .map_err(|e| format!("Encryption failed: {e}"))?; + + let mut file_data = Vec::new(); + file_data.extend_from_slice(E2E_FILE_HEADER); + file_data.push(E2E_FILE_VERSION); + + let salt_str = salt.as_str(); + file_data.push(salt_str.len() as u8); + file_data.extend_from_slice(salt_str.as_bytes()); + file_data.extend_from_slice(&nonce); + file_data.extend_from_slice(&(ciphertext.len() as u32).to_le_bytes()); + file_data.extend_from_slice(&ciphertext); + + std::fs::write(&file_path, file_data) + .map_err(|e| format!("Failed to write e2e password file: {e}"))?; + + Ok(()) +} + +pub fn load_e2e_password() -> Result, String> { + let file_path = get_e2e_password_path(); + if !file_path.exists() { + return Ok(None); + } + + let file_data = + std::fs::read(&file_path).map_err(|e| format!("Failed to read e2e password file: {e}"))?; + + if file_data.len() < E2E_FILE_HEADER.len() + 1 { + return Ok(None); + } + + if &file_data[..E2E_FILE_HEADER.len()] != E2E_FILE_HEADER { + return Ok(None); + } + + let version = file_data[E2E_FILE_HEADER.len()]; + if version != E2E_FILE_VERSION { + return Ok(None); + } + + let mut offset = E2E_FILE_HEADER.len() + 1; + + if offset >= file_data.len() { + return Ok(None); + } + let salt_len = file_data[offset] as usize; + offset += 1; + + if offset + salt_len > file_data.len() { + return Ok(None); + } + let salt_str = std::str::from_utf8(&file_data[offset..offset + salt_len]) + .map_err(|_| "Invalid salt encoding")?; + offset += salt_len; + + let salt = SaltString::from_b64(salt_str).map_err(|e| format!("Invalid salt: {e}"))?; + + if offset + 12 > file_data.len() { + return Ok(None); + } + let nonce_bytes: [u8; 12] = file_data[offset..offset + 12] + .try_into() + .map_err(|_| "Invalid nonce")?; + let nonce = aes_gcm::Nonce::from(nonce_bytes); + offset += 12; + + if offset + 4 > file_data.len() { + return Ok(None); + } + let ciphertext_len = + u32::from_le_bytes(file_data[offset..offset + 4].try_into().unwrap()) as usize; + offset += 4; + + if offset + ciphertext_len > file_data.len() { + return Ok(None); + } + let ciphertext = &file_data[offset..offset + ciphertext_len]; + + let vault_password = get_vault_password(); + let argon2 = Argon2::default(); + let password_hash = argon2 + .hash_password(vault_password.as_bytes(), &salt) + .map_err(|e| format!("Argon2 key derivation failed: {e}"))?; + let hash_value = password_hash.hash.unwrap(); + let hash_bytes = hash_value.as_bytes(); + + let key_bytes: [u8; 32] = hash_bytes[..32] + .try_into() + .map_err(|_| "Invalid key length")?; + let key = Key::::from(key_bytes); + let cipher = Aes256Gcm::new(&key); + + let plaintext = cipher + .decrypt(&nonce, ciphertext) + .map_err(|e| format!("Decryption failed: {e}"))?; + + let password = + String::from_utf8(plaintext).map_err(|e| format!("Invalid UTF-8 in password: {e}"))?; + + Ok(Some(password)) +} + +pub fn has_e2e_password() -> bool { + get_e2e_password_path().exists() +} + +pub fn remove_e2e_password() -> Result<(), String> { + let file_path = get_e2e_password_path(); + if file_path.exists() { + std::fs::remove_file(&file_path) + .map_err(|e| format!("Failed to remove e2e password file: {e}"))?; + } + Ok(()) +} + +/// Derive a per-profile encryption key using Argon2id +pub fn derive_profile_key(user_password: &str, profile_salt: &str) -> Result<[u8; 32], String> { + let salt_bytes = BASE64 + .decode(profile_salt) + .map_err(|e| format!("Invalid salt encoding: {e}"))?; + + let salt = SaltString::encode_b64(&salt_bytes) + .map_err(|e| format!("Failed to create salt string: {e}"))?; + + let argon2 = Argon2::default(); + let password_hash = argon2 + .hash_password(user_password.as_bytes(), &salt) + .map_err(|e| format!("Key derivation failed: {e}"))?; + let hash_value = password_hash.hash.unwrap(); + let hash_bytes = hash_value.as_bytes(); + + let mut key = [0u8; 32]; + key.copy_from_slice(&hash_bytes[..32]); + Ok(key) +} + +/// Generate a random 16-byte salt, base64-encoded +pub fn generate_salt() -> String { + let mut salt = [0u8; 16]; + use aes_gcm::aead::rand_core::RngCore; + OsRng.fill_bytes(&mut salt); + BASE64.encode(salt) +} + +/// Encrypt bytes with AES-256-GCM. Output format: [nonce 12B][ciphertext] +pub fn encrypt_bytes(key: &[u8; 32], plaintext: &[u8]) -> Result, String> { + let aes_key = Key::::from(*key); + let cipher = Aes256Gcm::new(&aes_key); + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + + let ciphertext = cipher + .encrypt(&nonce, plaintext) + .map_err(|e| format!("Encryption failed: {e}"))?; + + let mut output = Vec::with_capacity(12 + ciphertext.len()); + output.extend_from_slice(&nonce); + output.extend_from_slice(&ciphertext); + Ok(output) +} + +/// Decrypt bytes encrypted with encrypt_bytes. Input format: [nonce 12B][ciphertext] +pub fn decrypt_bytes(key: &[u8; 32], encrypted: &[u8]) -> Result, String> { + if encrypted.len() < 12 { + return Err("Encrypted data too short".to_string()); + } + + let nonce_bytes: [u8; 12] = encrypted[..12].try_into().map_err(|_| "Invalid nonce")?; + let nonce = aes_gcm::Nonce::from(nonce_bytes); + let ciphertext = &encrypted[12..]; + + let aes_key = Key::::from(*key); + let cipher = Aes256Gcm::new(&aes_key); + + cipher + .decrypt(&nonce, ciphertext) + .map_err(|e| format!("Decryption failed: {e}")) +} + +// Tauri commands + +#[tauri::command] +pub fn set_e2e_password(password: String) -> Result<(), String> { + if password.len() < 8 { + return Err("Password must be at least 8 characters".to_string()); + } + store_e2e_password(&password) +} + +#[tauri::command] +pub fn check_has_e2e_password() -> bool { + has_e2e_password() +} + +#[tauri::command] +pub fn delete_e2e_password() -> Result<(), String> { + remove_e2e_password() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_encrypt_decrypt_roundtrip() { + let key = [42u8; 32]; + let plaintext = b"Hello, World!"; + let encrypted = encrypt_bytes(&key, plaintext).unwrap(); + let decrypted = decrypt_bytes(&key, &encrypted).unwrap(); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn test_encrypt_decrypt_empty_data() { + let key = [1u8; 32]; + let plaintext = b""; + let encrypted = encrypt_bytes(&key, plaintext).unwrap(); + let decrypted = decrypt_bytes(&key, &encrypted).unwrap(); + assert_eq!(decrypted, plaintext.to_vec()); + } + + #[test] + fn test_encrypt_decrypt_large_data() { + let key = [7u8; 32]; + let plaintext = vec![0xABu8; 1_048_576]; // 1MB + let encrypted = encrypt_bytes(&key, &plaintext).unwrap(); + let decrypted = decrypt_bytes(&key, &encrypted).unwrap(); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn test_different_keys_different_ciphertext() { + let key1 = [1u8; 32]; + let key2 = [2u8; 32]; + let plaintext = b"same data"; + let encrypted1 = encrypt_bytes(&key1, plaintext).unwrap(); + let encrypted2 = encrypt_bytes(&key2, plaintext).unwrap(); + // Nonces are random so ciphertexts will differ regardless, + // but decrypting with wrong key should fail + assert!(decrypt_bytes(&key2, &encrypted1).is_err()); + assert!(decrypt_bytes(&key1, &encrypted2).is_err()); + } + + #[test] + fn test_nonce_uniqueness() { + let key = [5u8; 32]; + let plaintext = b"same data encrypted twice"; + let encrypted1 = encrypt_bytes(&key, plaintext).unwrap(); + let encrypted2 = encrypt_bytes(&key, plaintext).unwrap(); + // Different nonces should produce different ciphertext + assert_ne!(encrypted1, encrypted2); + // But both should decrypt to the same plaintext + assert_eq!( + decrypt_bytes(&key, &encrypted1).unwrap(), + decrypt_bytes(&key, &encrypted2).unwrap() + ); + } + + #[test] + fn test_wrong_key_fails() { + let key = [10u8; 32]; + let wrong_key = [20u8; 32]; + let plaintext = b"secret data"; + let encrypted = encrypt_bytes(&key, plaintext).unwrap(); + assert!(decrypt_bytes(&wrong_key, &encrypted).is_err()); + } + + #[test] + fn test_key_derivation_deterministic() { + let salt = generate_salt(); + let key1 = derive_profile_key("my_password", &salt).unwrap(); + let key2 = derive_profile_key("my_password", &salt).unwrap(); + assert_eq!(key1, key2); + } + + #[test] + fn test_key_derivation_different_salts() { + let salt1 = generate_salt(); + let salt2 = generate_salt(); + let key1 = derive_profile_key("my_password", &salt1).unwrap(); + let key2 = derive_profile_key("my_password", &salt2).unwrap(); + assert_ne!(key1, key2); + } + + #[test] + fn test_salt_generation_unique() { + let salt1 = generate_salt(); + let salt2 = generate_salt(); + assert_ne!(salt1, salt2); + } + + #[test] + fn test_password_storage_roundtrip() { + let password = "test_password_12345"; + store_e2e_password(password).unwrap(); + assert!(has_e2e_password()); + let loaded = load_e2e_password().unwrap(); + assert_eq!(loaded, Some(password.to_string())); + remove_e2e_password().unwrap(); + assert!(!has_e2e_password()); + } + + #[test] + fn test_decrypt_too_short_data() { + let key = [1u8; 32]; + assert!(decrypt_bytes(&key, &[0u8; 5]).is_err()); + } +} diff --git a/src-tauri/src/sync/engine.rs b/src-tauri/src/sync/engine.rs index 3ab1af8..8e5990e 100644 --- a/src-tauri/src/sync/engine.rs +++ b/src-tauri/src/sync/engine.rs @@ -1,8 +1,9 @@ use super::client::SyncClient; +use super::encryption; use super::manifest::{compute_diff, generate_manifest, get_cache_path, HashCache, SyncManifest}; use super::types::*; use crate::events; -use crate::profile::types::BrowserProfile; +use crate::profile::types::{BrowserProfile, SyncMode}; use crate::profile::ProfileManager; use crate::settings_manager::SettingsManager; use chrono::{DateTime, Utc}; @@ -12,6 +13,18 @@ use std::path::Path; use std::sync::Arc; use tokio::sync::Semaphore; +/// Check if sync is configured (cloud or self-hosted) +pub fn is_sync_configured() -> bool { + if crate::cloud_auth::CLOUD_AUTH.has_active_paid_subscription_sync() { + return true; + } + let manager = SettingsManager::instance(); + if let Ok(settings) = manager.load_settings() { + return settings.sync_server_url.is_some(); + } + false +} + pub struct SyncEngine { client: SyncClient, } @@ -68,6 +81,24 @@ impl SyncEngine { return Ok(()); } + // Derive encryption key if encrypted sync + let encryption_key = if profile.is_encrypted_sync() { + let password = encryption::load_e2e_password() + .map_err(|e| SyncError::InvalidData(format!("Failed to load E2E password: {e}")))? + .ok_or_else(|| { + let _ = events::emit("profile-sync-e2e-password-required", ()); + SyncError::InvalidData("E2E password not set".to_string()) + })?; + let salt = profile.encryption_salt.as_deref().ok_or_else(|| { + SyncError::InvalidData("Encryption salt missing on encrypted profile".to_string()) + })?; + let key = encryption::derive_profile_key(&password, salt) + .map_err(|e| SyncError::InvalidData(format!("Key derivation failed: {e}")))?; + Some(key) + } else { + None + }; + let profile_manager = ProfileManager::instance(); let profiles_dir = profile_manager.get_profiles_dir(); let profile_dir = profiles_dir.join(profile.id.to_string()); @@ -154,7 +185,13 @@ impl SyncEngine { // Perform uploads if !diff.files_to_upload.is_empty() { self - .upload_profile_files(app_handle, &profile_id, &profile_dir, &diff.files_to_upload) + .upload_profile_files( + app_handle, + &profile_id, + &profile_dir, + &diff.files_to_upload, + encryption_key.as_ref(), + ) .await?; } @@ -166,6 +203,7 @@ impl SyncEngine { &profile_id, &profile_dir, &diff.files_to_download, + encryption_key.as_ref(), ) .await?; } @@ -190,7 +228,9 @@ impl SyncEngine { self.upload_profile_metadata(&profile_id, profile).await?; // Upload manifest.json last for atomicity - self.upload_manifest(&profile_id, &local_manifest).await?; + let mut final_manifest = local_manifest; + final_manifest.encrypted = encryption_key.is_some(); + self.upload_manifest(&profile_id, &final_manifest).await?; // Sync associated proxy, group, and VPN if let Some(proxy_id) = &profile.proxy_id { @@ -291,6 +331,7 @@ impl SyncEngine { profile_id: &str, profile_dir: &Path, files: &[super::manifest::ManifestFileEntry], + encryption_key: Option<&[u8; 32]>, ) -> SyncResult<()> { if files.is_empty() { return Ok(()); @@ -324,6 +365,7 @@ impl SyncEngine { let client = self.client.clone(); let profile_dir = profile_dir.to_path_buf(); let profile_id = profile_id.to_string(); + let enc_key = encryption_key.copied(); let mut handles = Vec::new(); @@ -355,8 +397,20 @@ impl SyncEngine { } }; + let upload_data = if let Some(ref key) = enc_key { + match encryption::encrypt_bytes(key, &data) { + Ok(encrypted) => encrypted, + Err(e) => { + log::warn!("Failed to encrypt {}: {}", file_path.display(), e); + return; + } + } + } else { + data + }; + if let Err(e) = client - .upload_bytes(&url, &data, content_type.as_deref()) + .upload_bytes(&url, &upload_data, content_type.as_deref()) .await { log::warn!("Failed to upload {}: {}", file_path.display(), e); @@ -387,6 +441,7 @@ impl SyncEngine { profile_id: &str, profile_dir: &Path, files: &[super::manifest::ManifestFileEntry], + encryption_key: Option<&[u8; 32]>, ) -> SyncResult<()> { if files.is_empty() { return Ok(()); @@ -418,6 +473,7 @@ impl SyncEngine { let client = self.client.clone(); let profile_dir = profile_dir.to_path_buf(); let profile_id = profile_id.to_string(); + let enc_key = encryption_key.copied(); let mut handles = Vec::new(); @@ -440,10 +496,22 @@ impl SyncEngine { match client.download_bytes(&url).await { Ok(data) => { + let write_data = if let Some(ref key) = enc_key { + match encryption::decrypt_bytes(key, &data) { + Ok(decrypted) => decrypted, + Err(e) => { + log::warn!("Failed to decrypt {}, skipping: {}", remote_key, e); + return; + } + } + } else { + data + }; + if let Some(parent) = file_path.parent() { let _ = fs::create_dir_all(parent); } - if let Err(e) = fs::write(&file_path, &data) { + if let Err(e) = fs::write(&file_path, &write_data) { log::warn!("Failed to write {}: {}", file_path.display(), e); } } @@ -1016,7 +1084,9 @@ impl SyncEngine { )) })?; - profile.sync_enabled = true; + if profile.sync_mode == SyncMode::Disabled { + profile.sync_mode = SyncMode::Regular; + } profile.last_sync = Some( std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -1052,6 +1122,26 @@ impl SyncEngine { )); }; + // If remote manifest is encrypted, we need the E2E password + let encryption_key = if manifest.encrypted { + let password = encryption::load_e2e_password() + .map_err(|e| SyncError::InvalidData(format!("Failed to load E2E password: {e}")))? + .ok_or_else(|| { + let _ = events::emit("profile-sync-e2e-password-required", ()); + SyncError::InvalidData( + "Remote profile is encrypted but no E2E password is set".to_string(), + ) + })?; + let salt = profile.encryption_salt.as_deref().ok_or_else(|| { + SyncError::InvalidData("Encryption salt missing on encrypted profile".to_string()) + })?; + let key = encryption::derive_profile_key(&password, salt) + .map_err(|e| SyncError::InvalidData(format!("Key derivation failed: {e}")))?; + Some(key) + } else { + None + }; + // Ensure profile directory exists fs::create_dir_all(&profile_dir).map_err(|e| { SyncError::IoError(format!( @@ -1078,12 +1168,24 @@ impl SyncEngine { } if !manifest.files.is_empty() { self - .download_profile_files(app_handle, profile_id, &profile_dir, &manifest.files) + .download_profile_files( + app_handle, + profile_id, + &profile_dir, + &manifest.files, + encryption_key.as_ref(), + ) .await?; } - // Set sync enabled and save profile - profile.sync_enabled = true; + // Set sync mode and save profile + if profile.sync_mode == SyncMode::Disabled { + profile.sync_mode = if manifest.encrypted { + SyncMode::Encrypted + } else { + SyncMode::Regular + }; + } profile.last_sync = Some( std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -1170,23 +1272,23 @@ impl SyncEngine { // Refresh metadata for local cross-OS profiles (propagate renames, tags, notes from originating device) let profile_manager = ProfileManager::instance(); // Collect cross-OS profiles before async operations to avoid holding non-Send Result across await - let cross_os_profiles: Vec<(String, bool)> = profile_manager + let cross_os_profiles: Vec<(String, SyncMode)> = profile_manager .list_profiles() .unwrap_or_default() .iter() - .filter(|p| p.is_cross_os() && p.sync_enabled) - .map(|p| (p.id.to_string(), p.sync_enabled)) + .filter(|p| p.is_cross_os() && p.is_sync_enabled()) + .map(|p| (p.id.to_string(), p.sync_mode)) .collect(); if !cross_os_profiles.is_empty() { - for (pid, sync_enabled) in &cross_os_profiles { + for (pid, sync_mode) in &cross_os_profiles { let metadata_key = format!("profiles/{}/metadata.json", pid); match self.client.stat(&metadata_key).await { Ok(stat) if stat.exists => match self.client.presign_download(&metadata_key).await { Ok(presign) => match self.client.download_bytes(&presign.url).await { Ok(data) => { if let Ok(mut remote_profile) = serde_json::from_slice::(&data) { - remote_profile.sync_enabled = *sync_enabled; + remote_profile.sync_mode = *sync_mode; remote_profile.last_sync = Some( std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -1220,6 +1322,111 @@ impl SyncEngine { Ok(downloaded) } + + /// Check for remote entities (proxies, groups, VPNs) not present locally and download them + pub async fn check_for_missing_synced_entities( + &self, + app_handle: &tauri::AppHandle, + ) -> SyncResult<()> { + log::info!("Checking for missing synced entities..."); + + // Check for remote proxies not present locally + let remote_proxies = self.client.list("proxies/").await?; + for obj in &remote_proxies.objects { + if let Some(proxy_id) = obj + .key + .strip_prefix("proxies/") + .and_then(|s| s.strip_suffix(".json")) + { + let exists_locally = crate::proxy_manager::PROXY_MANAGER + .get_stored_proxies() + .iter() + .any(|p| p.id == proxy_id); + if !exists_locally { + let tombstone_key = format!("tombstones/proxies/{}.json", proxy_id); + if let Ok(stat) = self.client.stat(&tombstone_key).await { + if stat.exists { + continue; + } + } + log::info!( + "Proxy {} exists remotely but not locally, downloading...", + proxy_id + ); + if let Err(e) = self.download_proxy(proxy_id, Some(app_handle)).await { + log::warn!("Failed to download missing proxy {}: {}", proxy_id, e); + } + } + } + } + + // Check for remote groups not present locally + let remote_groups = self.client.list("groups/").await?; + for obj in &remote_groups.objects { + if let Some(group_id) = obj + .key + .strip_prefix("groups/") + .and_then(|s| s.strip_suffix(".json")) + { + let exists_locally = { + let group_manager = crate::group_manager::GROUP_MANAGER.lock().unwrap(); + group_manager + .get_all_groups() + .unwrap_or_default() + .iter() + .any(|g| g.id == group_id) + }; + if !exists_locally { + let tombstone_key = format!("tombstones/groups/{}.json", group_id); + if let Ok(stat) = self.client.stat(&tombstone_key).await { + if stat.exists { + continue; + } + } + log::info!( + "Group {} exists remotely but not locally, downloading...", + group_id + ); + if let Err(e) = self.download_group(group_id, Some(app_handle)).await { + log::warn!("Failed to download missing group {}: {}", group_id, e); + } + } + } + } + + // Check for remote VPNs not present locally + let remote_vpns = self.client.list("vpns/").await?; + for obj in &remote_vpns.objects { + if let Some(vpn_id) = obj + .key + .strip_prefix("vpns/") + .and_then(|s| s.strip_suffix(".json")) + { + let exists_locally = { + let storage = crate::vpn::VPN_STORAGE.lock().unwrap(); + storage.load_config(vpn_id).is_ok() + }; + if !exists_locally { + let tombstone_key = format!("tombstones/vpns/{}.json", vpn_id); + if let Ok(stat) = self.client.stat(&tombstone_key).await { + if stat.exists { + continue; + } + } + log::info!( + "VPN {} exists remotely but not locally, downloading...", + vpn_id + ); + if let Err(e) = self.download_vpn(vpn_id, Some(app_handle)).await { + log::warn!("Failed to download missing VPN {}: {}", vpn_id, e); + } + } + } + } + + log::info!("Missing synced entities check complete"); + Ok(()) + } } /// Check if proxy is used by any synced profile @@ -1228,7 +1435,7 @@ pub fn is_proxy_used_by_synced_profile(proxy_id: &str) -> bool { if let Ok(profiles) = profile_manager.list_profiles() { profiles .iter() - .any(|p| p.sync_enabled && p.proxy_id.as_deref() == Some(proxy_id)) + .any(|p| p.is_sync_enabled() && p.proxy_id.as_deref() == Some(proxy_id)) } else { false } @@ -1240,7 +1447,7 @@ pub fn is_group_used_by_synced_profile(group_id: &str) -> bool { if let Ok(profiles) = profile_manager.list_profiles() { profiles .iter() - .any(|p| p.sync_enabled && p.group_id.as_deref() == Some(group_id)) + .any(|p| p.is_sync_enabled() && p.group_id.as_deref() == Some(group_id)) } else { false } @@ -1281,7 +1488,7 @@ pub fn is_vpn_used_by_synced_profile(vpn_id: &str) -> bool { if let Ok(profiles) = profile_manager.list_profiles() { profiles .iter() - .any(|p| p.sync_enabled && p.vpn_id.as_deref() == Some(vpn_id)) + .any(|p| p.is_sync_enabled() && p.vpn_id.as_deref() == Some(vpn_id)) } else { false } @@ -1346,11 +1553,18 @@ pub async fn enable_group_sync_if_needed( } #[tauri::command] -pub async fn set_profile_sync_enabled( +pub async fn set_profile_sync_mode( app_handle: tauri::AppHandle, profile_id: String, - enabled: bool, + sync_mode: String, ) -> Result<(), String> { + let new_mode = match sync_mode.as_str() { + "Disabled" => SyncMode::Disabled, + "Regular" => SyncMode::Regular, + "Encrypted" => SyncMode::Encrypted, + _ => return Err(format!("Invalid sync mode: {sync_mode}")), + }; + let profile_manager = ProfileManager::instance(); let profiles = profile_manager .list_profiles() @@ -1367,9 +1581,14 @@ pub async fn set_profile_sync_enabled( return Err("Cannot modify sync settings for a cross-OS profile".to_string()); } - // If enabling, first check that sync settings are configured - if enabled { - // Cloud auth provides sync settings dynamically — skip local checks + if profile.ephemeral { + return Err("Cannot enable sync for an ephemeral profile".to_string()); + } + + let old_mode = profile.sync_mode; + let enabling = new_mode != SyncMode::Disabled; + + if enabling { let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await; if !cloud_logged_in { @@ -1407,7 +1626,32 @@ pub async fn set_profile_sync_enabled( } } - profile.sync_enabled = enabled; + // If switching to Encrypted, verify password and generate salt + if new_mode == SyncMode::Encrypted { + if !encryption::has_e2e_password() { + return Err("E2E password not set. Please set a password in Settings first.".to_string()); + } + if profile.encryption_salt.is_none() { + profile.encryption_salt = Some(encryption::generate_salt()); + } + } + + // If switching between Regular<->Encrypted, delete remote manifest to force full re-upload + let mode_switched = old_mode != SyncMode::Disabled && enabling && old_mode != new_mode; + if mode_switched { + if let Ok(engine) = SyncEngine::create_from_settings(&app_handle).await { + let manifest_key = format!("profiles/{}/manifest.json", profile_id); + let _ = engine.client.delete(&manifest_key, None).await; + log::info!( + "Deleted remote manifest for profile {} due to sync mode change ({:?} -> {:?})", + profile_id, + old_mode, + new_mode + ); + } + } + + profile.sync_mode = new_mode; profile_manager .save_profile(&profile) @@ -1415,8 +1659,7 @@ pub async fn set_profile_sync_enabled( let _ = events::emit("profiles-changed", ()); - if enabled { - // Check if profile is running to determine status + if enabling { let is_running = profile.process_id.is_some(); let _ = events::emit( @@ -1427,13 +1670,11 @@ pub async fn set_profile_sync_enabled( }), ); - // Queue sync via scheduler (not direct sync) if let Some(scheduler) = super::get_global_scheduler() { scheduler .queue_profile_sync_immediate(profile_id.clone()) .await; - // Auto-enable sync for proxy and group if they exist if let Some(ref proxy_id) = profile.proxy_id { if let Err(e) = enable_proxy_sync_if_needed(proxy_id, &app_handle).await { log::warn!("Failed to enable sync for proxy {}: {}", proxy_id, e); @@ -1459,6 +1700,30 @@ pub async fn set_profile_sync_enabled( log::warn!("Scheduler not initialized, sync will not start"); } } else { + // Delete remote data when disabling sync + 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); + } + } + }); + } + let _ = events::emit( "profile-sync-status", serde_json::json!({ @@ -1468,11 +1733,10 @@ pub async fn set_profile_sync_enabled( ); } - // Report updated sync-enabled profile count to the cloud backend if crate::cloud_auth::CLOUD_AUTH.is_logged_in().await { let sync_count = profile_manager .list_profiles() - .map(|profiles| profiles.iter().filter(|p| p.sync_enabled).count()) + .map(|profiles| profiles.iter().filter(|p| p.is_sync_enabled()).count()) .unwrap_or(0); tokio::spawn(async move { @@ -1506,7 +1770,7 @@ pub async fn request_profile_sync( .find(|p| p.id == profile_uuid) .ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?; - if !profile.sync_enabled { + if !profile.is_sync_enabled() { return Err("Sync is not enabled for this profile".to_string()); } diff --git a/src-tauri/src/sync/manifest.rs b/src-tauri/src/sync/manifest.rs index cf80f2f..43ffe16 100644 --- a/src-tauri/src/sync/manifest.rs +++ b/src-tauri/src/sync/manifest.rs @@ -52,6 +52,8 @@ pub struct SyncManifest { #[serde(rename = "excludeGlobs")] pub exclude_globs: Vec, pub files: Vec, + #[serde(default)] + pub encrypted: bool, } impl SyncManifest { @@ -64,6 +66,7 @@ impl SyncManifest { updated_at: now, exclude_globs, files: Vec::new(), + encrypted: false, } } @@ -547,6 +550,7 @@ mod tests { hash: "def".to_string(), }, ], + encrypted: false, }; let diff = compute_diff(&local, None); @@ -588,6 +592,7 @@ mod tests { hash: "new".to_string(), }, ], + encrypted: false, }; let remote = SyncManifest { @@ -616,6 +621,7 @@ mod tests { hash: "gone".to_string(), }, ], + encrypted: false, }; let diff = compute_diff(&local, Some(&remote)); @@ -634,4 +640,22 @@ mod tests { .files_to_delete_remote .contains(&"deleted.txt".to_string())); } + + #[test] + fn test_manifest_encrypted_flag_default() { + let json = r#"{"version":1,"profileId":"test","generatedAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:00:00Z","excludeGlobs":[],"files":[]}"#; + let manifest: SyncManifest = serde_json::from_str(json).unwrap(); + assert!(!manifest.encrypted); + } + + #[test] + fn test_manifest_with_encrypted_flag() { + let json = r#"{"version":1,"profileId":"test","generatedAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:00:00Z","excludeGlobs":[],"files":[],"encrypted":true}"#; + let manifest: SyncManifest = serde_json::from_str(json).unwrap(); + assert!(manifest.encrypted); + + let serialized = serde_json::to_string(&manifest).unwrap(); + let deserialized: SyncManifest = serde_json::from_str(&serialized).unwrap(); + assert!(deserialized.encrypted); + } } diff --git a/src-tauri/src/sync/mod.rs b/src-tauri/src/sync/mod.rs index 45e9b4e..c4d366e 100644 --- a/src-tauri/src/sync/mod.rs +++ b/src-tauri/src/sync/mod.rs @@ -1,4 +1,5 @@ mod client; +pub mod encryption; mod engine; pub mod manifest; pub mod scheduler; @@ -6,13 +7,15 @@ pub mod subscription; pub mod types; pub use client::SyncClient; +pub use encryption::{check_has_e2e_password, delete_e2e_password, set_e2e_password}; pub use engine::{ 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_vpn_in_use_by_synced_profile, is_vpn_used_by_synced_profile, - request_profile_sync, set_group_sync_enabled, set_profile_sync_enabled, set_proxy_sync_enabled, - set_vpn_sync_enabled, sync_profile, trigger_sync_for_profile, SyncEngine, + 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, set_group_sync_enabled, + set_profile_sync_mode, set_proxy_sync_enabled, set_vpn_sync_enabled, sync_profile, + trigger_sync_for_profile, SyncEngine, }; pub use manifest::{compute_diff, generate_manifest, HashCache, ManifestDiff, SyncManifest}; pub use scheduler::{get_global_scheduler, set_global_scheduler, SyncScheduler}; diff --git a/src-tauri/src/sync/scheduler.rs b/src-tauri/src/sync/scheduler.rs index ce994b3..a63bff4 100644 --- a/src-tauri/src/sync/scheduler.rs +++ b/src-tauri/src/sync/scheduler.rs @@ -232,7 +232,10 @@ impl SyncScheduler { } }; - let sync_enabled_profiles: Vec<_> = profiles.into_iter().filter(|p| p.sync_enabled).collect(); + let sync_enabled_profiles: Vec<_> = profiles + .into_iter() + .filter(|p| p.is_sync_enabled()) + .collect(); if sync_enabled_profiles.is_empty() { log::debug!("No sync-enabled profiles found"); @@ -353,7 +356,7 @@ impl SyncScheduler { profile_manager.list_profiles().ok().and_then(|profiles| { profiles .into_iter() - .find(|p| p.id.to_string() == profile_id && p.sync_enabled) + .find(|p| p.id.to_string() == profile_id && p.is_sync_enabled()) }) }; @@ -615,7 +618,7 @@ impl SyncScheduler { } } - async fn process_pending_tombstones(&self, app_handle: &tauri::AppHandle) { + async fn process_pending_tombstones(&self, _app_handle: &tauri::AppHandle) { let tombstones: Vec<(String, String)> = { let mut pending = self.pending_tombstones.lock().await; std::mem::take(&mut *pending) @@ -629,67 +632,68 @@ impl SyncScheduler { log::info!("Processing tombstone for {} {}", entity_type, entity_id); match entity_type.as_str() { "profile" => { - let exists_locally = { - let profile_manager = ProfileManager::instance(); + let profile_manager = ProfileManager::instance(); + let profile_to_delete = { if let Ok(profiles) = profile_manager.list_profiles() { let profile_uuid = uuid::Uuid::parse_str(&entity_id).ok(); - profile_uuid - .as_ref() - .map(|uuid| profiles.iter().any(|p| p.id == *uuid)) - .unwrap_or(false) + profile_uuid.and_then(|uuid| profiles.into_iter().find(|p| p.id == uuid)) } else { - false + None } }; - if exists_locally { - // Profile exists locally but was deleted remotely - delete locally + if let Some(mut profile) = profile_to_delete { log::info!( - "Profile {} exists locally, deleting due to remote tombstone", + "Profile {} was deleted remotely, disabling sync locally", entity_id ); - // Note: We don't actually delete here to avoid data loss. - // The user should be notified or we could add a confirmation step. - // For now, just log it. - } else { - // Profile doesn't exist locally - check if it still exists remotely - // (tombstone might have been created but profile files still exist) - // Try to download it - match SyncEngine::create_from_settings(app_handle).await { - Ok(engine) => { - if let Ok(true) = engine - .download_profile_if_missing(app_handle, &entity_id) - .await - { - log::info!( - "Downloaded missing profile {} from remote storage", - entity_id - ); - } - } - Err(e) => { - log::debug!("Sync not configured, skipping profile download: {}", e); - } + profile.sync_mode = crate::profile::types::SyncMode::Disabled; + if let Err(e) = profile_manager.save_profile(&profile) { + log::warn!("Failed to disable sync for profile {}: {}", entity_id, e); + } else { + log::info!( + "Profile {} sync disabled due to remote tombstone (local copy kept)", + entity_id + ); + let _ = events::emit("profiles-changed", ()); } } } "proxy" => { - log::debug!( - "Proxy tombstone for {} - local deletion not implemented", - entity_id - ); + let proxy_manager = &crate::proxy_manager::PROXY_MANAGER; + let proxies = proxy_manager.get_stored_proxies(); + if let Some(proxy) = proxies.iter().find(|p| p.id == entity_id) { + if proxy.sync_enabled { + log::info!("Proxy {} was deleted remotely, deleting locally", entity_id); + let proxy_file = proxy_manager.get_proxy_file_path(&entity_id); + if proxy_file.exists() { + let _ = std::fs::remove_file(&proxy_file); + } + proxy_manager.remove_from_memory(&entity_id); + let _ = events::emit("stored-proxies-changed", ()); + } + } } "group" => { - log::debug!( - "Group tombstone for {} - local deletion not implemented", - entity_id - ); + let group_manager = crate::group_manager::GROUP_MANAGER.lock().unwrap(); + let groups = group_manager.get_all_groups().unwrap_or_default(); + if let Some(group) = groups.iter().find(|g| g.id == entity_id) { + if group.sync_enabled { + log::info!("Group {} was deleted remotely, deleting locally", entity_id); + let _ = group_manager.delete_group_internal(&entity_id); + let _ = events::emit("groups-changed", ()); + } + } } "vpn" => { - log::debug!( - "VPN tombstone for {} - local deletion not implemented", - entity_id - ); + let storage = crate::vpn::VPN_STORAGE.lock().unwrap(); + if let Ok(vpn) = storage.load_config(&entity_id) { + if vpn.sync_enabled { + log::info!("VPN {} was deleted remotely, deleting locally", entity_id); + let _ = storage.delete_config(&entity_id); + let _ = events::emit("vpn-configs-changed", ()); + } + } } _ => {} } diff --git a/src-tauri/src/vpn/openvpn.rs b/src-tauri/src/vpn/openvpn.rs index 2349827..0b249cd 100644 --- a/src-tauri/src/vpn/openvpn.rs +++ b/src-tauri/src/vpn/openvpn.rs @@ -73,7 +73,13 @@ impl OpenVpnTunnel { #[cfg(windows)] { - if let Ok(output) = Command::new("where").arg("openvpn").output() { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + if let Ok(output) = Command::new("where") + .arg("openvpn") + .creation_flags(CREATE_NO_WINDOW) + .output() + { if output.status.success() { let path = String::from_utf8_lossy(&output.stdout) .lines() diff --git a/src-tauri/src/vpn/openvpn_socks5.rs b/src-tauri/src/vpn/openvpn_socks5.rs index 81dfed1..3c954b1 100644 --- a/src-tauri/src/vpn/openvpn_socks5.rs +++ b/src-tauri/src/vpn/openvpn_socks5.rs @@ -45,7 +45,13 @@ impl OpenVpnSocks5Server { #[cfg(windows)] { - if let Ok(output) = Command::new("where").arg("openvpn").output() { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + if let Ok(output) = Command::new("where") + .arg("openvpn") + .creation_flags(CREATE_NO_WINDOW) + .output() + { if output.status.success() { let path = String::from_utf8_lossy(&output.stdout) .lines() diff --git a/src-tauri/src/vpn/storage.rs b/src-tauri/src/vpn/storage.rs index c578070..f783741 100644 --- a/src-tauri/src/vpn/storage.rs +++ b/src-tauri/src/vpn/storage.rs @@ -339,7 +339,7 @@ impl VpnStorage { } let id = Uuid::new_v4().to_string(); - let sync_enabled = crate::cloud_auth::CLOUD_AUTH.has_active_paid_subscription_sync(); + let sync_enabled = crate::sync::is_sync_configured(); let config = VpnConfig { id, @@ -408,7 +408,7 @@ impl VpnStorage { let base = filename.trim_end_matches(".conf").trim_end_matches(".ovpn"); format!("{} ({})", base, vpn_type) }); - let sync_enabled = crate::cloud_auth::CLOUD_AUTH.has_active_paid_subscription_sync(); + let sync_enabled = crate::sync::is_sync_configured(); let config = VpnConfig { id, diff --git a/src-tauri/src/vpn_worker_runner.rs b/src-tauri/src/vpn_worker_runner.rs index 71b8905..3b43127 100644 --- a/src-tauri/src/vpn_worker_runner.rs +++ b/src-tauri/src/vpn_worker_runner.rs @@ -210,9 +210,12 @@ pub async fn stop_vpn_worker(id: &str) -> Result = None; for (id, instance) in &inner.instances { if let Some(path) = &instance.profile_path { - if path == profile_path { + let instance_path = std::path::Path::new(path) + .canonicalize() + .unwrap_or_else(|_| std::path::Path::new(path).to_path_buf()); + if instance_path == target_path { found_id = Some(id.clone()); break; } @@ -667,7 +687,6 @@ impl WayfernManager { let sysinfo_pid = sysinfo::Pid::from_u32(pid); if system.process(sysinfo_pid).is_some() { - // Process is still running return Some(WayfernLaunchResult { id: id.clone(), processId: instance.process_id, @@ -676,7 +695,6 @@ impl WayfernManager { cdp_port: instance.cdp_port, }); } else { - // Process has died (e.g., Cmd+Q), remove from instances log::info!( "Wayfern process {} for profile {} is no longer running, cleaning up", pid, @@ -689,6 +707,101 @@ impl WayfernManager { } } + // If not found in in-memory instances, scan system processes. + // This handles the case where the GUI was restarted but Wayfern is still running. + if let Some((pid, found_profile_path, cdp_port)) = + Self::find_wayfern_process_by_profile(&target_path) + { + log::info!( + "Found running Wayfern process (PID: {}) for profile path via system scan", + pid + ); + + let instance_id = format!("recovered_{}", pid); + inner.instances.insert( + instance_id.clone(), + WayfernInstance { + id: instance_id.clone(), + process_id: Some(pid), + profile_path: Some(found_profile_path.clone()), + url: None, + cdp_port, + }, + ); + + return Some(WayfernLaunchResult { + id: instance_id, + processId: Some(pid), + profilePath: Some(found_profile_path), + url: None, + cdp_port, + }); + } + + None + } + + /// Scan system processes to find a Wayfern/Chromium process using a specific profile path + fn find_wayfern_process_by_profile( + target_path: &std::path::Path, + ) -> Option<(u32, String, Option)> { + use sysinfo::{ProcessRefreshKind, RefreshKind, System}; + + let system = System::new_with_specifics( + RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()), + ); + + let target_path_str = target_path.to_string_lossy(); + + for (pid, process) in system.processes() { + let cmd = process.cmd(); + if cmd.is_empty() { + continue; + } + + let exe_name = process.name().to_string_lossy().to_lowercase(); + let is_chromium_like = exe_name.contains("wayfern") + || exe_name.contains("chromium") + || exe_name.contains("chrome"); + + if !is_chromium_like { + continue; + } + + // Skip child processes (renderer, GPU, utility, zygote, etc.) + // Only the main browser process lacks a --type= argument + let is_child = cmd + .iter() + .any(|a| a.to_str().is_some_and(|s| s.starts_with("--type="))); + if is_child { + continue; + } + + let mut matched = false; + let mut cdp_port: Option = None; + + for arg in cmd.iter() { + if let Some(arg_str) = arg.to_str() { + if let Some(dir_val) = arg_str.strip_prefix("--user-data-dir=") { + let cmd_path = std::path::Path::new(dir_val) + .canonicalize() + .unwrap_or_else(|_| std::path::Path::new(dir_val).to_path_buf()); + if cmd_path == target_path { + matched = true; + } + } + + if let Some(port_val) = arg_str.strip_prefix("--remote-debugging-port=") { + cdp_port = port_val.parse().ok(); + } + } + } + + if matched { + return Some((pid.as_u32(), target_path_str.to_string(), cdp_port)); + } + } + None } diff --git a/src/app/page.tsx b/src/app/page.tsx index fdc7495..53f12b3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -7,8 +7,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog"; import { CommercialTrialModal } from "@/components/commercial-trial-modal"; import { CookieCopyDialog } from "@/components/cookie-copy-dialog"; -import { CookieExportDialog } from "@/components/cookie-export-dialog"; -import { CookieImportDialog } from "@/components/cookie-import-dialog"; +import { CookieManagementDialog } from "@/components/cookie-management-dialog"; import { CreateProfileDialog } from "@/components/create-profile-dialog"; import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"; import { GroupAssignmentDialog } from "@/components/group-assignment-dialog"; @@ -28,6 +27,7 @@ import { SettingsDialog } from "@/components/settings-dialog"; import { SyncAllDialog } from "@/components/sync-all-dialog"; import { SyncConfigDialog } from "@/components/sync-config-dialog"; import { WayfernTermsDialog } from "@/components/wayfern-terms-dialog"; +import { WindowResizeWarningDialog } from "@/components/window-resize-warning-dialog"; import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications"; import { useCloudAuth } from "@/hooks/use-cloud-auth"; import { useCommercialTrial } from "@/hooks/use-commercial-trial"; @@ -144,12 +144,12 @@ export default function Home() { const [proxyAssignmentDialogOpen, setProxyAssignmentDialogOpen] = useState(false); const [cookieCopyDialogOpen, setCookieCopyDialogOpen] = useState(false); - const [cookieImportDialogOpen, setCookieImportDialogOpen] = useState(false); - const [currentProfileForCookieImport, setCurrentProfileForCookieImport] = - useState(null); - const [cookieExportDialogOpen, setCookieExportDialogOpen] = useState(false); - const [currentProfileForCookieExport, setCurrentProfileForCookieExport] = - useState(null); + const [cookieManagementDialogOpen, setCookieManagementDialogOpen] = + useState(false); + const [ + currentProfileForCookieManagement, + setCurrentProfileForCookieManagement, + ] = useState(null); const [selectedProfilesForCookies, setSelectedProfilesForCookies] = useState< string[] >([]); @@ -167,6 +167,10 @@ export default function Home() { useState(null); const [hasCheckedStartupPrompt, setHasCheckedStartupPrompt] = useState(false); const [launchOnLoginDialogOpen, setLaunchOnLoginDialogOpen] = useState(false); + const [windowResizeWarningOpen, setWindowResizeWarningOpen] = useState(false); + const windowResizeWarningResolver = useRef< + ((proceed: boolean) => void) | null + >(null); const [permissionDialogOpen, setPermissionDialogOpen] = useState(false); const [currentPermissionType, setCurrentPermissionType] = useState("microphone"); @@ -528,6 +532,26 @@ export default function Home() { const launchProfile = useCallback(async (profile: BrowserProfile) => { console.log("Starting launch for profile:", profile.name); + // Show one-time warning about window resizing for fingerprinted browsers + if (profile.browser === "camoufox" || profile.browser === "wayfern") { + try { + const dismissed = await invoke( + "get_window_resize_warning_dismissed", + ); + if (!dismissed) { + const proceed = await new Promise((resolve) => { + windowResizeWarningResolver.current = resolve; + setWindowResizeWarningOpen(true); + }); + if (!proceed) { + return; + } + } + } catch (error) { + console.error("Failed to check window resize warning:", error); + } + } + try { const result = await invoke("launch_browser_profile", { profile, @@ -537,7 +561,6 @@ export default function Home() { console.error("Failed to launch browser:", err); const errorMessage = err instanceof Error ? err.message : String(err); showErrorToast(`Failed to launch browser: ${errorMessage}`); - // Re-throw the error so the table component can handle loading state cleanup throw err; } }, []); @@ -698,14 +721,9 @@ export default function Home() { setCookieCopyDialogOpen(true); }, []); - const handleImportCookies = useCallback((profile: BrowserProfile) => { - setCurrentProfileForCookieImport(profile); - setCookieImportDialogOpen(true); - }, []); - - const handleExportCookies = useCallback((profile: BrowserProfile) => { - setCurrentProfileForCookieExport(profile); - setCookieExportDialogOpen(true); + const handleOpenCookieManagement = useCallback((profile: BrowserProfile) => { + setCurrentProfileForCookieManagement(profile); + setCookieManagementDialogOpen(true); }, []); const handleGroupAssignmentComplete = useCallback(async () => { @@ -732,10 +750,10 @@ export default function Home() { const handleToggleProfileSync = useCallback( async (profile: BrowserProfile) => { try { - const enabling = !profile.sync_enabled; - await invoke("set_profile_sync_enabled", { + const enabling = !profile.sync_mode || profile.sync_mode === "Disabled"; + await invoke("set_profile_sync_mode", { profileId: profile.id, - enabled: enabling, + syncMode: enabling ? "Regular" : "Disabled", }); if (enabling) { userInitiatedSyncIds.current.add(profile.id); @@ -1014,8 +1032,7 @@ export default function Home() { onRenameProfile={handleRenameProfile} onConfigureCamoufox={handleConfigureCamoufox} onCopyCookiesToProfile={handleCopyCookiesToProfile} - onImportCookies={handleImportCookies} - onExportCookies={handleExportCookies} + onOpenCookieManagement={handleOpenCookieManagement} runningProfiles={runningProfiles} isUpdating={isUpdating} onDeleteSelectedProfiles={handleDeleteSelectedProfiles} @@ -1159,22 +1176,13 @@ export default function Home() { onCopyComplete={() => setSelectedProfilesForCookies([])} /> - { - setCookieImportDialogOpen(false); - setCurrentProfileForCookieImport(null); + setCookieManagementDialogOpen(false); + setCurrentProfileForCookieManagement(null); }} - profile={currentProfileForCookieImport} - /> - - { - setCookieExportDialogOpen(false); - setCurrentProfileForCookieExport(null); - }} - profile={currentProfileForCookieExport} + profile={currentProfileForCookieManagement} /> setLaunchOnLoginDialogOpen(false)} /> + + { + setWindowResizeWarningOpen(false); + windowResizeWarningResolver.current?.(proceed); + windowResizeWarningResolver.current = null; + }} + /> ); } diff --git a/src/components/camoufox-config-dialog.tsx b/src/components/camoufox-config-dialog.tsx index f8a2b76..2b5be26 100644 --- a/src/components/camoufox-config-dialog.tsx +++ b/src/components/camoufox-config-dialog.tsx @@ -26,9 +26,7 @@ const getCurrentOS = (): CamoufoxOS => { return "linux"; }; -import { LuLock } from "react-icons/lu"; import { LoadingButton } from "./loading-button"; -import { ProBadge } from "./ui/pro-badge"; import { RippleButton } from "./ui/ripple"; interface CamoufoxConfigDialogProps { @@ -157,34 +155,27 @@ export function CamoufoxConfigDialog({ -
+
{profile.browser === "wayfern" ? ( ) : ( )} - {!crossOsUnlocked && ( -
- -

- Fingerprint editing is a Pro feature -

- -
- )}
@@ -192,7 +183,7 @@ export function CamoufoxConfigDialog({ {isRunning ? "Close" : "Cancel"} - {!isRunning && crossOsUnlocked && ( + {!isRunning && ( void; - profile: BrowserProfile | null; -} - -export function CookieExportDialog({ - isOpen, - onClose, - profile, -}: CookieExportDialogProps) { - const [format, setFormat] = useState<"netscape" | "json">("json"); - const [isExporting, setIsExporting] = useState(false); - - const handleClose = useCallback(() => { - setFormat("json"); - setIsExporting(false); - onClose(); - }, [onClose]); - - const handleExport = useCallback(async () => { - if (!profile) return; - setIsExporting(true); - try { - const content = await invoke("export_profile_cookies", { - profileId: profile.id, - format, - }); - - const ext = format === "json" ? "json" : "txt"; - const defaultName = `${profile.name}_cookies.${ext}`; - - const filePath = await save({ - defaultPath: defaultName, - filters: [ - { - name: format === "json" ? "JSON" : "Text", - extensions: [ext], - }, - ], - }); - - if (!filePath) { - setIsExporting(false); - return; - } - - await writeTextFile(filePath, content); - toast.success("Cookies exported successfully"); - handleClose(); - } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)); - } finally { - setIsExporting(false); - } - }, [profile, format, handleClose]); - - return ( - - - - Export Cookies - - Export cookies from this profile. - - - -
-
- - -
-
- - - - Cancel - - void handleExport()} - > - - Export - - -
-
- ); -} diff --git a/src/components/cookie-import-dialog.tsx b/src/components/cookie-import-dialog.tsx deleted file mode 100644 index cc5e5e2..0000000 --- a/src/components/cookie-import-dialog.tsx +++ /dev/null @@ -1,212 +0,0 @@ -"use client"; - -import { invoke } from "@tauri-apps/api/core"; -import { useCallback, useState } from "react"; -import { LuUpload } from "react-icons/lu"; -import { toast } from "sonner"; -import { LoadingButton } from "@/components/loading-button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { RippleButton } from "@/components/ui/ripple"; -import type { BrowserProfile } from "@/types"; - -interface CookieImportResult { - cookies_imported: number; - cookies_replaced: number; - errors: string[]; -} - -interface CookieImportDialogProps { - isOpen: boolean; - onClose: () => void; - profile: BrowserProfile | null; -} - -const countCookies = (content: string): number => { - const trimmed = content.trim(); - if (trimmed.startsWith("[")) { - try { - const arr = JSON.parse(trimmed); - if (Array.isArray(arr)) return arr.length; - } catch { - // Fall through to Netscape counting - } - } - return content.split("\n").filter((line) => { - const l = line.trim(); - return l && !l.startsWith("#"); - }).length; -}; - -export function CookieImportDialog({ - isOpen, - onClose, - profile, -}: CookieImportDialogProps) { - const [fileContent, setFileContent] = useState(null); - const [fileName, setFileName] = useState(null); - const [cookieCount, setCookieCount] = useState(0); - const [isImporting, setIsImporting] = useState(false); - const [result, setResult] = useState(null); - - const resetState = useCallback(() => { - setFileContent(null); - setFileName(null); - setCookieCount(0); - setIsImporting(false); - setResult(null); - }, []); - - const handleClose = useCallback(() => { - resetState(); - onClose(); - }, [resetState, onClose]); - - const handleFileRead = useCallback((file: File) => { - const reader = new FileReader(); - reader.onload = (e) => { - const content = e.target?.result as string; - setFileContent(content); - setFileName(file.name); - setCookieCount(countCookies(content)); - }; - reader.onerror = () => { - toast.error("Failed to read file"); - }; - reader.readAsText(file); - }, []); - - const handleImport = useCallback(async () => { - if (!fileContent || !profile) return; - setIsImporting(true); - try { - const importResult = await invoke( - "import_cookies_from_file", - { - profileId: profile.id, - content: fileContent, - }, - ); - setResult(importResult); - } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)); - } finally { - setIsImporting(false); - } - }, [fileContent, profile]); - - return ( - - - - Import Cookies - - {!fileContent && - "Import cookies from a Netscape or JSON format file."} - {fileContent && - !result && - `${cookieCount} cookies found in ${fileName}`} - {result && "Cookie import completed"} - - - - {!fileContent && ( -
-
- document.getElementById("cookie-file-input")?.click() - } - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - document.getElementById("cookie-file-input")?.click(); - } - }} - > - -

- Click to choose a cookie file -
- (.txt, .cookies, or .json) -

- { - const file = e.target.files?.[0]; - if (file) handleFileRead(file); - e.target.value = ""; - }} - /> -
-
- )} - - {fileContent && !result && ( -
-
-
-
{fileName}
-
- {cookieCount} cookies found -
-
-
-
- )} - - {result && ( -
-
-
- Successfully imported {result.cookies_imported} cookies ( - {result.cookies_replaced} replaced) -
- {result.errors.length > 0 && ( -
- {result.errors.length} line(s) skipped -
- )} -
-
- )} - - - {!fileContent && ( - - Cancel - - )} - - {fileContent && !result && ( - <> - - Back - - void handleImport()} - disabled={cookieCount === 0} - > - Import - - - )} - - {result && Done} - -
-
- ); -} diff --git a/src/components/cookie-management-dialog.tsx b/src/components/cookie-management-dialog.tsx new file mode 100644 index 0000000..e0cdfb6 --- /dev/null +++ b/src/components/cookie-management-dialog.tsx @@ -0,0 +1,649 @@ +"use client"; + +import { invoke } from "@tauri-apps/api/core"; +import { save } from "@tauri-apps/plugin-dialog"; +import { writeTextFile } from "@tauri-apps/plugin-fs"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { LuChevronDown, LuChevronRight, LuUpload } from "react-icons/lu"; +import { toast } from "sonner"; +import { LoadingButton } from "@/components/loading-button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { RippleButton } from "@/components/ui/ripple"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import type { + BrowserProfile, + CookieReadResult, + DomainCookies, + UnifiedCookie, +} from "@/types"; + +interface CookieImportResult { + cookies_imported: number; + cookies_replaced: number; + errors: string[]; +} + +interface CookieManagementDialogProps { + isOpen: boolean; + onClose: () => void; + profile: BrowserProfile | null; + initialTab?: "import" | "export"; +} + +type SelectionState = { + [domain: string]: { + allSelected: boolean; + cookies: Set; + }; +}; + +const countCookies = (content: string): number => { + const trimmed = content.trim(); + if (trimmed.startsWith("[")) { + try { + const arr = JSON.parse(trimmed); + if (Array.isArray(arr)) return arr.length; + } catch { + // Fall through to Netscape counting + } + } + return content.split("\n").filter((line) => { + const l = line.trim(); + return l && !l.startsWith("#"); + }).length; +}; + +function formatJsonCookies(cookies: UnifiedCookie[]): string { + const arr = cookies.map((c) => { + const sameSite = + c.same_site === 1 + ? "lax" + : c.same_site === 2 + ? "strict" + : "no_restriction"; + return { + name: c.name, + value: c.value, + domain: c.domain, + path: c.path, + secure: c.is_secure, + httpOnly: c.is_http_only, + sameSite, + expirationDate: c.expires, + session: c.expires === 0, + hostOnly: !c.domain.startsWith("."), + }; + }); + return JSON.stringify(arr, null, 2); +} + +function formatNetscapeCookies(cookies: UnifiedCookie[]): string { + const lines = ["# Netscape HTTP Cookie File"]; + for (const c of cookies) { + const flag = c.domain.startsWith(".") ? "TRUE" : "FALSE"; + const secure = c.is_secure ? "TRUE" : "FALSE"; + lines.push( + `${c.domain}\t${flag}\t${c.path}\t${secure}\t${c.expires}\t${c.name}\t${c.value}`, + ); + } + return lines.join("\n"); +} + +function initSelectionFromCookieData(data: CookieReadResult): SelectionState { + const sel: SelectionState = {}; + for (const d of data.domains) { + sel[d.domain] = { + allSelected: true, + cookies: new Set(d.cookies.map((c) => c.name)), + }; + } + return sel; +} + +export function CookieManagementDialog({ + isOpen, + onClose, + profile, + initialTab = "import", +}: CookieManagementDialogProps) { + // Import state + const [fileContent, setFileContent] = useState(null); + const [fileName, setFileName] = useState(null); + const [cookieCount, setCookieCount] = useState(0); + const [isImporting, setIsImporting] = useState(false); + const [importResult, setImportResult] = useState( + null, + ); + + // Export state + const [format, setFormat] = useState<"netscape" | "json">("json"); + const [isExporting, setIsExporting] = useState(false); + const [exportCookieData, setExportCookieData] = + useState(null); + const [isLoadingExportCookies, setIsLoadingExportCookies] = useState(false); + const [exportSelection, setExportSelection] = useState({}); + const [expandedDomains, setExpandedDomains] = useState>( + new Set(), + ); + const [activeTab, setActiveTab] = useState(initialTab); + + const selectedExportCount = useMemo(() => { + let count = 0; + for (const domain of Object.keys(exportSelection)) { + const ds = exportSelection[domain]; + if (ds.allSelected) { + const domainData = exportCookieData?.domains.find( + (d) => d.domain === domain, + ); + count += domainData?.cookie_count || 0; + } else { + count += ds.cookies.size; + } + } + return count; + }, [exportSelection, exportCookieData]); + + const loadExportCookies = useCallback( + async (profileId: string) => { + if (exportCookieData) return; + setIsLoadingExportCookies(true); + try { + const result = await invoke("read_profile_cookies", { + profileId, + }); + setExportCookieData(result); + setExportSelection(initSelectionFromCookieData(result)); + } catch (err) { + toast.error( + `Failed to load cookies: ${err instanceof Error ? err.message : String(err)}`, + ); + } finally { + setIsLoadingExportCookies(false); + } + }, + [exportCookieData], + ); + + useEffect(() => { + if (activeTab === "export" && profile && !exportCookieData) { + void loadExportCookies(profile.id); + } + }, [activeTab, profile, exportCookieData, loadExportCookies]); + + const resetImportState = useCallback(() => { + setFileContent(null); + setFileName(null); + setCookieCount(0); + setIsImporting(false); + setImportResult(null); + }, []); + + const resetExportState = useCallback(() => { + setFormat("json"); + setIsExporting(false); + setExportCookieData(null); + setExportSelection({}); + setExpandedDomains(new Set()); + }, []); + + const handleClose = useCallback(() => { + resetImportState(); + resetExportState(); + setActiveTab(initialTab); + onClose(); + }, [resetImportState, resetExportState, onClose, initialTab]); + + const handleTabChange = useCallback( + (tab: string) => { + setActiveTab(tab); + resetImportState(); + if (tab !== "export") { + resetExportState(); + } + }, + [resetImportState, resetExportState], + ); + + const handleFileRead = useCallback((file: File) => { + const reader = new FileReader(); + reader.onload = (e) => { + const content = e.target?.result as string; + setFileContent(content); + setFileName(file.name); + setCookieCount(countCookies(content)); + }; + reader.onerror = () => { + toast.error("Failed to read file"); + }; + reader.readAsText(file); + }, []); + + const handleImport = useCallback(async () => { + if (!fileContent || !profile) return; + setIsImporting(true); + try { + const result = await invoke( + "import_cookies_from_file", + { + profileId: profile.id, + content: fileContent, + }, + ); + setImportResult(result); + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)); + } finally { + setIsImporting(false); + } + }, [fileContent, profile]); + + const getSelectedCookies = useCallback((): UnifiedCookie[] => { + if (!exportCookieData) return []; + const result: UnifiedCookie[] = []; + for (const domain of exportCookieData.domains) { + const ds = exportSelection[domain.domain]; + if (!ds) continue; + if (ds.allSelected) { + result.push(...domain.cookies); + } else { + result.push(...domain.cookies.filter((c) => ds.cookies.has(c.name))); + } + } + return result; + }, [exportCookieData, exportSelection]); + + const handleExport = useCallback(async () => { + if (!profile) return; + setIsExporting(true); + try { + const cookies = getSelectedCookies(); + const content = + format === "json" + ? formatJsonCookies(cookies) + : formatNetscapeCookies(cookies); + + const ext = format === "json" ? "json" : "txt"; + const defaultName = `${profile.name}_cookies.${ext}`; + + const filePath = await save({ + defaultPath: defaultName, + filters: [ + { + name: format === "json" ? "JSON" : "Text", + extensions: [ext], + }, + ], + }); + + if (!filePath) { + setIsExporting(false); + return; + } + + await writeTextFile(filePath, content); + toast.success("Cookies exported successfully"); + handleClose(); + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)); + } finally { + setIsExporting(false); + } + }, [profile, format, getSelectedCookies, handleClose]); + + const toggleDomain = useCallback( + (domain: string, cookies: UnifiedCookie[]) => { + setExportSelection((prev) => { + const current = prev[domain]; + if (current?.allSelected) { + const next = { ...prev }; + delete next[domain]; + return next; + } + return { + ...prev, + [domain]: { + allSelected: true, + cookies: new Set(cookies.map((c) => c.name)), + }, + }; + }); + }, + [], + ); + + const toggleCookie = useCallback( + (domain: string, cookieName: string, totalCookies: number) => { + setExportSelection((prev) => { + const current = prev[domain] || { + allSelected: false, + cookies: new Set(), + }; + const newCookies = new Set(current.cookies); + if (newCookies.has(cookieName)) { + newCookies.delete(cookieName); + } else { + newCookies.add(cookieName); + } + if (newCookies.size === 0) { + const next = { ...prev }; + delete next[domain]; + return next; + } + return { + ...prev, + [domain]: { + allSelected: newCookies.size === totalCookies, + cookies: newCookies, + }, + }; + }); + }, + [], + ); + + const toggleExpand = useCallback((domain: string) => { + setExpandedDomains((prev) => { + const next = new Set(prev); + if (next.has(domain)) { + next.delete(domain); + } else { + next.add(domain); + } + return next; + }); + }, []); + + const toggleSelectAll = useCallback(() => { + if (!exportCookieData) return; + if (selectedExportCount === exportCookieData.total_count) { + setExportSelection({}); + } else { + setExportSelection(initSelectionFromCookieData(exportCookieData)); + } + }, [exportCookieData, selectedExportCount]); + + return ( + + + + Cookie Management + + + + + Import + Export + + + + {!fileContent && ( +
+

+ Import cookies from a Netscape or JSON format file. +

+
+ document.getElementById("cookie-file-input")?.click() + } + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + document.getElementById("cookie-file-input")?.click(); + } + }} + > + +

+ Click to choose a cookie file +
+ (.txt, .cookies, or .json) +

+ { + const file = e.target.files?.[0]; + if (file) handleFileRead(file); + e.target.value = ""; + }} + /> +
+
+ )} + + {fileContent && !importResult && ( +
+
+
+
{fileName}
+
+ {cookieCount} cookies found +
+
+
+
+ + Back + + void handleImport()} + disabled={cookieCount === 0} + > + Import + +
+
+ )} + + {importResult && ( +
+
+
+ Successfully imported {importResult.cookies_imported}{" "} + cookies ({importResult.cookies_replaced} replaced) +
+ {importResult.errors.length > 0 && ( +
+ {importResult.errors.length} line(s) skipped +
+ )} +
+
+ Done +
+
+ )} +
+ + +
+ + +
+ +
+
+ + {exportCookieData && exportCookieData.total_count > 0 && ( + + )} +
+ + {isLoadingExportCookies ? ( +
+
+
+ ) : !exportCookieData || exportCookieData.domains.length === 0 ? ( +
+ No cookies found in this profile +
+ ) : ( + +
+ {exportCookieData.domains.map((domain) => ( + + ))} +
+
+ )} +
+ +
+ + Cancel + + void handleExport()} + disabled={selectedExportCount === 0} + > + Export + +
+ + + +
+ ); +} + +interface ExportDomainRowProps { + domain: DomainCookies; + selection: SelectionState; + isExpanded: boolean; + onToggleDomain: (domain: string, cookies: UnifiedCookie[]) => void; + onToggleCookie: ( + domain: string, + cookieName: string, + totalCookies: number, + ) => void; + onToggleExpand: (domain: string) => void; +} + +function ExportDomainRow({ + domain, + selection, + isExpanded, + onToggleDomain, + onToggleCookie, + onToggleExpand, +}: ExportDomainRowProps) { + const domainSelection = selection[domain.domain]; + const isAllSelected = domainSelection?.allSelected || false; + const selectedCount = domainSelection?.cookies.size || 0; + const isPartial = + selectedCount > 0 && selectedCount < domain.cookie_count && !isAllSelected; + + return ( +
+
+ onToggleDomain(domain.domain, domain.cookies)} + className={isPartial ? "opacity-70" : ""} + /> + +
+ {isExpanded && ( +
+ {domain.cookies.map((cookie) => { + const isSelected = + domainSelection?.cookies.has(cookie.name) || false; + return ( +
+ + onToggleCookie( + domain.domain, + cookie.name, + domain.cookie_count, + ) + } + /> + {cookie.name} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/components/create-profile-dialog.tsx b/src/components/create-profile-dialog.tsx index 7323a08..bbc45cc 100644 --- a/src/components/create-profile-dialog.tsx +++ b/src/components/create-profile-dialog.tsx @@ -2,8 +2,8 @@ import { invoke } from "@tauri-apps/api/core"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { GoPlus } from "react-icons/go"; -import { LuLock } from "react-icons/lu"; import { LoadingButton } from "@/components/loading-button"; import { ProxyFormDialog } from "@/components/proxy-form-dialog"; import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form"; @@ -18,7 +18,6 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { ProBadge } from "@/components/ui/pro-badge"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Select, @@ -31,7 +30,6 @@ import { } from "@/components/ui/select"; import { Tabs, TabsContent } from "@/components/ui/tabs"; import { WayfernConfigForm } from "@/components/wayfern-config-form"; - import { useBrowserDownload } from "@/hooks/use-browser-download"; import { useProxyEvents } from "@/hooks/use-proxy-events"; import { useVpnEvents } from "@/hooks/use-vpn-events"; @@ -117,6 +115,7 @@ export function CreateProfileDialog({ selectedGroupId, crossOsUnlocked = false, }: CreateProfileDialogProps) { + const { t } = useTranslation(); const [profileName, setProfileName] = useState(""); const [currentStep, setCurrentStep] = useState< "browser-selection" | "browser-config" @@ -180,6 +179,7 @@ export function CreateProfileDialog({ downloadBrowser, loadDownloadedVersions, isVersionDownloaded, + downloadedVersions, } = useBrowserDownload(); const loadSupportedBrowsers = useCallback(async () => { @@ -338,6 +338,26 @@ export function CreateProfileDialog({ [releaseTypes], ); + const getCreatableVersion = useCallback( + (browserType?: string) => { + const bestVersion = getBestAvailableVersion(browserType); + if (bestVersion && isVersionDownloaded(bestVersion.version)) { + return bestVersion; + } + if (downloadedVersions.length > 0) { + const fallbackVersion = downloadedVersions[0]; + const releaseType = + browserType === "firefox-developer" ? "nightly" : "stable"; + return { + version: fallbackVersion, + releaseType: releaseType as "stable" | "nightly", + }; + } + return null; + }, + [getBestAvailableVersion, isVersionDownloaded, downloadedVersions], + ); + const handleDownload = async (browserStr: string) => { const bestVersion = getBestAvailableVersion(browserStr); @@ -366,7 +386,7 @@ export function CreateProfileDialog({ if (activeTab === "anti-detect") { // Anti-detect browser - check if Wayfern or Camoufox is selected if (selectedBrowser === "wayfern") { - const bestWayfernVersion = getBestAvailableVersion("wayfern"); + const bestWayfernVersion = getCreatableVersion("wayfern"); if (!bestWayfernVersion) { console.error("No Wayfern version available"); return; @@ -389,7 +409,7 @@ export function CreateProfileDialog({ }); } else { // Default to Camoufox - const bestCamoufoxVersion = getBestAvailableVersion("camoufox"); + const bestCamoufoxVersion = getCreatableVersion("camoufox"); if (!bestCamoufoxVersion) { console.error("No Camoufox version available"); return; @@ -420,7 +440,7 @@ export function CreateProfileDialog({ } // Use the best available version (stable preferred, nightly as fallback) - const bestVersion = getBestAvailableVersion(selectedBrowser); + const bestVersion = getCreatableVersion(selectedBrowser); if (!bestVersion) { console.error("No version available"); return; @@ -497,14 +517,14 @@ export function CreateProfileDialog({ if (!profileName.trim()) return true; if (!selectedBrowser) return true; if (isBrowserCurrentlyDownloading(selectedBrowser)) return true; - if (!isBrowserVersionAvailable(selectedBrowser)) return true; + if (!getCreatableVersion(selectedBrowser)) return true; return false; }, [ profileName, selectedBrowser, isBrowserCurrentlyDownloading, - isBrowserVersionAvailable, + getCreatableVersion, ]); // Filter supported browsers for regular browsers @@ -666,26 +686,26 @@ export function CreateProfileDialog({ />
- {/* Ephemeral Toggle */} -
- - setEphemeral(checked === true) - } - /> -
-
)} -
- - {!crossOsUnlocked && ( -
- -

- Fingerprint editing is a Pro feature -

- -
- )} -
+
) : selectedBrowser === "camoufox" ? ( // Camoufox Configuration @@ -886,24 +896,14 @@ export function CreateProfileDialog({ )} -
- - {!crossOsUnlocked && ( -
- -

- Fingerprint editing is a Pro feature -

- -
- )} -
+ ) : ( // Regular Browser Configuration (should not happen in anti-detect tab) diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index 85ec018..6d8f90a 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -13,6 +13,7 @@ import { invoke } from "@tauri-apps/api/core"; import { emit, listen } from "@tauri-apps/api/event"; import type { Dispatch, SetStateAction } from "react"; import * as React from "react"; +import { useTranslation } from "react-i18next"; import { FaApple, FaLinux, FaWindows } from "react-icons/fa"; import { FiWifi } from "react-icons/fi"; import { IoEllipsisHorizontal } from "react-icons/io5"; @@ -68,9 +69,9 @@ import { useTableSorting } from "@/hooks/use-table-sorting"; import { useVpnEvents } from "@/hooks/use-vpn-events"; import { getBrowserDisplayName, - getBrowserIcon, getCurrentOS, getOSDisplayName, + getProfileIcon, isCrossOsProfile, } from "@/lib/browser-utils"; import { formatRelativeTime } from "@/lib/flag-utils"; @@ -99,6 +100,7 @@ import { RippleButton } from "./ui/ripple"; // Stable table meta type to pass volatile state/handlers into TanStack Table without // causing column definitions to be recreated on every render. type TableMeta = { + t: (key: string, options?: Record) => string; selectedProfiles: string[]; selectableCount: number; showCheckboxes: boolean; @@ -176,8 +178,7 @@ type TableMeta = { onConfigureCamoufox?: (profile: BrowserProfile) => void; onCloneProfile?: (profile: BrowserProfile) => void; onCopyCookiesToProfile?: (profile: BrowserProfile) => void; - onImportCookies?: (profile: BrowserProfile) => void; - onExportCookies?: (profile: BrowserProfile) => void; + onOpenCookieManagement?: (profile: BrowserProfile) => void; // Traffic snapshots (lightweight real-time data) trafficSnapshots: Record; @@ -213,7 +214,11 @@ function getProfileSyncStatusDot( | undefined, errorMessage?: string, ): SyncStatusDot | null { - const status = liveStatus ?? (profile.sync_enabled ? "synced" : "disabled"); + const status = + liveStatus ?? + (profile.sync_mode && profile.sync_mode !== "Disabled" + ? "synced" + : "disabled"); switch (status) { case "syncing": @@ -758,8 +763,7 @@ interface ProfilesDataTableProps { onRenameProfile: (profileId: string, newName: string) => Promise; onConfigureCamoufox: (profile: BrowserProfile) => void; onCopyCookiesToProfile?: (profile: BrowserProfile) => void; - onImportCookies?: (profile: BrowserProfile) => void; - onExportCookies?: (profile: BrowserProfile) => void; + onOpenCookieManagement?: (profile: BrowserProfile) => void; runningProfiles: Set; isUpdating: (browser: string) => boolean; onDeleteSelectedProfiles: (profileIds: string[]) => Promise; @@ -786,8 +790,7 @@ export function ProfilesDataTable({ onRenameProfile, onConfigureCamoufox, onCopyCookiesToProfile, - onImportCookies, - onExportCookies, + onOpenCookieManagement, runningProfiles, isUpdating, onAssignProfilesToGroup, @@ -802,6 +805,7 @@ export function ProfilesDataTable({ crossOsUnlocked = false, syncUnlocked = false, }: ProfilesDataTableProps) { + const { t } = useTranslation(); const { getTableSorting, updateSorting, isLoaded } = useTableSorting(); const [sorting, setSorting] = React.useState([]); @@ -1201,9 +1205,8 @@ export function ProfilesDataTable({ browserState.isClient && runningProfiles.has(profile.id); const isLaunching = launchingProfiles.has(profile.id); const isStopping = stoppingProfiles.has(profile.id); - const isBrowserUpdating = isUpdating(profile.browser); - if (isRunning || isLaunching || isStopping || isBrowserUpdating) { + if (isRunning || isLaunching || isStopping) { newSet.delete(profileId); hasChanges = true; } @@ -1218,7 +1221,6 @@ export function ProfilesDataTable({ runningProfiles, launchingProfiles, stoppingProfiles, - isUpdating, browserState.isClient, onSelectedProfilesChange, selectedProfiles, @@ -1364,13 +1366,7 @@ export function ProfilesDataTable({ browserState.isClient && runningProfiles.has(profile.id); const isLaunching = launchingProfiles.has(profile.id); const isStopping = stoppingProfiles.has(profile.id); - const isBrowserUpdating = isUpdating(profile.browser); - return ( - !isRunning && - !isLaunching && - !isStopping && - !isBrowserUpdating - ); + return !isRunning && !isLaunching && !isStopping; }) .map((profile) => profile.id), ) @@ -1386,7 +1382,6 @@ export function ProfilesDataTable({ runningProfiles, launchingProfiles, stoppingProfiles, - isUpdating, ], ); @@ -1397,8 +1392,7 @@ export function ProfilesDataTable({ browserState.isClient && runningProfiles.has(profile.id); const isLaunching = launchingProfiles.has(profile.id); const isStopping = stoppingProfiles.has(profile.id); - const isBrowserUpdating = isUpdating(profile.browser); - return !isRunning && !isLaunching && !isStopping && !isBrowserUpdating; + return !isRunning && !isLaunching && !isStopping; }); }, [ profiles, @@ -1406,12 +1400,12 @@ export function ProfilesDataTable({ runningProfiles, launchingProfiles, stoppingProfiles, - isUpdating, ]); // Build table meta from volatile state so columns can stay stable const tableMeta = React.useMemo( () => ({ + t, selectedProfiles, selectableCount: selectableProfiles.length, showCheckboxes, @@ -1477,8 +1471,7 @@ export function ProfilesDataTable({ onCloneProfile, onConfigureCamoufox, onCopyCookiesToProfile, - onImportCookies, - onExportCookies, + onOpenCookieManagement, // Traffic snapshots (lightweight real-time data) trafficSnapshots, @@ -1501,6 +1494,7 @@ export function ProfilesDataTable({ handleCreateCountryProxy, }), [ + t, selectedProfiles, selectableProfiles.length, showCheckboxes, @@ -1540,8 +1534,7 @@ export function ProfilesDataTable({ onCloneProfile, onConfigureCamoufox, onCopyCookiesToProfile, - onImportCookies, - onExportCookies, + onOpenCookieManagement, syncStatuses, onOpenProfileSyncDialog, onToggleProfileSync, @@ -1578,7 +1571,7 @@ export function ProfilesDataTable({ const meta = table.options.meta as TableMeta; const profile = row.original; const browser = profile.browser; - const IconComponent = getBrowserIcon(browser); + const IconComponent = getProfileIcon(profile); const isCrossOs = isCrossOsProfile(profile); const isSelected = meta.isProfileSelected(profile.id); @@ -1586,9 +1579,7 @@ export function ProfilesDataTable({ meta.isClient && meta.runningProfiles.has(profile.id); const isLaunching = meta.launchingProfiles.has(profile.id); const isStopping = meta.stoppingProfiles.has(profile.id); - const isBrowserUpdating = meta.isUpdating(browser); - const isDisabled = - isRunning || isLaunching || isStopping || isBrowserUpdating; + const isDisabled = isRunning || isLaunching || isStopping; // Cross-OS profiles: show OS icon when checkboxes aren't visible, show checkbox when they are if (isCrossOs && !meta.showCheckboxes && !isSelected) { @@ -1907,13 +1898,8 @@ export function ProfilesDataTable({ meta.isClient && meta.runningProfiles.has(profile.id); const isLaunching = meta.launchingProfiles.has(profile.id); const isStopping = meta.stoppingProfiles.has(profile.id); - const isBrowserUpdating = meta.isUpdating(profile.browser); const isDisabled = - isRunning || - isLaunching || - isStopping || - isBrowserUpdating || - isCrossOs; + isRunning || isLaunching || isStopping || isCrossOs; return ( ); }, @@ -1963,13 +1942,8 @@ export function ProfilesDataTable({ meta.isClient && meta.runningProfiles.has(profile.id); const isLaunching = meta.launchingProfiles.has(profile.id); const isStopping = meta.stoppingProfiles.has(profile.id); - const isBrowserUpdating = meta.isUpdating(profile.browser); const isDisabled = - isRunning || - isLaunching || - isStopping || - isBrowserUpdating || - isCrossOs; + isRunning || isLaunching || isStopping || isCrossOs; return ( @@ -2364,28 +2321,25 @@ export function ProfilesDataTable({ }} disabled={isCrossOs} > - View Network - - { - if (meta.syncUnlocked) { - meta.onToggleProfileSync?.(profile); - } - }} - disabled={!meta.syncUnlocked || isCrossOs} - > - - {profile.sync_enabled ? "Disable Sync" : "Enable Sync"} - {!meta.syncUnlocked && } - + {meta.t("profiles.actions.viewNetwork")} + {!profile.ephemeral && ( + { + meta.onOpenProfileSyncDialog?.(profile); + }} + disabled={isCrossOs} + > + {meta.t("profiles.actions.syncSettings")} + + )} { meta.onAssignProfilesToGroup?.([profile.id]); }} disabled={isDisabled} > - Assign to Group + {meta.t("profiles.actions.assignToGroup")} {(profile.browser === "camoufox" || profile.browser === "wayfern") && @@ -2396,10 +2350,7 @@ export function ProfilesDataTable({ }} disabled={isDisabled} > - - Change Fingerprint - {!meta.crossOsUnlocked && } - + {meta.t("profiles.actions.changeFingerprint")} )} {(profile.browser === "camoufox" || @@ -2415,7 +2366,7 @@ export function ProfilesDataTable({ disabled={isDisabled || !meta.crossOsUnlocked} > - Copy Cookies to Profile + {meta.t("profiles.actions.copyCookiesToProfile")} {!meta.crossOsUnlocked && } @@ -2423,35 +2374,17 @@ export function ProfilesDataTable({ {(profile.browser === "camoufox" || profile.browser === "wayfern") && !profile.ephemeral && - meta.onImportCookies && ( + meta.onOpenCookieManagement && ( { if (meta.crossOsUnlocked) { - meta.onImportCookies?.(profile); + meta.onOpenCookieManagement?.(profile); } }} disabled={isDisabled || !meta.crossOsUnlocked} > - Import Cookies - {!meta.crossOsUnlocked && } - - - )} - {(profile.browser === "camoufox" || - profile.browser === "wayfern") && - !profile.ephemeral && - meta.onExportCookies && ( - { - if (meta.crossOsUnlocked) { - meta.onExportCookies?.(profile); - } - }} - disabled={isDisabled || !meta.crossOsUnlocked} - > - - Export Cookies + {meta.t("cookies.management.menuItem")} {!meta.crossOsUnlocked && } @@ -2463,7 +2396,7 @@ export function ProfilesDataTable({ }} disabled={isDisabled} > - Clone Profile + {meta.t("profiles.actions.clone")} )} - Delete + {meta.t("profiles.actions.delete")} @@ -2499,8 +2432,7 @@ export function ProfilesDataTable({ browserState.isClient && runningProfiles.has(profile.id); const isLaunching = launchingProfiles.has(profile.id); const isStopping = stoppingProfiles.has(profile.id); - const isBrowserUpdating = isUpdating(profile.browser); - return !isRunning && !isLaunching && !isStopping && !isBrowserUpdating; + return !isRunning && !isLaunching && !isStopping; }, getSortedRowModel: getSortedRowModel(), getCoreRowModel: getCoreRowModel(), diff --git a/src/components/profile-sync-dialog.tsx b/src/components/profile-sync-dialog.tsx index cdd7f44..190f4ec 100644 --- a/src/components/profile-sync-dialog.tsx +++ b/src/components/profile-sync-dialog.tsx @@ -2,10 +2,10 @@ import { invoke } from "@tauri-apps/api/core"; import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; import { LoadingButton } from "@/components/loading-button"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, @@ -15,8 +15,10 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; -import type { BrowserProfile, SyncSettings } from "@/types"; +import type { BrowserProfile, SyncMode, SyncSettings } from "@/types"; +import { isSyncEnabled } from "@/types"; interface ProfileSyncDialogProps { isOpen: boolean; @@ -31,12 +33,14 @@ export function ProfileSyncDialog({ profile, onSyncConfigOpen, }: ProfileSyncDialogProps) { + const { t } = useTranslation(); const [isSaving, setIsSaving] = useState(false); const [isSyncing, setIsSyncing] = useState(false); - const [syncEnabled, setSyncEnabled] = useState( - profile?.sync_enabled ?? false, + const [syncMode, setSyncMode] = useState( + profile?.sync_mode ?? "Disabled", ); const [hasConfig, setHasConfig] = useState(false); + const [hasE2ePassword, setHasE2ePassword] = useState(false); const [isCheckingConfig, setIsCheckingConfig] = useState(false); const checkSyncConfig = useCallback(async () => { @@ -44,6 +48,8 @@ export function ProfileSyncDialog({ try { const settings = await invoke("get_sync_settings"); setHasConfig(Boolean(settings.sync_server_url && settings.sync_token)); + const hasPassword = await invoke("check_has_e2e_password"); + setHasE2ePassword(hasPassword); } catch { setHasConfig(false); } finally { @@ -54,7 +60,7 @@ export function ProfileSyncDialog({ const handleOpenChange = useCallback( (open: boolean) => { if (open && profile) { - setSyncEnabled(profile.sync_enabled ?? false); + setSyncMode(profile.sync_mode ?? "Disabled"); void checkSyncConfig(); } if (!open) { @@ -64,39 +70,49 @@ export function ProfileSyncDialog({ [profile, onClose, checkSyncConfig], ); - const handleToggleSync = useCallback(async () => { - if (!profile) return; + const handleModeChange = useCallback( + async (newMode: string) => { + if (!profile) return; - if (!hasConfig) { - showErrorToast("Please configure sync service first"); - onSyncConfigOpen(); - onClose(); - return; - } + if (!hasConfig) { + showErrorToast(t("sync.mode.noPasswordWarning")); + onSyncConfigOpen(); + onClose(); + return; + } - setIsSaving(true); - try { - await invoke("set_profile_sync_enabled", { - profileId: profile.id, - enabled: !syncEnabled, - }); - setSyncEnabled(!syncEnabled); - showSuccessToast( - !syncEnabled ? "Sync enabled - syncing now..." : "Sync disabled", - ); - } catch (error) { - console.error("Failed to toggle sync:", error); - showErrorToast("Failed to update sync settings"); - } finally { - setIsSaving(false); - } - }, [profile, syncEnabled, hasConfig, onSyncConfigOpen, onClose]); + if (newMode === "Encrypted" && !hasE2ePassword) { + showErrorToast(t("sync.mode.passwordRequired")); + return; + } + + setIsSaving(true); + try { + await invoke("set_profile_sync_mode", { + profileId: profile.id, + syncMode: newMode, + }); + setSyncMode(newMode as SyncMode); + showSuccessToast( + newMode !== "Disabled" + ? t("sync.mode.enabledToast") + : t("sync.mode.disabledToast"), + ); + } catch (error) { + console.error("Failed to set sync mode:", error); + showErrorToast(String(error)); + } finally { + setIsSaving(false); + } + }, + [profile, hasConfig, hasE2ePassword, onSyncConfigOpen, onClose, t], + ); const handleSyncNow = useCallback(async () => { if (!profile) return; if (!hasConfig) { - showErrorToast("Please configure sync service first"); + showErrorToast(t("sync.mode.noPasswordWarning")); onSyncConfigOpen(); onClose(); return; @@ -105,17 +121,17 @@ export function ProfileSyncDialog({ setIsSyncing(true); try { await invoke("request_profile_sync", { profileId: profile.id }); - showSuccessToast("Sync queued"); + showSuccessToast(t("sync.mode.syncQueued")); } catch (error) { console.error("Failed to queue sync:", error); - showErrorToast("Failed to queue sync"); + showErrorToast(String(error)); } finally { setIsSyncing(false); } - }, [profile, hasConfig, onSyncConfigOpen, onClose]); + }, [profile, hasConfig, onSyncConfigOpen, onClose, t]); const formatLastSync = (timestamp?: number) => { - if (!timestamp) return "Never"; + if (!timestamp) return t("common.labels.never", "Never"); const date = new Date(timestamp * 1000); return date.toLocaleString(); }; @@ -126,9 +142,12 @@ export function ProfileSyncDialog({ - Profile Sync + {t("sync.mode.title", "Profile Sync")} - Manage sync settings for "{profile.name}" + {t("sync.mode.description", { + name: profile.name, + defaultValue: `Manage sync settings for "${profile.name}"`, + })} @@ -140,7 +159,9 @@ export function ProfileSyncDialog({
{!hasConfig && (
-

Sync service not configured.

+

+ {t("sync.mode.notConfigured", "Sync service not configured.")} +

)} {hasConfig && ( <> -
-
- -

- Sync this profile across devices -

+ +
+ +
- -
+ +
+ + +
+ +
+ + +
+ + + {syncMode === "Encrypted" && !hasE2ePassword && ( +
+ {t( + "sync.mode.noPasswordWarning", + "E2E password not set. Please set a password in Settings.", + )} +
+ )}
- +
{formatLastSync(profile.last_sync)} - {syncEnabled && ( + {isSyncEnabled(profile) && ( - {profile.last_sync ? "Synced" : "Pending"} + {profile.last_sync + ? t("common.status.synced") + : t("common.status.pending")} )}
@@ -193,11 +262,11 @@ export function ProfileSyncDialog({ - {hasConfig && syncEnabled && ( + {hasConfig && isSyncEnabled(profile) && ( - Sync Now + {t("sync.mode.syncNow", "Sync Now")} )} diff --git a/src/components/settings-dialog.tsx b/src/components/settings-dialog.tsx index 987a789..90d6bbd 100644 --- a/src/components/settings-dialog.tsx +++ b/src/components/settings-dialog.tsx @@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next"; import { BsCamera, BsMic } from "react-icons/bs"; import { LoadingButton } from "@/components/loading-button"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { ColorPicker, ColorPickerAlpha, @@ -24,6 +25,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Popover, @@ -113,6 +115,11 @@ export function SettingsDialog({ const [requestingPermission, setRequestingPermission] = useState(null); const [isMacOS, setIsMacOS] = useState(false); + const [hasE2ePassword, setHasE2ePassword] = useState(false); + const [e2ePassword, setE2ePassword] = useState(""); + const [e2ePasswordConfirm, setE2ePasswordConfirm] = useState(""); + const [e2eError, setE2eError] = useState(""); + const [isSavingE2e, setIsSavingE2e] = useState(false); const { t } = useTranslation(); const { setTheme } = useTheme(); @@ -202,6 +209,13 @@ export function SettingsDialog({ colors: tokyoNightTheme.colors, }); } + // Check E2E password status + try { + const hasPassword = await invoke("check_has_e2e_password"); + setHasE2ePassword(hasPassword); + } catch { + setHasE2ePassword(false); + } } catch (error) { console.error("Failed to load settings:", error); } finally { @@ -827,6 +841,145 @@ export function SettingsDialog({
+ {/* Sync Encryption Section */} +
+ +

+ {t( + "settings.encryption.description", + "Set a password to enable E2E encrypted sync. If you lose this password, encrypted profiles cannot be recovered.", + )} +

+ + {hasE2ePassword ? ( +
+
+ + {t("settings.encryption.passwordSet", "Active")} + + + {t( + "settings.encryption.passwordSetDescription", + "E2E encryption password is set", + )} + +
+
+ + +
+
+ ) : ( +
+ { + setE2ePassword(e.target.value); + setE2eError(""); + }} + /> + { + setE2ePasswordConfirm(e.target.value); + setE2eError(""); + }} + /> + {e2eError && ( +

{e2eError}

+ )} + { + if (e2ePassword.length < 8) { + setE2eError( + t( + "settings.encryption.passwordTooShort", + "Password must be at least 8 characters", + ), + ); + return; + } + if (e2ePassword !== e2ePasswordConfirm) { + setE2eError( + t( + "settings.encryption.passwordMismatch", + "Passwords do not match", + ), + ); + return; + } + setIsSavingE2e(true); + try { + await invoke("set_e2e_password", { + password: e2ePassword, + }); + setHasE2ePassword(true); + setE2ePassword(""); + setE2ePasswordConfirm(""); + showSuccessToast( + t( + "settings.encryption.passwordSaved", + "Encryption password set", + ), + ); + } catch (error) { + showErrorToast(String(error)); + } finally { + setIsSavingE2e(false); + } + }} + > + {t("settings.encryption.setPassword", "Set Password")} + +
+ )} +
+ {/* Commercial License Section */}
diff --git a/src/components/shared-camoufox-config-form.tsx b/src/components/shared-camoufox-config-form.tsx index 3ce1036..e86222d 100644 --- a/src/components/shared-camoufox-config-form.tsx +++ b/src/components/shared-camoufox-config-form.tsx @@ -32,6 +32,7 @@ interface SharedCamoufoxConfigFormProps { readOnly?: boolean; // Flag to indicate if the form should be read-only browserType?: "camoufox" | "wayfern"; // Browser type to customize form options crossOsUnlocked?: boolean; // Allow selecting non-current OS (paid feature) + limitedMode?: boolean; // Blur and disable advanced fields while keeping basic options accessible } // Determine if fingerprint editing should be disabled @@ -122,6 +123,7 @@ export function SharedCamoufoxConfigForm({ readOnly = false, browserType = "camoufox", crossOsUnlocked = false, + limitedMode = false, }: SharedCamoufoxConfigFormProps) { const { t } = useTranslation(); const [activeTab, setActiveTab] = useState( @@ -221,14 +223,14 @@ export function SharedCamoufoxConfigForm({
{/* Operating System Selection */}
- + - updateFingerprintConfig( - "navigator.userAgent", - e.target.value || undefined, - ) - } - placeholder="Mozilla/5.0..." - /> -
-
- - - updateFingerprintConfig( - "navigator.platform", - e.target.value || undefined, - ) - } - placeholder="e.g., MacIntel, Win32" - /> -
-
- - - updateFingerprintConfig( - "navigator.appVersion", - e.target.value || undefined, - ) - } - placeholder="e.g., 5.0 (Macintosh)" - /> -
-
- - - updateFingerprintConfig( - "navigator.oscpu", - e.target.value || undefined, - ) - } - placeholder="e.g., Intel Mac OS X 10.15" - /> -
-
- - - updateFingerprintConfig( - "navigator.hardwareConcurrency", - e.target.value ? parseInt(e.target.value, 10) : undefined, - ) - } - placeholder="e.g., 8" - /> -
-
- - - updateFingerprintConfig( - "navigator.maxTouchPoints", - e.target.value ? parseInt(e.target.value, 10) : undefined, - ) - } - placeholder="e.g., 0" - /> -
-
- - -
-
- - - updateFingerprintConfig( - "navigator.language", - e.target.value || undefined, - ) - } - placeholder="e.g., en-US" - /> -
-
-
- - {/* Screen Properties */} -
- -
-
- - - updateFingerprintConfig( - "screen.width", - e.target.value ? parseInt(e.target.value, 10) : undefined, - ) - } - placeholder="e.g., 1920" - /> -
-
- - - updateFingerprintConfig( - "screen.height", - e.target.value ? parseInt(e.target.value, 10) : undefined, - ) - } - placeholder="e.g., 1080" - /> -
-
- - - updateFingerprintConfig( - "screen.availWidth", - e.target.value ? parseInt(e.target.value, 10) : undefined, - ) - } - placeholder="e.g., 1920" - /> -
-
- - - updateFingerprintConfig( - "screen.availHeight", - e.target.value ? parseInt(e.target.value, 10) : undefined, - ) - } - placeholder="e.g., 1055" - /> -
-
- - - updateFingerprintConfig( - "screen.colorDepth", - e.target.value ? parseInt(e.target.value, 10) : undefined, - ) - } - placeholder="e.g., 30" - /> -
-
- - - updateFingerprintConfig( - "screen.pixelDepth", - e.target.value ? parseInt(e.target.value, 10) : undefined, - ) - } - placeholder="e.g., 30" - /> -
-
-
- - {/* Window Properties */} -
- -
-
- - - updateFingerprintConfig( - "window.outerWidth", - e.target.value ? parseInt(e.target.value, 10) : undefined, - ) - } - placeholder="e.g., 1512" - /> -
-
- - - updateFingerprintConfig( - "window.outerHeight", - e.target.value ? parseInt(e.target.value, 10) : undefined, - ) - } - placeholder="e.g., 886" - /> -
-
- - - updateFingerprintConfig( - "window.innerWidth", - e.target.value ? parseInt(e.target.value, 10) : undefined, - ) - } - placeholder="e.g., 1512" - /> -
-
- - - updateFingerprintConfig( - "window.innerHeight", - e.target.value ? parseInt(e.target.value, 10) : undefined, - ) - } - placeholder="e.g., 886" - /> -
-
- - - updateFingerprintConfig( - "window.screenX", - e.target.value ? parseInt(e.target.value, 10) : undefined, - ) - } - placeholder="e.g., 0" - /> -
-
- - - updateFingerprintConfig( - "window.screenY", - e.target.value ? parseInt(e.target.value, 10) : undefined, - ) - } - placeholder="e.g., 0" - /> -
-
-
- - {/* Geolocation */} -
- -
-
- - - updateFingerprintConfig( - "geolocation:latitude", - e.target.value ? parseFloat(e.target.value) : undefined, - ) - } - placeholder="e.g., 41.0019" - /> -
-
- - - updateFingerprintConfig( - "geolocation:longitude", - e.target.value ? parseFloat(e.target.value) : undefined, - ) - } - placeholder="e.g., 28.9645" - /> -
-
- - - updateFingerprintConfig( - "timezone", - e.target.value || undefined, - ) - } - placeholder="e.g., America/New_York" - /> -
-
-
- - {/* Locale */} -
- -
-
- - - updateFingerprintConfig( - "locale:language", - e.target.value || undefined, - ) - } - placeholder="e.g., tr" - /> -
-
- - - updateFingerprintConfig( - "locale:region", - e.target.value || undefined, - ) - } - placeholder="e.g., TR" - /> -
-
- - - updateFingerprintConfig( - "locale:script", - e.target.value || undefined, - ) - } - placeholder="e.g., Latn" - /> -
-
-
- - {/* WebGL Properties */} -
- -
-
- - - updateFingerprintConfig( - "webGl:vendor", - e.target.value || undefined, - ) - } - placeholder="e.g., Mesa" - /> -
-
- - - updateFingerprintConfig( - "webGl:renderer", - e.target.value || undefined, - ) - } - placeholder="e.g., llvmpipe, or similar" - /> -
-
-
- - {/* WebGL Parameters */} -
- ) || {} - } - onChange={(value) => - updateFingerprintConfig("webGl:parameters", value) - } - title="WebGL Parameters" - readOnly={readOnly} + {/* Automatic Location Configuration */} +
+
+ +
+
- {/* WebGL2 Parameters */} -
- ) || {} - } - onChange={(value) => - updateFingerprintConfig("webGl2:parameters", value) - } - title="WebGL2 Parameters" - readOnly={readOnly} - /> -
+
+ {!limitedMode && + (isEditingDisabled ? ( + + + {readOnly + ? t("fingerprint.editingDisabledRunning") + : t("fingerprint.editingDisabledRandomized")} + + + ) : ( + + + {t("fingerprint.advancedWarning")} + + + ))} - {/* WebGL Shader Precision Formats */} -
- ) || {} - } - onChange={(value) => - updateFingerprintConfig("webGl:shaderPrecisionFormats", value) - } - title="WebGL Shader Precision Formats" - readOnly={readOnly} - /> -
- - {/* WebGL2 Shader Precision Formats */} -
- ) || {} - } - onChange={(value) => - updateFingerprintConfig("webGl2:shaderPrecisionFormats", value) - } - title="WebGL2 Shader Precision Formats" - readOnly={readOnly} - /> -
- - {/* Fonts */} -
- - { - // Handle fonts being either an array or a JSON string (Wayfern format) - let fontsArray: string[] = []; - if (fingerprintConfig.fonts) { - if (Array.isArray(fingerprintConfig.fonts)) { - fontsArray = fingerprintConfig.fonts; - } else if (typeof fingerprintConfig.fonts === "string") { - try { - const parsed = JSON.parse(fingerprintConfig.fonts); - if (Array.isArray(parsed)) { - fontsArray = parsed; +
+ {/* Blocking Options - Only available for Camoufox */} + {browserType === "camoufox" && ( +
+ +
+
+ + onConfigChange("block_images", checked) } - } catch { - // Invalid JSON, ignore - } - } - } - return fontsArray.map((font) => ({ - label: font, - value: font, - })); - })()} - onChange={(selected: Option[]) => - updateFingerprintConfig( - "fonts", - selected.map((s: Option) => s.value), - ) - } - placeholder="Add fonts..." - creatable - /> -
- - {/* Battery */} -
- -
-
-
- - updateFingerprintConfig("battery:charging", checked) - } - /> - + /> + +
+
+ + onConfigChange("block_webrtc", checked) + } + /> + +
+
+ + onConfigChange("block_webgl", checked) + } + /> + +
-
- - - updateFingerprintConfig( - "battery:chargingTime", - e.target.value ? parseFloat(e.target.value) : undefined, - ) - } - placeholder="e.g., 0" - /> -
-
- - - updateFingerprintConfig( - "battery:dischargingTime", - e.target.value ? parseFloat(e.target.value) : undefined, - ) - } - placeholder="e.g., 0" - /> + )} + + {/* Navigator Properties */} +
+ +
+
+ + + updateFingerprintConfig( + "navigator.userAgent", + e.target.value || undefined, + ) + } + placeholder="Mozilla/5.0..." + /> +
+
+ + + updateFingerprintConfig( + "navigator.platform", + e.target.value || undefined, + ) + } + placeholder="e.g., MacIntel, Win32" + /> +
+
+ + + updateFingerprintConfig( + "navigator.appVersion", + e.target.value || undefined, + ) + } + placeholder="e.g., 5.0 (Macintosh)" + /> +
+
+ + + updateFingerprintConfig( + "navigator.oscpu", + e.target.value || undefined, + ) + } + placeholder="e.g., Intel Mac OS X 10.15" + /> +
+
+ + + updateFingerprintConfig( + "navigator.hardwareConcurrency", + e.target.value ? parseInt(e.target.value, 10) : undefined, + ) + } + placeholder="e.g., 8" + /> +
+
+ + + updateFingerprintConfig( + "navigator.maxTouchPoints", + e.target.value ? parseInt(e.target.value, 10) : undefined, + ) + } + placeholder="e.g., 0" + /> +
+
+ + +
+
+ + + updateFingerprintConfig( + "navigator.language", + e.target.value || undefined, + ) + } + placeholder="e.g., en-US" + /> +
-
- {/* Browser Behavior */} - {/*
+ {/* Screen Properties */} +
+ +
+
+ + + updateFingerprintConfig( + "screen.width", + e.target.value ? parseInt(e.target.value, 10) : undefined, + ) + } + placeholder="e.g., 1920" + /> +
+
+ + + updateFingerprintConfig( + "screen.height", + e.target.value ? parseInt(e.target.value, 10) : undefined, + ) + } + placeholder="e.g., 1080" + /> +
+
+ + + updateFingerprintConfig( + "screen.availWidth", + e.target.value ? parseInt(e.target.value, 10) : undefined, + ) + } + placeholder="e.g., 1920" + /> +
+
+ + + updateFingerprintConfig( + "screen.availHeight", + e.target.value ? parseInt(e.target.value, 10) : undefined, + ) + } + placeholder="e.g., 1055" + /> +
+
+ + + updateFingerprintConfig( + "screen.colorDepth", + e.target.value ? parseInt(e.target.value, 10) : undefined, + ) + } + placeholder="e.g., 30" + /> +
+
+ + + updateFingerprintConfig( + "screen.pixelDepth", + e.target.value ? parseInt(e.target.value, 10) : undefined, + ) + } + placeholder="e.g., 30" + /> +
+
+
+ + {/* Window Properties */} +
+ +
+
+ + + updateFingerprintConfig( + "window.outerWidth", + e.target.value ? parseInt(e.target.value, 10) : undefined, + ) + } + placeholder="e.g., 1512" + /> +
+
+ + + updateFingerprintConfig( + "window.outerHeight", + e.target.value ? parseInt(e.target.value, 10) : undefined, + ) + } + placeholder="e.g., 886" + /> +
+
+ + + updateFingerprintConfig( + "window.innerWidth", + e.target.value ? parseInt(e.target.value, 10) : undefined, + ) + } + placeholder="e.g., 1512" + /> +
+
+ + + updateFingerprintConfig( + "window.innerHeight", + e.target.value ? parseInt(e.target.value, 10) : undefined, + ) + } + placeholder="e.g., 886" + /> +
+
+ + + updateFingerprintConfig( + "window.screenX", + e.target.value ? parseInt(e.target.value, 10) : undefined, + ) + } + placeholder="e.g., 0" + /> +
+
+ + + updateFingerprintConfig( + "window.screenY", + e.target.value ? parseInt(e.target.value, 10) : undefined, + ) + } + placeholder="e.g., 0" + /> +
+
+
+ + {/* Geolocation */} +
+ +
+
+ + + updateFingerprintConfig( + "geolocation:latitude", + e.target.value ? parseFloat(e.target.value) : undefined, + ) + } + placeholder="e.g., 41.0019" + /> +
+
+ + + updateFingerprintConfig( + "geolocation:longitude", + e.target.value ? parseFloat(e.target.value) : undefined, + ) + } + placeholder="e.g., 28.9645" + /> +
+
+ + + updateFingerprintConfig( + "timezone", + e.target.value || undefined, + ) + } + placeholder="e.g., America/New_York" + /> +
+
+
+ + {/* Locale */} +
+ +
+
+ + + updateFingerprintConfig( + "locale:language", + e.target.value || undefined, + ) + } + placeholder="e.g., tr" + /> +
+
+ + + updateFingerprintConfig( + "locale:region", + e.target.value || undefined, + ) + } + placeholder="e.g., TR" + /> +
+
+ + + updateFingerprintConfig( + "locale:script", + e.target.value || undefined, + ) + } + placeholder="e.g., Latn" + /> +
+
+
+ + {/* WebGL Properties */} +
+ +
+
+ + + updateFingerprintConfig( + "webGl:vendor", + e.target.value || undefined, + ) + } + placeholder="e.g., Mesa" + /> +
+
+ + + updateFingerprintConfig( + "webGl:renderer", + e.target.value || undefined, + ) + } + placeholder="e.g., llvmpipe, or similar" + /> +
+
+
+ + {/* WebGL Parameters */} +
+ ) || {} + } + onChange={(value) => + updateFingerprintConfig("webGl:parameters", value) + } + title={t("fingerprint.webglParameters")} + readOnly={readOnly} + /> +
+ + {/* WebGL2 Parameters */} +
+ ) || {} + } + onChange={(value) => + updateFingerprintConfig("webGl2:parameters", value) + } + title={t("fingerprint.webgl2Parameters")} + readOnly={readOnly} + /> +
+ + {/* WebGL Shader Precision Formats */} +
+ ) || {} + } + onChange={(value) => + updateFingerprintConfig("webGl:shaderPrecisionFormats", value) + } + title={t("fingerprint.webglShaderPrecisionFormats")} + readOnly={readOnly} + /> +
+ + {/* WebGL2 Shader Precision Formats */} +
+ ) || {} + } + onChange={(value) => + updateFingerprintConfig("webGl2:shaderPrecisionFormats", value) + } + title={t("fingerprint.webgl2ShaderPrecisionFormats")} + readOnly={readOnly} + /> +
+ + {/* Fonts */} +
+ + { + // Handle fonts being either an array or a JSON string (Wayfern format) + let fontsArray: string[] = []; + if (fingerprintConfig.fonts) { + if (Array.isArray(fingerprintConfig.fonts)) { + fontsArray = fingerprintConfig.fonts; + } else if (typeof fingerprintConfig.fonts === "string") { + try { + const parsed = JSON.parse(fingerprintConfig.fonts); + if (Array.isArray(parsed)) { + fontsArray = parsed; + } + } catch { + // Invalid JSON, ignore + } + } + } + return fontsArray.map((font) => ({ + label: font, + value: font, + })); + })()} + onChange={(selected: Option[]) => + updateFingerprintConfig( + "fonts", + selected.map((s: Option) => s.value), + ) + } + placeholder="Add fonts..." + creatable + /> +
+ + {/* Battery */} +
+ +
+
+
+ + updateFingerprintConfig("battery:charging", checked) + } + /> + +
+
+
+ + + updateFingerprintConfig( + "battery:chargingTime", + e.target.value ? parseFloat(e.target.value) : undefined, + ) + } + placeholder="e.g., 0" + /> +
+
+ + + updateFingerprintConfig( + "battery:dischargingTime", + e.target.value ? parseFloat(e.target.value) : undefined, + ) + } + placeholder="e.g., 0" + /> +
+
+
+ + {/* Browser Behavior */} + {/*
*/} -
+ + {limitedMode && ( + <> +
+
+
+ + + {t("fingerprint.proFeature")} + +
+
+
+
+
+ + )} +
); @@ -981,17 +1077,17 @@ export function SharedCamoufoxConfigForm({ > - Automatic + {t("fingerprint.automatic")} - Manual + {t("fingerprint.manual")} {/* Operating System Selection */}
- + - onConfigChange( - "screen_max_width", - e.target.value - ? parseInt(e.target.value, 10) - : undefined, - ) - } - placeholder="e.g., 1920" - /> +
+
+ +
+
+ + + onConfigChange( + "screen_max_width", + e.target.value + ? parseInt(e.target.value, 10) + : undefined, + ) + } + placeholder="e.g., 1920" + /> +
+
+ + + onConfigChange( + "screen_max_height", + e.target.value + ? parseInt(e.target.value, 10) + : undefined, + ) + } + placeholder="e.g., 1080" + /> +
+
+ + + onConfigChange( + "screen_min_width", + e.target.value + ? parseInt(e.target.value, 10) + : undefined, + ) + } + placeholder="e.g., 800" + /> +
+
+ + + onConfigChange( + "screen_min_height", + e.target.value + ? parseInt(e.target.value, 10) + : undefined, + ) + } + placeholder="e.g., 600" + /> +
-
- - - onConfigChange( - "screen_max_height", - e.target.value - ? parseInt(e.target.value, 10) - : undefined, - ) - } - placeholder="e.g., 1080" - /> -
-
- - - onConfigChange( - "screen_min_width", - e.target.value - ? parseInt(e.target.value, 10) - : undefined, - ) - } - placeholder="e.g., 800" - /> -
-
- - - onConfigChange( - "screen_min_height", - e.target.value - ? parseInt(e.target.value, 10) - : undefined, - ) - } - placeholder="e.g., 600" - /> -
-
- + + {limitedMode && ( + <> +
+
+
+ + + {t("fingerprint.proFeature")} + +
+
+
+
+
+ + )} +
diff --git a/src/components/sync-config-dialog.tsx b/src/components/sync-config-dialog.tsx index 67dc331..dfe0da7 100644 --- a/src/components/sync-config-dialog.tsx +++ b/src/components/sync-config-dialog.tsx @@ -60,7 +60,21 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) { const [activeTab, setActiveTab] = useState("cloud"); - const isConnected = Boolean(serverUrl && token); + const [connectionStatus, setConnectionStatus] = useState< + "unknown" | "testing" | "connected" | "error" + >("unknown"); + const hasConfig = Boolean(serverUrl && token); + + const testConnection = useCallback(async (url: string) => { + setConnectionStatus("testing"); + try { + const healthUrl = `${url.replace(/\/$/, "")}/health`; + const response = await fetch(healthUrl); + setConnectionStatus(response.ok ? "connected" : "error"); + } catch { + setConnectionStatus("error"); + } + }, []); const loadSettings = useCallback(async () => { setIsLoading(true); @@ -68,15 +82,19 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) { const settings = await invoke("get_sync_settings"); setServerUrl(settings.sync_server_url || ""); setToken(settings.sync_token || ""); + if (settings.sync_server_url && settings.sync_token) { + void testConnection(settings.sync_server_url); + } } catch (error) { console.error("Failed to load sync settings:", error); } finally { setIsLoading(false); } - }, []); + }, [testConnection]); useEffect(() => { if (isOpen) { + setConnectionStatus("unknown"); void loadSettings(); setCodeSent(false); setOtpCode(""); @@ -103,15 +121,19 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) { } setIsTesting(true); + setConnectionStatus("testing"); try { const healthUrl = `${serverUrl.replace(/\/$/, "")}/health`; const response = await fetch(healthUrl); if (response.ok) { + setConnectionStatus("connected"); showSuccessToast("Connection successful!"); } else { + setConnectionStatus("error"); showErrorToast("Server responded with an error"); } } catch { + setConnectionStatus("error"); showErrorToast("Failed to connect to server"); } finally { setIsTesting(false); @@ -125,6 +147,11 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) { syncServerUrl: serverUrl || null, syncToken: token || null, }); + try { + await invoke("restart_sync_service"); + } catch (e) { + console.error("Failed to restart sync service:", e); + } showSuccessToast("Sync settings saved"); onClose(); } catch (error) { @@ -142,8 +169,14 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) { syncServerUrl: null, syncToken: null, }); + try { + await invoke("restart_sync_service"); + } catch (e) { + console.error("Failed to restart sync service:", e); + } setServerUrl(""); setToken(""); + setConnectionStatus("unknown"); showSuccessToast("Sync disconnected"); } catch (error) { console.error("Failed to disconnect:", error); @@ -209,7 +242,7 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) { }, [logout, t]); // Determine which tabs are available - const cloudBlocked = !isLoggedIn && isConnected; + const cloudBlocked = !isLoggedIn && hasConfig; const selfHostedBlocked = isLoggedIn; return ( @@ -427,17 +460,29 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
- {isConnected && ( + {connectionStatus === "testing" && ( +
+
+ {t("sync.status.syncing")} +
+ )} + {connectionStatus === "connected" && (
{t("sync.status.connected")}
)} + {connectionStatus === "error" && ( +
+
+ {t("sync.status.disconnected")} +
+ )}
)} - {isConnected && ( + {hasConfig && (
- - {/* Hardware Properties */} -
- -
-
- - - updateFingerprintConfig( - "hardwareConcurrency", - e.target.value ? parseInt(e.target.value, 10) : undefined, - ) - } - placeholder="e.g., 8" - /> -
-
- - - updateFingerprintConfig( - "maxTouchPoints", - e.target.value ? parseInt(e.target.value, 10) : undefined, - ) - } - placeholder="e.g., 0" - /> -
-
- - - updateFingerprintConfig( - "deviceMemory", - e.target.value ? parseInt(e.target.value, 10) : undefined, - ) - } - placeholder="e.g., 8" - /> -
-
-
- - {/* Screen Properties */} -
- -
-
- - - updateFingerprintConfig( - "screenWidth", - e.target.value ? parseInt(e.target.value, 10) : undefined, - ) - } - placeholder="e.g., 1920" - /> -
-
- - - updateFingerprintConfig( - "screenHeight", - e.target.value ? parseInt(e.target.value, 10) : undefined, - ) - } - placeholder="e.g., 1080" - /> -
-
- - - updateFingerprintConfig( - "devicePixelRatio", - e.target.value ? parseFloat(e.target.value) : undefined, - ) - } - placeholder="e.g., 1.0" - /> -
-
- - - updateFingerprintConfig( - "screenAvailWidth", - e.target.value ? parseInt(e.target.value, 10) : undefined, - ) - } - placeholder="e.g., 1920" - /> -
-
- - - updateFingerprintConfig( - "screenAvailHeight", - e.target.value ? parseInt(e.target.value, 10) : undefined, - ) - } - placeholder="e.g., 1040" - /> -
-
- - - updateFingerprintConfig( - "screenColorDepth", - e.target.value ? parseInt(e.target.value, 10) : undefined, - ) - } - placeholder="e.g., 24" - /> -
-
-
- - {/* Window Properties */} -
- -
-
- - - updateFingerprintConfig( - "windowOuterWidth", - e.target.value ? parseInt(e.target.value, 10) : undefined, - ) - } - placeholder="e.g., 1920" - /> -
-
- - - updateFingerprintConfig( - "windowOuterHeight", - e.target.value ? parseInt(e.target.value, 10) : undefined, - ) - } - placeholder="e.g., 1040" - /> -
-
- - - updateFingerprintConfig( - "windowInnerWidth", - e.target.value ? parseInt(e.target.value, 10) : undefined, - ) - } - placeholder="e.g., 1920" - /> -
-
- - - updateFingerprintConfig( - "windowInnerHeight", - e.target.value ? parseInt(e.target.value, 10) : undefined, - ) - } - placeholder="e.g., 940" - /> -
-
- - - updateFingerprintConfig( - "screenX", - e.target.value ? parseInt(e.target.value, 10) : undefined, - ) - } - placeholder="e.g., 0" - /> -
-
- - - updateFingerprintConfig( - "screenY", - e.target.value ? parseInt(e.target.value, 10) : undefined, - ) - } - placeholder="e.g., 0" - /> -
-
-
- - {/* Language & Locale */} -
- -
-
- - - updateFingerprintConfig( - "language", - e.target.value || undefined, - ) - } - placeholder="e.g., en-US" - /> -
-
- - { - if (!e.target.value) { - updateFingerprintConfig("languages", undefined); - return; - } - try { - const parsed = JSON.parse(e.target.value); - if (Array.isArray(parsed)) { - updateFingerprintConfig("languages", parsed); - } - } catch { - // Invalid JSON, keep current value - } - }} - placeholder='["en-US", "en"]' - /> -
-
- - -
-
-
- - {/* Timezone and Geolocation */} -
- -

- These values override the browser's timezone and geolocation APIs. -

-
-
- - - updateFingerprintConfig( - "timezone", - e.target.value || undefined, - ) - } - placeholder="e.g., America/New_York" - /> -
-
- - - updateFingerprintConfig( - "timezoneOffset", - e.target.value ? parseInt(e.target.value, 10) : undefined, - ) - } - placeholder="e.g., 300 for EST (UTC-5)" - /> -
-
- - - updateFingerprintConfig( - "latitude", - e.target.value ? parseFloat(e.target.value) : undefined, - ) - } - placeholder="e.g., 40.7128" - /> -
-
- - - updateFingerprintConfig( - "longitude", - e.target.value ? parseFloat(e.target.value) : undefined, - ) - } - placeholder="e.g., -74.0060" - /> -
-
- - - updateFingerprintConfig( - "accuracy", - e.target.value ? parseFloat(e.target.value) : undefined, - ) - } - placeholder="e.g., 100" - /> -
-
-
- - {/* WebGL Properties */} -
- -
-
- - - updateFingerprintConfig( - "webglVendor", - e.target.value || undefined, - ) - } - placeholder="e.g., Intel" - /> -
-
- - - updateFingerprintConfig( - "webglRenderer", - e.target.value || undefined, - ) - } - placeholder="e.g., Intel(R) HD Graphics" - /> -
-
-
- - {/* WebGL Parameters (JSON) */} -
- -