feat: e2e encrypted sync

This commit is contained in:
zhom
2026-02-24 05:51:48 +04:00
parent 21d80fde56
commit e6cb4e6082
56 changed files with 5831 additions and 2549 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.
+2 -45
View File
@@ -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"
+1 -4
View File
@@ -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"] }
+2 -10
View File
@@ -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<bool, Box<dyn std::error::Error + Send + Sync>> {
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,
+41 -14
View File
@@ -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<u16>,
@@ -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;
-11
View File
@@ -80,17 +80,6 @@ impl BrowserRunner {
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
// 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
+18 -10
View File
@@ -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}");
+6
View File
@@ -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);
+57 -38
View File
@@ -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<std::path::PathBuf> {
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<u32> {
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);
}
+29 -7
View File
@@ -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<u32>,
@@ -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<PathBuf> {
// 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();
+12 -22
View File
@@ -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(())
}
+188 -26
View File
@@ -8,9 +8,132 @@ lazy_static::lazy_static! {
static ref EPHEMERAL_DIRS: Mutex<HashMap<String, PathBuf>> = 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<PathBuf, String> {
#[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<PathBuf, String> {
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<PathBuf, String> {
// 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<PathBuf, String> {
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);
}
}
+9
View File
@@ -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(),
+30 -1
View File
@@ -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<dyn std::error::Error>> {
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,
+103 -36
View File
@@ -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<vpn::VpnConfig, String> {
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<vpn::VpnConfig, String> {
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
+246 -2
View File
@@ -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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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]
+3
View File
@@ -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 {
+14 -10
View File
@@ -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() {
+21 -1
View File
@@ -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<String>, // 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<String>,
#[serde(default)]
pub last_sync: Option<u64>, // 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
}
}
+2 -1
View File
@@ -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,
+24 -1
View File
@@ -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<StoredProxy> {
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)
}
+3
View File
@@ -254,9 +254,12 @@ pub async fn stop_proxy_process(id: &str) -> Result<bool, Box<dyn std::error::Er
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
use std::process::Command;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let _ = Command::new("taskkill")
.args(["/F", "/PID", &pid.to_string()])
.creation_flags(CREATE_NO_WINDOW)
.output();
}
+34
View File
@@ -53,6 +53,8 @@ pub struct AppSettings {
pub launch_on_login_declined: bool, // User permanently declined the launch-on-login prompt
#[serde(default)]
pub language: Option<String>, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ru", or None for system default
#[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::<AppSettings>(&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<bool, String> {
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);
+351
View File
@@ -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::<Aes256Gcm>::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<Option<String>, 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::<Aes256Gcm>::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<Vec<u8>, String> {
let aes_key = Key::<Aes256Gcm>::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<Vec<u8>, 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::<Aes256Gcm>::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());
}
}
+294 -30
View File
@@ -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::<BrowserProfile>(&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());
}
+24
View File
@@ -52,6 +52,8 @@ pub struct SyncManifest {
#[serde(rename = "excludeGlobs")]
pub exclude_globs: Vec<String>,
pub files: Vec<ManifestFileEntry>,
#[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);
}
}
+6 -3
View File
@@ -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};
+51 -47
View File
@@ -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", ());
}
}
}
_ => {}
}
+7 -1
View File
@@ -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()
+7 -1
View File
@@ -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()
+2 -2
View File
@@ -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,
+3
View File
@@ -210,9 +210,12 @@ pub async fn stop_vpn_worker(id: &str) -> Result<bool, Box<dyn std::error::Error
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
use std::process::Command;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let _ = Command::new("taskkill")
.args(["/F", "/PID", &pid.to_string()])
.creation_flags(CREATE_NO_WINDOW)
.output();
}
+118 -5
View File
@@ -245,6 +245,9 @@ impl WayfernManager {
.arg("--no-first-run")
.arg("--no-default-browser-check")
.arg("--disable-background-mode")
.arg("--use-mock-keychain")
.arg("--password-store=basic")
.arg("--disable-features=DialMediaRouteProvider")
.stdout(Stdio::null())
.stderr(Stdio::null());
@@ -261,8 +264,11 @@ impl WayfernManager {
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let _ = std::process::Command::new("taskkill")
.args(["/PID", &id.to_string(), "/F"])
.creation_flags(CREATE_NO_WINDOW)
.output();
}
}
@@ -435,8 +441,11 @@ impl WayfernManager {
}
if ephemeral {
args.push(format!("--disk-cache-dir={}/cache", profile_path));
args.push("--incognito".to_string());
args.push("--disk-cache-size=1".to_string());
args.push("--disable-breakpad".to_string());
args.push("--disable-crash-reporter".to_string());
args.push("--no-service-autorun".to_string());
args.push("--disable-sync".to_string());
}
// Don't add URL to args - we'll navigate via CDP after setting fingerprint
@@ -591,8 +600,11 @@ impl WayfernManager {
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let _ = std::process::Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/F"])
.creation_flags(CREATE_NO_WINDOW)
.output();
}
log::info!("Stopped Wayfern instance {id} (PID: {pid})");
@@ -646,11 +658,19 @@ impl WayfernManager {
let mut inner = self.inner.lock().await;
// Canonicalize the target path for comparison
let target_path = std::path::Path::new(profile_path)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf());
// Find the instance with the matching profile path
let mut found_id: Option<String> = 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<u16>)> {
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<u16> = 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
}
+53 -36
View File
@@ -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<BrowserProfile | null>(null);
const [cookieExportDialogOpen, setCookieExportDialogOpen] = useState(false);
const [currentProfileForCookieExport, setCurrentProfileForCookieExport] =
useState<BrowserProfile | null>(null);
const [cookieManagementDialogOpen, setCookieManagementDialogOpen] =
useState(false);
const [
currentProfileForCookieManagement,
setCurrentProfileForCookieManagement,
] = useState<BrowserProfile | null>(null);
const [selectedProfilesForCookies, setSelectedProfilesForCookies] = useState<
string[]
>([]);
@@ -167,6 +167,10 @@ export default function Home() {
useState<BrowserProfile | null>(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<PermissionType>("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<boolean>(
"get_window_resize_warning_dismissed",
);
if (!dismissed) {
const proceed = await new Promise<boolean>((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<BrowserProfile>("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([])}
/>
<CookieImportDialog
isOpen={cookieImportDialogOpen}
<CookieManagementDialog
isOpen={cookieManagementDialogOpen}
onClose={() => {
setCookieImportDialogOpen(false);
setCurrentProfileForCookieImport(null);
setCookieManagementDialogOpen(false);
setCurrentProfileForCookieManagement(null);
}}
profile={currentProfileForCookieImport}
/>
<CookieExportDialog
isOpen={cookieExportDialogOpen}
onClose={() => {
setCookieExportDialogOpen(false);
setCurrentProfileForCookieExport(null);
}}
profile={currentProfileForCookieExport}
profile={currentProfileForCookieManagement}
/>
<DeleteConfirmationDialog
@@ -1237,6 +1245,15 @@ export default function Home() {
isOpen={launchOnLoginDialogOpen}
onClose={() => setLaunchOnLoginDialogOpen(false)}
/>
<WindowResizeWarningDialog
isOpen={windowResizeWarningOpen}
onResult={(proceed) => {
setWindowResizeWarningOpen(false);
windowResizeWarningResolver.current?.(proceed);
windowResizeWarningResolver.current = null;
}}
/>
</div>
);
}
+6 -15
View File
@@ -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({
</DialogHeader>
<ScrollArea className="flex-1 h-[300px]">
<div className="py-4 relative">
<div className="py-4">
{profile.browser === "wayfern" ? (
<WayfernConfigForm
config={config as WayfernConfig}
onConfigChange={updateConfig}
forceAdvanced={true}
readOnly={isRunning || !crossOsUnlocked}
readOnly={isRunning}
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
/>
) : (
<SharedCamoufoxConfigForm
config={config as CamoufoxConfig}
onConfigChange={updateConfig}
forceAdvanced={true}
readOnly={isRunning || !crossOsUnlocked}
readOnly={isRunning}
browserType="camoufox"
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
/>
)}
{!crossOsUnlocked && (
<div className="absolute inset-0 backdrop-blur-sm bg-background/60 z-10 flex flex-col items-center justify-center gap-2">
<LuLock className="w-6 h-6 text-muted-foreground" />
<p className="text-sm text-muted-foreground font-medium">
Fingerprint editing is a Pro feature
</p>
<ProBadge />
</div>
)}
</div>
</ScrollArea>
@@ -192,7 +183,7 @@ export function CamoufoxConfigDialog({
<RippleButton variant="outline" onClick={handleClose}>
{isRunning ? "Close" : "Cancel"}
</RippleButton>
{!isRunning && crossOsUnlocked && (
{!isRunning && (
<LoadingButton
isLoading={isSaving}
onClick={handleSave}
-129
View File
@@ -1,129 +0,0 @@
"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, useState } from "react";
import { LuDownload } 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 { Label } from "@/components/ui/label";
import { RippleButton } from "@/components/ui/ripple";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { BrowserProfile } from "@/types";
interface CookieExportDialogProps {
isOpen: boolean;
onClose: () => 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<string>("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 (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Export Cookies</DialogTitle>
<DialogDescription>
Export cookies from this profile.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Format</Label>
<Select
value={format}
onValueChange={(v) => setFormat(v as "netscape" | "json")}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="json">JSON</SelectItem>
<SelectItem value="netscape">Netscape TXT</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<RippleButton variant="outline" onClick={handleClose}>
Cancel
</RippleButton>
<LoadingButton
isLoading={isExporting}
onClick={() => void handleExport()}
>
<LuDownload className="w-4 h-4 mr-2" />
Export
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
-212
View File
@@ -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<string | null>(null);
const [fileName, setFileName] = useState<string | null>(null);
const [cookieCount, setCookieCount] = useState(0);
const [isImporting, setIsImporting] = useState(false);
const [result, setResult] = useState<CookieImportResult | null>(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<CookieImportResult>(
"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 (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Import Cookies</DialogTitle>
<DialogDescription>
{!fileContent &&
"Import cookies from a Netscape or JSON format file."}
{fileContent &&
!result &&
`${cookieCount} cookies found in ${fileName}`}
{result && "Cookie import completed"}
</DialogDescription>
</DialogHeader>
{!fileContent && (
<div className="space-y-4">
<div
role="button"
tabIndex={0}
className="flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-8 transition-colors cursor-pointer border-muted-foreground/25 hover:border-muted-foreground/50"
onClick={() =>
document.getElementById("cookie-file-input")?.click()
}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
document.getElementById("cookie-file-input")?.click();
}
}}
>
<LuUpload className="w-10 h-10 text-muted-foreground mb-4" />
<p className="text-sm text-muted-foreground text-center">
Click to choose a cookie file
<br />
<span className="text-xs">(.txt, .cookies, or .json)</span>
</p>
<input
id="cookie-file-input"
type="file"
accept=".txt,.cookies,.json"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileRead(file);
e.target.value = "";
}}
/>
</div>
</div>
)}
{fileContent && !result && (
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 bg-muted/30 rounded-lg">
<div>
<div className="font-medium">{fileName}</div>
<div className="text-sm text-muted-foreground">
{cookieCount} cookies found
</div>
</div>
</div>
</div>
)}
{result && (
<div className="space-y-4">
<div className="p-4 rounded-lg bg-green-500/10">
<div className="font-medium text-green-600 dark:text-green-400">
Successfully imported {result.cookies_imported} cookies (
{result.cookies_replaced} replaced)
</div>
{result.errors.length > 0 && (
<div className="mt-2 text-sm text-muted-foreground">
{result.errors.length} line(s) skipped
</div>
)}
</div>
</div>
)}
<DialogFooter>
{!fileContent && (
<RippleButton variant="outline" onClick={handleClose}>
Cancel
</RippleButton>
)}
{fileContent && !result && (
<>
<RippleButton variant="outline" onClick={resetState}>
Back
</RippleButton>
<LoadingButton
isLoading={isImporting}
onClick={() => void handleImport()}
disabled={cookieCount === 0}
>
Import
</LoadingButton>
</>
)}
{result && <RippleButton onClick={handleClose}>Done</RippleButton>}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+649
View File
@@ -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<string>;
};
};
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<string | null>(null);
const [fileName, setFileName] = useState<string | null>(null);
const [cookieCount, setCookieCount] = useState(0);
const [isImporting, setIsImporting] = useState(false);
const [importResult, setImportResult] = useState<CookieImportResult | null>(
null,
);
// Export state
const [format, setFormat] = useState<"netscape" | "json">("json");
const [isExporting, setIsExporting] = useState(false);
const [exportCookieData, setExportCookieData] =
useState<CookieReadResult | null>(null);
const [isLoadingExportCookies, setIsLoadingExportCookies] = useState(false);
const [exportSelection, setExportSelection] = useState<SelectionState>({});
const [expandedDomains, setExpandedDomains] = useState<Set<string>>(
new Set(),
);
const [activeTab, setActiveTab] = useState<string>(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<CookieReadResult>("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<CookieImportResult>(
"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<string>(),
};
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 (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Cookie Management</DialogTitle>
</DialogHeader>
<Tabs
defaultValue={initialTab}
onValueChange={handleTabChange}
className="w-full"
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="import">Import</TabsTrigger>
<TabsTrigger value="export">Export</TabsTrigger>
</TabsList>
<TabsContent value="import" className="space-y-4 mt-4">
{!fileContent && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Import cookies from a Netscape or JSON format file.
</p>
<div
role="button"
tabIndex={0}
className="flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-8 transition-colors cursor-pointer border-muted-foreground/25 hover:border-muted-foreground/50"
onClick={() =>
document.getElementById("cookie-file-input")?.click()
}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
document.getElementById("cookie-file-input")?.click();
}
}}
>
<LuUpload className="w-10 h-10 text-muted-foreground mb-4" />
<p className="text-sm text-muted-foreground text-center">
Click to choose a cookie file
<br />
<span className="text-xs">(.txt, .cookies, or .json)</span>
</p>
<input
id="cookie-file-input"
type="file"
accept=".txt,.cookies,.json"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileRead(file);
e.target.value = "";
}}
/>
</div>
</div>
)}
{fileContent && !importResult && (
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 bg-muted/30 rounded-lg">
<div>
<div className="font-medium">{fileName}</div>
<div className="text-sm text-muted-foreground">
{cookieCount} cookies found
</div>
</div>
</div>
<div className="flex justify-end gap-2">
<RippleButton variant="outline" onClick={resetImportState}>
Back
</RippleButton>
<LoadingButton
isLoading={isImporting}
onClick={() => void handleImport()}
disabled={cookieCount === 0}
>
Import
</LoadingButton>
</div>
</div>
)}
{importResult && (
<div className="space-y-4">
<div className="p-4 rounded-lg bg-green-500/10">
<div className="font-medium text-green-600 dark:text-green-400">
Successfully imported {importResult.cookies_imported}{" "}
cookies ({importResult.cookies_replaced} replaced)
</div>
{importResult.errors.length > 0 && (
<div className="mt-2 text-sm text-muted-foreground">
{importResult.errors.length} line(s) skipped
</div>
)}
</div>
<div className="flex justify-end">
<RippleButton onClick={handleClose}>Done</RippleButton>
</div>
</div>
)}
</TabsContent>
<TabsContent value="export" className="space-y-3 mt-4">
<div className="space-y-2">
<Label>Format</Label>
<Select
value={format}
onValueChange={(v) => setFormat(v as "netscape" | "json")}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="json">JSON</SelectItem>
<SelectItem value="netscape">Netscape TXT</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>
Cookies{" "}
{exportCookieData && (
<span className="text-muted-foreground font-normal">
({selectedExportCount} of {exportCookieData.total_count}{" "}
selected)
</span>
)}
</Label>
{exportCookieData && exportCookieData.total_count > 0 && (
<button
type="button"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={toggleSelectAll}
>
{selectedExportCount === exportCookieData.total_count
? "Deselect all"
: "Select all"}
</button>
)}
</div>
{isLoadingExportCookies ? (
<div className="flex items-center justify-center h-24">
<div className="animate-spin h-5 w-5 border-2 border-primary border-t-transparent rounded-full" />
</div>
) : !exportCookieData || exportCookieData.domains.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground border rounded-md">
No cookies found in this profile
</div>
) : (
<ScrollArea className="h-[200px] border rounded-md">
<div className="p-2 space-y-1">
{exportCookieData.domains.map((domain) => (
<ExportDomainRow
key={domain.domain}
domain={domain}
selection={exportSelection}
isExpanded={expandedDomains.has(domain.domain)}
onToggleDomain={toggleDomain}
onToggleCookie={toggleCookie}
onToggleExpand={toggleExpand}
/>
))}
</div>
</ScrollArea>
)}
</div>
<div className="flex justify-end gap-2">
<RippleButton variant="outline" onClick={handleClose}>
Cancel
</RippleButton>
<LoadingButton
isLoading={isExporting}
onClick={() => void handleExport()}
disabled={selectedExportCount === 0}
>
Export
</LoadingButton>
</div>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}
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 (
<div>
<div className="flex items-center gap-2 p-1.5 hover:bg-accent/50 rounded">
<Checkbox
checked={isAllSelected || isPartial}
onCheckedChange={() => onToggleDomain(domain.domain, domain.cookies)}
className={isPartial ? "opacity-70" : ""}
/>
<button
type="button"
className="flex items-center gap-1 flex-1 text-left text-sm bg-transparent border-none cursor-pointer"
onClick={() => onToggleExpand(domain.domain)}
>
{isExpanded ? (
<LuChevronDown className="w-3.5 h-3.5" />
) : (
<LuChevronRight className="w-3.5 h-3.5" />
)}
<span className="font-medium truncate">{domain.domain}</span>
<span className="text-xs text-muted-foreground shrink-0">
({domain.cookie_count})
</span>
</button>
</div>
{isExpanded && (
<div className="ml-7 pl-2 border-l space-y-0.5">
{domain.cookies.map((cookie) => {
const isSelected =
domainSelection?.cookies.has(cookie.name) || false;
return (
<div
key={`${domain.domain}-${cookie.name}`}
className="flex items-center gap-2 p-1 text-sm hover:bg-accent/30 rounded"
>
<Checkbox
checked={isSelected || isAllSelected}
onCheckedChange={() =>
onToggleCookie(
domain.domain,
cookie.name,
domain.cookie_count,
)
}
/>
<span className="truncate">{cookie.name}</span>
</div>
);
})}
</div>
)}
</div>
);
}
+60 -60
View File
@@ -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({
/>
</div>
{/* Ephemeral Toggle */}
<div className="flex items-center gap-2">
<Checkbox
id="ephemeral"
checked={ephemeral}
onCheckedChange={(checked) =>
setEphemeral(checked === true)
}
/>
<div className="flex flex-col">
<Label
htmlFor="ephemeral"
className="cursor-pointer"
>
Ephemeral
{/* Ephemeral Option */}
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="flex items-center space-x-2">
<Checkbox
id="ephemeral"
checked={ephemeral}
onCheckedChange={(checked) =>
setEphemeral(checked === true)
}
/>
<Label htmlFor="ephemeral" className="font-medium">
{t("profiles.ephemeral")}
</Label>
<span className="text-xs text-muted-foreground">
Browser data is deleted when closed
<span className="px-1 py-0.5 text-[10px] leading-none rounded bg-muted text-muted-foreground font-medium">
{t("profiles.ephemeralAlpha")}
</span>
</div>
<p className="text-sm text-muted-foreground ml-6">
{t("profiles.ephemeralDescription")}
</p>
</div>
{selectedBrowser === "wayfern" ? (
@@ -778,23 +798,13 @@ export function CreateProfileDialog({
</div>
)}
<div className="relative">
<WayfernConfigForm
config={wayfernConfig}
onConfigChange={updateWayfernConfig}
isCreating
crossOsUnlocked={crossOsUnlocked}
/>
{!crossOsUnlocked && (
<div className="absolute inset-0 backdrop-blur-sm bg-background/60 z-10 flex flex-col items-center justify-center gap-2">
<LuLock className="w-6 h-6 text-muted-foreground" />
<p className="text-sm text-muted-foreground font-medium">
Fingerprint editing is a Pro feature
</p>
<ProBadge />
</div>
)}
</div>
<WayfernConfigForm
config={wayfernConfig}
onConfigChange={updateWayfernConfig}
isCreating
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
/>
</div>
) : selectedBrowser === "camoufox" ? (
// Camoufox Configuration
@@ -886,24 +896,14 @@ export function CreateProfileDialog({
</div>
)}
<div className="relative">
<SharedCamoufoxConfigForm
config={camoufoxConfig}
onConfigChange={updateCamoufoxConfig}
isCreating
browserType="camoufox"
crossOsUnlocked={crossOsUnlocked}
/>
{!crossOsUnlocked && (
<div className="absolute inset-0 backdrop-blur-sm bg-background/60 z-10 flex flex-col items-center justify-center gap-2">
<LuLock className="w-6 h-6 text-muted-foreground" />
<p className="text-sm text-muted-foreground font-medium">
Fingerprint editing is a Pro feature
</p>
<ProBadge />
</div>
)}
</div>
<SharedCamoufoxConfigForm
config={camoufoxConfig}
onConfigChange={updateCamoufoxConfig}
isCreating
browserType="camoufox"
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
/>
</div>
) : (
// Regular Browser Configuration (should not happen in anti-detect tab)
+48 -116
View File
@@ -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, unknown>) => 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<string, TrafficSnapshot>;
@@ -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<void>;
onConfigureCamoufox: (profile: BrowserProfile) => void;
onCopyCookiesToProfile?: (profile: BrowserProfile) => void;
onImportCookies?: (profile: BrowserProfile) => void;
onExportCookies?: (profile: BrowserProfile) => void;
onOpenCookieManagement?: (profile: BrowserProfile) => void;
runningProfiles: Set<string>;
isUpdating: (browser: string) => boolean;
onDeleteSelectedProfiles: (profileIds: string[]) => Promise<void>;
@@ -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<SortingState>([]);
@@ -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<TableMeta>(
() => ({
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 (
<button
@@ -1940,14 +1926,7 @@ export function ProfilesDataTable({
}
}}
>
<span className="flex items-center gap-1">
{display}
{profile.ephemeral && (
<span className="px-1 py-0.5 text-[10px] leading-none rounded bg-muted text-muted-foreground font-medium">
Ephemeral
</span>
)}
</span>
{display}
</button>
);
},
@@ -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 (
<TagsCell
@@ -1996,13 +1970,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 (
<NoteCell
@@ -2027,13 +1996,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;
const hasProxyOverride = Object.hasOwn(
meta.proxyOverrides,
@@ -2331,18 +2295,11 @@ export function ProfilesDataTable({
const isCrossOs = isCrossOsProfile(profile);
const isRunning =
meta.isClient && meta.runningProfiles.has(profile.id);
const isBrowserUpdating =
meta.isClient && meta.isUpdating(profile.browser);
const isLaunching = meta.launchingProfiles.has(profile.id);
const isStopping = meta.stoppingProfiles.has(profile.id);
const isDisabled =
isRunning ||
isLaunching ||
isStopping ||
isBrowserUpdating ||
isCrossOs;
const isDeleteDisabled =
isRunning || isLaunching || isStopping || isBrowserUpdating;
isRunning || isLaunching || isStopping || isCrossOs;
const isDeleteDisabled = isRunning || isLaunching || isStopping;
return (
<div className="flex justify-end items-center">
@@ -2364,28 +2321,25 @@ export function ProfilesDataTable({
}}
disabled={isCrossOs}
>
View Network
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
if (meta.syncUnlocked) {
meta.onToggleProfileSync?.(profile);
}
}}
disabled={!meta.syncUnlocked || isCrossOs}
>
<span className="flex items-center gap-2">
{profile.sync_enabled ? "Disable Sync" : "Enable Sync"}
{!meta.syncUnlocked && <ProBadge />}
</span>
{meta.t("profiles.actions.viewNetwork")}
</DropdownMenuItem>
{!profile.ephemeral && (
<DropdownMenuItem
onClick={() => {
meta.onOpenProfileSyncDialog?.(profile);
}}
disabled={isCrossOs}
>
{meta.t("profiles.actions.syncSettings")}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => {
meta.onAssignProfilesToGroup?.([profile.id]);
}}
disabled={isDisabled}
>
Assign to Group
{meta.t("profiles.actions.assignToGroup")}
</DropdownMenuItem>
{(profile.browser === "camoufox" ||
profile.browser === "wayfern") &&
@@ -2396,10 +2350,7 @@ export function ProfilesDataTable({
}}
disabled={isDisabled}
>
<span className="flex items-center gap-2">
Change Fingerprint
{!meta.crossOsUnlocked && <ProBadge />}
</span>
{meta.t("profiles.actions.changeFingerprint")}
</DropdownMenuItem>
)}
{(profile.browser === "camoufox" ||
@@ -2415,7 +2366,7 @@ export function ProfilesDataTable({
disabled={isDisabled || !meta.crossOsUnlocked}
>
<span className="flex items-center gap-2">
Copy Cookies to Profile
{meta.t("profiles.actions.copyCookiesToProfile")}
{!meta.crossOsUnlocked && <ProBadge />}
</span>
</DropdownMenuItem>
@@ -2423,35 +2374,17 @@ export function ProfilesDataTable({
{(profile.browser === "camoufox" ||
profile.browser === "wayfern") &&
!profile.ephemeral &&
meta.onImportCookies && (
meta.onOpenCookieManagement && (
<DropdownMenuItem
onClick={() => {
if (meta.crossOsUnlocked) {
meta.onImportCookies?.(profile);
meta.onOpenCookieManagement?.(profile);
}
}}
disabled={isDisabled || !meta.crossOsUnlocked}
>
<span className="flex items-center gap-2">
Import Cookies
{!meta.crossOsUnlocked && <ProBadge />}
</span>
</DropdownMenuItem>
)}
{(profile.browser === "camoufox" ||
profile.browser === "wayfern") &&
!profile.ephemeral &&
meta.onExportCookies && (
<DropdownMenuItem
onClick={() => {
if (meta.crossOsUnlocked) {
meta.onExportCookies?.(profile);
}
}}
disabled={isDisabled || !meta.crossOsUnlocked}
>
<span className="flex items-center gap-2">
Export Cookies
{meta.t("cookies.management.menuItem")}
{!meta.crossOsUnlocked && <ProBadge />}
</span>
</DropdownMenuItem>
@@ -2463,7 +2396,7 @@ export function ProfilesDataTable({
}}
disabled={isDisabled}
>
Clone Profile
{meta.t("profiles.actions.clone")}
</DropdownMenuItem>
)}
<DropdownMenuItem
@@ -2472,7 +2405,7 @@ export function ProfilesDataTable({
}}
disabled={isDeleteDisabled}
>
Delete
{meta.t("profiles.actions.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -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(),
+127 -58
View File
@@ -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<SyncMode>(
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<SyncSettings>("get_sync_settings");
setHasConfig(Boolean(settings.sync_server_url && settings.sync_token));
const hasPassword = await invoke<boolean>("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({
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Profile Sync</DialogTitle>
<DialogTitle>{t("sync.mode.title", "Profile Sync")}</DialogTitle>
<DialogDescription>
Manage sync settings for &quot;{profile.name}&quot;
{t("sync.mode.description", {
name: profile.name,
defaultValue: `Manage sync settings for "${profile.name}"`,
})}
</DialogDescription>
</DialogHeader>
@@ -140,7 +159,9 @@ export function ProfileSyncDialog({
<div className="grid gap-4 py-4">
{!hasConfig && (
<div className="p-3 text-sm rounded-md bg-muted">
<p className="mb-2">Sync service not configured.</p>
<p className="mb-2">
{t("sync.mode.notConfigured", "Sync service not configured.")}
</p>
<Button
variant="outline"
size="sm"
@@ -149,39 +170,87 @@ export function ProfileSyncDialog({
onClose();
}}
>
Configure Sync Service
{t("sync.mode.configureService", "Configure Sync Service")}
</Button>
</div>
)}
{hasConfig && (
<>
<div className="flex justify-between items-center">
<div className="space-y-0.5">
<Label htmlFor="sync-enabled">Sync Enabled</Label>
<p className="text-sm text-muted-foreground">
Sync this profile across devices
</p>
<RadioGroup
value={syncMode}
onValueChange={handleModeChange}
disabled={isSaving}
className="grid gap-3"
>
<div className="flex items-start space-x-3">
<RadioGroupItem value="Disabled" id="sync-disabled" />
<Label htmlFor="sync-disabled" className="cursor-pointer">
<span className="font-medium">
{t("sync.mode.disabled", "Disabled")}
</span>
<p className="text-sm text-muted-foreground">
{t(
"sync.mode.disabledDescription",
"No sync for this profile",
)}
</p>
</Label>
</div>
<Checkbox
id="sync-enabled"
checked={syncEnabled}
onCheckedChange={handleToggleSync}
disabled={isSaving}
/>
</div>
<div className="flex items-start space-x-3">
<RadioGroupItem value="Regular" id="sync-regular" />
<Label htmlFor="sync-regular" className="cursor-pointer">
<span className="font-medium">
{t("sync.mode.regular", "Regular Sync")}
</span>
<p className="text-sm text-muted-foreground">
{t(
"sync.mode.regularDescription",
"Fast sync, unencrypted",
)}
</p>
</Label>
</div>
<div className="flex items-start space-x-3">
<RadioGroupItem value="Encrypted" id="sync-encrypted" />
<Label htmlFor="sync-encrypted" className="cursor-pointer">
<span className="font-medium">
{t("sync.mode.encrypted", "E2E Encrypted Sync")}
</span>
<p className="text-sm text-muted-foreground">
{t(
"sync.mode.encryptedDescription",
"Encrypted before upload. Server never sees plaintext data.",
)}
</p>
</Label>
</div>
</RadioGroup>
{syncMode === "Encrypted" && !hasE2ePassword && (
<div className="p-3 text-sm rounded-md bg-destructive/10 text-destructive">
{t(
"sync.mode.noPasswordWarning",
"E2E password not set. Please set a password in Settings.",
)}
</div>
)}
<div className="space-y-2">
<Label>Last Synced</Label>
<Label>{t("sync.mode.lastSynced", "Last Synced")}</Label>
<div className="flex gap-2 items-center">
<Badge variant="outline">
{formatLastSync(profile.last_sync)}
</Badge>
{syncEnabled && (
{isSyncEnabled(profile) && (
<Badge
variant={profile.last_sync ? "default" : "secondary"}
>
{profile.last_sync ? "Synced" : "Pending"}
{profile.last_sync
? t("common.status.synced")
: t("common.status.pending")}
</Badge>
)}
</div>
@@ -193,11 +262,11 @@ export function ProfileSyncDialog({
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Close
{t("common.buttons.close")}
</Button>
{hasConfig && syncEnabled && (
{hasConfig && isSyncEnabled(profile) && (
<LoadingButton onClick={handleSyncNow} isLoading={isSyncing}>
Sync Now
{t("sync.mode.syncNow", "Sync Now")}
</LoadingButton>
)}
</DialogFooter>
+153
View File
@@ -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<PermissionType | null>(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<boolean>("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({
</RippleButton>
</div>
{/* Sync Encryption Section */}
<div className="space-y-4">
<Label className="text-base font-medium">
{t("settings.encryption.title", "Sync Encryption")}
</Label>
<p className="text-xs text-muted-foreground">
{t(
"settings.encryption.description",
"Set a password to enable E2E encrypted sync. If you lose this password, encrypted profiles cannot be recovered.",
)}
</p>
{hasE2ePassword ? (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Badge variant="default">
{t("settings.encryption.passwordSet", "Active")}
</Badge>
<span className="text-sm text-muted-foreground">
{t(
"settings.encryption.passwordSetDescription",
"E2E encryption password is set",
)}
</span>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setHasE2ePassword(false);
setE2ePassword("");
setE2ePasswordConfirm("");
setE2eError("");
}}
>
{t("settings.encryption.changePassword", "Change Password")}
</Button>
<Button
variant="destructive"
size="sm"
onClick={async () => {
try {
await invoke("delete_e2e_password");
setHasE2ePassword(false);
showSuccessToast(
t(
"settings.encryption.removed",
"Encryption password removed",
),
);
} catch (error) {
showErrorToast(String(error));
}
}}
>
{t("settings.encryption.removePassword", "Remove Password")}
</Button>
</div>
</div>
) : (
<div className="space-y-3">
<Input
type="password"
placeholder={t(
"settings.encryption.passwordPlaceholder",
"Password (min 8 characters)",
)}
value={e2ePassword}
onChange={(e) => {
setE2ePassword(e.target.value);
setE2eError("");
}}
/>
<Input
type="password"
placeholder={t(
"settings.encryption.confirmPlaceholder",
"Confirm password",
)}
value={e2ePasswordConfirm}
onChange={(e) => {
setE2ePasswordConfirm(e.target.value);
setE2eError("");
}}
/>
{e2eError && (
<p className="text-sm text-destructive">{e2eError}</p>
)}
<LoadingButton
variant="default"
size="sm"
isLoading={isSavingE2e}
onClick={async () => {
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")}
</LoadingButton>
</div>
)}
</div>
{/* Commercial License Section */}
<div className="space-y-4">
<Label className="text-base font-medium">Commercial License</Label>
File diff suppressed because it is too large Load Diff
+50 -5
View File
@@ -60,7 +60,21 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
const [activeTab, setActiveTab] = useState<string>("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<SyncSettings>("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) {
</div>
</div>
{isConnected && (
{connectionStatus === "testing" && (
<div className="flex gap-2 items-center text-sm text-muted-foreground">
<div className="w-4 h-4 rounded-full border-2 border-current animate-spin border-t-transparent" />
{t("sync.status.syncing")}
</div>
)}
{connectionStatus === "connected" && (
<div className="flex gap-2 items-center text-sm text-muted-foreground">
<div className="w-2 h-2 rounded-full bg-green-500" />
{t("sync.status.connected")}
</div>
)}
{connectionStatus === "error" && (
<div className="flex gap-2 items-center text-sm text-muted-foreground">
<div className="w-2 h-2 rounded-full bg-red-500" />
{t("sync.status.disconnected")}
</div>
)}
</div>
)}
<DialogFooter className="flex gap-2">
{isConnected && (
{hasConfig && (
<Button
variant="outline"
onClick={handleDisconnect}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,80 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
interface WindowResizeWarningDialogProps {
isOpen: boolean;
onResult: (proceed: boolean) => void;
}
export function WindowResizeWarningDialog({
isOpen,
onResult,
}: WindowResizeWarningDialogProps) {
const { t } = useTranslation();
const [dontShowAgain, setDontShowAgain] = useState(false);
const handleContinue = async () => {
if (dontShowAgain) {
try {
await invoke("dismiss_window_resize_warning");
} catch (error) {
console.error("Failed to dismiss window resize warning:", error);
}
}
onResult(true);
};
const handleCancel = () => {
onResult(false);
};
return (
<Dialog open={isOpen}>
<DialogContent
className="sm:max-w-sm"
onEscapeKeyDown={(e) => e.preventDefault()}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>{t("warnings.windowResizeTitle")}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{t("warnings.windowResizeDescription")}
</p>
<div className="flex items-center space-x-2">
<Checkbox
id="dont-show-again"
checked={dontShowAgain}
onCheckedChange={(checked) => setDontShowAgain(checked === true)}
/>
<Label htmlFor="dont-show-again" className="text-sm">
{t("warnings.dontShowAgain")}
</Label>
</div>
<DialogFooter className="flex-row justify-between sm:justify-between">
<Button variant="ghost" onClick={handleCancel}>
{t("warnings.cancel")}
</Button>
<Button onClick={handleContinue}>{t("warnings.continue")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+7 -35
View File
@@ -12,7 +12,7 @@ import type { BrowserProfile } from "@/types";
export function useBrowserState(
profiles: BrowserProfile[],
runningProfiles: Set<string>,
isUpdating: (browser: string) => boolean,
_isUpdating: (browser: string) => boolean,
launchingProfiles: Set<string>,
stoppingProfiles: Set<string>,
) {
@@ -57,7 +57,6 @@ export function useBrowserState(
const isRunning = runningProfiles.has(profile.id);
const isLaunching = launchingProfiles.has(profile.id);
const isStopping = stoppingProfiles.has(profile.id);
const isBrowserUpdating = isUpdating(profile.browser);
// If the profile is launching or stopping, disable the button
if (isLaunching || isStopping) {
@@ -67,11 +66,6 @@ export function useBrowserState(
// If the profile is already running, it can always be stopped
if (isRunning) return true;
// If THIS specific browser is updating or downloading, block this profile
if (isBrowserUpdating) {
return false;
}
// For single-instance browsers, check if any instance is running
if (isSingleInstanceBrowser(profile.browser)) {
return !isAnyInstanceRunning(profile.browser);
@@ -82,7 +76,6 @@ export function useBrowserState(
[
runningProfiles,
isClient,
isUpdating,
isSingleInstanceBrowser,
isAnyInstanceRunning,
launchingProfiles,
@@ -98,18 +91,17 @@ export function useBrowserState(
(profile: BrowserProfile): boolean => {
if (!isClient) return false;
const isRunning = runningProfiles.has(profile.id);
const isLaunching = launchingProfiles.has(profile.id);
const isStopping = stoppingProfiles.has(profile.id);
const isBrowserUpdating = isUpdating(profile.browser);
// If this specific browser is updating, downloading, launching, or stopping, block it
if (isBrowserUpdating || isLaunching || isStopping) {
// If this specific browser is launching or stopping, block it
if (isLaunching || isStopping) {
return false;
}
// For single-instance browsers
if (isSingleInstanceBrowser(profile.browser)) {
const isRunning = runningProfiles.has(profile.id);
const runningInstancesOfType = profiles.filter(
(p) => p.browser === profile.browser && runningProfiles.has(p.id),
);
@@ -131,7 +123,6 @@ export function useBrowserState(
runningProfiles,
isClient,
isSingleInstanceBrowser,
isUpdating,
launchingProfiles,
stoppingProfiles,
],
@@ -147,22 +138,15 @@ export function useBrowserState(
const isRunning = runningProfiles.has(profile.id);
const isLaunching = launchingProfiles.has(profile.id);
const isStopping = stoppingProfiles.has(profile.id);
const isBrowserUpdating = isUpdating(profile.browser);
// If profile is running, launching, stopping, or browser is updating, block selection
if (isRunning || isLaunching || isStopping || isBrowserUpdating) {
// If profile is running, launching, or stopping, block selection
if (isRunning || isLaunching || isStopping) {
return false;
}
return true;
},
[
isClient,
runningProfiles,
launchingProfiles,
stoppingProfiles,
isUpdating,
],
[isClient, runningProfiles, launchingProfiles, stoppingProfiles],
);
/**
@@ -180,7 +164,6 @@ export function useBrowserState(
const isRunning = runningProfiles.has(profile.id);
const isLaunching = launchingProfiles.has(profile.id);
const isStopping = stoppingProfiles.has(profile.id);
const isBrowserUpdating = isUpdating(profile.browser);
if (isLaunching) {
return "Launching browser...";
@@ -194,10 +177,6 @@ export function useBrowserState(
return "";
}
if (isBrowserUpdating) {
return `${getBrowserDisplayName(profile.browser)} is being updated. Please wait for the update to complete.`;
}
if (
isSingleInstanceBrowser(profile.browser) &&
!canLaunchProfile(profile)
@@ -210,7 +189,6 @@ export function useBrowserState(
[
runningProfiles,
isClient,
isUpdating,
isSingleInstanceBrowser,
canLaunchProfile,
launchingProfiles,
@@ -231,7 +209,6 @@ export function useBrowserState(
const isLaunching = launchingProfiles.has(profile.id);
const isStopping = stoppingProfiles.has(profile.id);
const isBrowserUpdating = isUpdating(profile.browser);
if (isLaunching) {
return "Profile is currently launching. Please wait.";
@@ -241,10 +218,6 @@ export function useBrowserState(
return "Profile is currently stopping. Please wait.";
}
if (isBrowserUpdating) {
return `${getBrowserDisplayName(profile.browser)} is being updated. Please wait for the update to complete.`;
}
if (isSingleInstanceBrowser(profile.browser)) {
const runningInstancesOfType = profiles.filter(
(p) => p.browser === profile.browser && runningProfiles.has(p.id),
@@ -266,7 +239,6 @@ export function useBrowserState(
isClient,
canUseProfileForLinks,
isSingleInstanceBrowser,
isUpdating,
launchingProfiles,
stoppingProfiles,
],
+155 -5
View File
@@ -106,6 +106,22 @@
"description": "Configure Local API and MCP (Model Context Protocol) for integrating with external tools and AI assistants.",
"openSettings": "Open Integrations Settings"
},
"encryption": {
"title": "Sync Encryption",
"description": "Set a password to enable E2E encrypted sync. If you lose this password, encrypted profiles cannot be recovered.",
"passwordSet": "Active",
"passwordSetDescription": "E2E encryption password is set",
"noPassword": "No password set",
"passwordPlaceholder": "Password (min 8 characters)",
"confirmPlaceholder": "Confirm password",
"setPassword": "Set Password",
"changePassword": "Change Password",
"removePassword": "Remove Password",
"removed": "Encryption password removed",
"passwordSaved": "Encryption password set",
"passwordMismatch": "Passwords do not match",
"passwordTooShort": "Password must be at least 8 characters"
},
"commercial": {
"title": "Commercial License",
"trialActive": "Trial: {{days}} days, {{hours}} hours remaining",
@@ -157,11 +173,17 @@
"delete": "Delete",
"copyCookies": "Copy Cookies",
"configure": "Configure",
"clone": "Clone Profile"
"clone": "Clone Profile",
"viewNetwork": "View Network",
"syncSettings": "Sync Settings",
"assignToGroup": "Assign to Group",
"changeFingerprint": "Change Fingerprint",
"copyCookiesToProfile": "Copy Cookies to Profile"
},
"ephemeral": "Ephemeral",
"ephemeralDescription": "Browser data is deleted when closed",
"ephemeralBadge": "Ephemeral"
"ephemeralDescription": "The browser is forced to write profile data into memory instead of disk. Data is deleted when the browser is closed.",
"ephemeralBadge": "Ephemeral",
"ephemeralAlpha": "Alpha"
},
"createProfile": {
"title": "Create New Profile",
@@ -265,6 +287,25 @@
}
},
"sync": {
"mode": {
"title": "Profile Sync",
"description": "Manage sync settings for \"{{name}}\"",
"disabled": "Disabled",
"regular": "Regular Sync",
"encrypted": "E2E Encrypted Sync",
"disabledDescription": "No sync for this profile",
"regularDescription": "Fast sync, unencrypted",
"encryptedDescription": "Encrypted before upload. Server never sees plaintext data.",
"noPasswordWarning": "E2E password not set. Please set a password in Settings.",
"passwordRequired": "E2E password not set. Please set a password in Settings first.",
"enabledToast": "Sync enabled",
"disabledToast": "Sync disabled",
"syncQueued": "Sync queued",
"syncNow": "Sync Now",
"lastSynced": "Last Synced",
"notConfigured": "Sync service not configured.",
"configureService": "Configure Sync Service"
},
"title": "Account",
"config": "Sync Configuration",
"serverUrl": "Server URL",
@@ -486,7 +527,111 @@
"wayfern": "Wayfern"
},
"fingerprint": {
"crossOsWarning": "Spoofing fingerprint for a different operating system is less reliable because it is impossible to perfectly mimic all underlying components. Use with caution."
"crossOsWarning": "Spoofing fingerprint for a different operating system is less reliable because it is impossible to perfectly mimic all underlying components. Use with caution.",
"crossOsLimitations": "Cross-OS fingerprinting has limitations. System-level APIs may still reflect your actual operating system, and some features may have degraded performance.",
"osLabel": "Operating System Fingerprint",
"selectOSPlaceholder": "Select operating system",
"generateRandomOnLaunch": "Generate random fingerprint on every launch",
"generateRandomDescription": "When enabled, a new fingerprint will be generated each time the browser is launched.",
"generateRandomDescriptionAuto": "When enabled, a new fingerprint will be generated each time the browser is launched. The generated fingerprint is saved for reference.",
"autoLocationDescription": "Automatically configure location information based on proxy configuration or your connection if no proxy provided",
"editingDisabledRunning": "Fingerprint editing is disabled because the profile is currently running. Stop the profile to make changes.",
"editingDisabledRandomized": "Fingerprint editing is disabled because random fingerprint generation is enabled. Disable the option above to manually edit the fingerprint configuration.",
"advancedWarning": "Warning: Only edit these parameters if you know what you're doing. Incorrect values may break websites, make them detect you, and lead to hard-to-debug bugs.",
"basicWarning": "Warning: Only edit these parameters if you know what you're doing.",
"automatic": "Automatic",
"manual": "Manual",
"blockingOptions": "Blocking Options",
"blockImages": "Block Images",
"blockWebRTC": "Block WebRTC",
"blockWebGL": "Block WebGL",
"navigatorProperties": "Navigator Properties",
"userAgent": "User Agent",
"userAgentAndPlatform": "User Agent & Platform",
"platform": "Platform",
"platformVersion": "Platform Version",
"appVersion": "App Version",
"osCpu": "OS CPU",
"hardwareConcurrency": "Hardware Concurrency",
"maxTouchPoints": "Max Touch Points",
"doNotTrack": "Do Not Track",
"selectDntPlaceholder": "Select DNT value",
"dntAllowed": "0 (tracking allowed)",
"dntNotAllowed": "1 (tracking not allowed)",
"dntUnspecified": "unspecified",
"language": "Language",
"primaryLanguage": "Primary Language (navigator.language)",
"languages": "Languages (JSON array)",
"languageAndLocale": "Language & Locale",
"screenProperties": "Screen Properties",
"screenWidth": "Screen Width",
"screenHeight": "Screen Height",
"availableWidth": "Available Width",
"availableHeight": "Available Height",
"colorDepth": "Color Depth",
"pixelDepth": "Pixel Depth",
"devicePixelRatio": "Device Pixel Ratio",
"windowProperties": "Window Properties",
"outerWidth": "Outer Width",
"outerHeight": "Outer Height",
"innerWidth": "Inner Width",
"innerHeight": "Inner Height",
"screenX": "Screen X",
"screenY": "Screen Y",
"geolocation": "Geolocation",
"timezoneAndGeolocation": "Timezone & Geolocation",
"timezoneGeolocationDescription": "These values override the browser's timezone and geolocation APIs.",
"latitude": "Latitude",
"longitude": "Longitude",
"timezone": "Timezone",
"timezoneIana": "Timezone (IANA)",
"timezoneOffset": "Offset (minutes from UTC)",
"accuracy": "Accuracy (meters)",
"locale": "Locale",
"region": "Region",
"script": "Script",
"webglProperties": "WebGL Properties",
"webglVendor": "WebGL Vendor",
"webglRenderer": "WebGL Renderer",
"webglParameters": "WebGL Parameters",
"webglParametersJson": "WebGL Parameters (JSON)",
"webgl2Parameters": "WebGL2 Parameters",
"webglShaderPrecisionFormats": "WebGL Shader Precision Formats",
"webgl2ShaderPrecisionFormats": "WebGL2 Shader Precision Formats",
"canvasFingerprint": "Canvas Fingerprint",
"canvasNoiseSeed": "Canvas Noise Seed",
"canvasNoiseSeedDescription": "This seed is used to generate a consistent but unique canvas fingerprint. Each profile should have a different seed.",
"fonts": "Fonts",
"fontsJson": "Fonts (JSON array)",
"battery": "Battery",
"charging": "Charging",
"chargingTime": "Charging Time",
"dischargingTime": "Discharging Time",
"batteryLevel": "Level (0-1)",
"screenResolution": "Screen Resolution",
"maxWidth": "Max Width",
"maxHeight": "Max Height",
"minWidth": "Min Width",
"minHeight": "Min Height",
"hardwareProperties": "Hardware Properties",
"deviceMemory": "Device Memory (GB)",
"audioProperties": "Audio Properties",
"sampleRate": "Sample Rate",
"maxChannelCount": "Max Channel Count",
"vendorInfo": "Vendor Info",
"vendor": "Vendor",
"vendorSub": "Vendor Sub",
"productSub": "Product Sub",
"brand": "Brand",
"brandVersion": "Brand Version",
"proFeature": "This is a Pro feature"
},
"warnings": {
"windowResizeTitle": "Custom Window Dimensions",
"windowResizeDescription": "Changing browser window dimensions may increase the chance of website detection that browser information is spoofed.",
"dontShowAgain": "Don't show this again",
"continue": "Continue",
"cancel": "Cancel"
},
"syncAll": {
"title": "Enable Sync for Existing Items",
@@ -508,6 +653,10 @@
"cannotModify": "Cannot modify sync settings for a cross-OS profile"
},
"cookies": {
"management": {
"title": "Cookie Management",
"menuItem": "Cookie Management"
},
"import": {
"title": "Import Cookies",
"description": "Import cookies from a Netscape or JSON format file.",
@@ -532,6 +681,7 @@
"fingerprintLocked": "Fingerprint editing is a Pro feature",
"cookieCopyLocked": "Cookie copying is a Pro feature",
"cookieImportLocked": "Cookie import is a Pro feature",
"cookieExportLocked": "Cookie export is a Pro feature"
"cookieExportLocked": "Cookie export is a Pro feature",
"cookieManagementLocked": "Cookie management is a Pro feature"
}
}
+155 -5
View File
@@ -106,6 +106,22 @@
"description": "Configura la API Local y MCP (Protocolo de Contexto de Modelo) para integración con herramientas externas y asistentes de IA.",
"openSettings": "Abrir Configuración de Integraciones"
},
"encryption": {
"title": "Cifrado de sincronización",
"description": "Establece una contraseña para habilitar la sincronización cifrada E2E. Si pierdes esta contraseña, los perfiles cifrados no podrán recuperarse.",
"passwordSet": "Activo",
"passwordSetDescription": "La contraseña de cifrado E2E está configurada",
"noPassword": "Sin contraseña configurada",
"passwordPlaceholder": "Contraseña (mín. 8 caracteres)",
"confirmPlaceholder": "Confirmar contraseña",
"setPassword": "Establecer contraseña",
"changePassword": "Cambiar contraseña",
"removePassword": "Eliminar contraseña",
"removed": "Contraseña de cifrado eliminada",
"passwordSaved": "Contraseña de cifrado establecida",
"passwordMismatch": "Las contraseñas no coinciden",
"passwordTooShort": "La contraseña debe tener al menos 8 caracteres"
},
"commercial": {
"title": "Licencia Comercial",
"trialActive": "Prueba: {{days}} días, {{hours}} horas restantes",
@@ -157,11 +173,17 @@
"delete": "Eliminar",
"copyCookies": "Copiar Cookies",
"configure": "Configurar",
"clone": "Clonar perfil"
"clone": "Clonar perfil",
"viewNetwork": "Ver Red",
"syncSettings": "Configuración de Sincronización",
"assignToGroup": "Asignar a Grupo",
"changeFingerprint": "Cambiar Huella Digital",
"copyCookiesToProfile": "Copiar Cookies al Perfil"
},
"ephemeral": "Efímero",
"ephemeralDescription": "Los datos del navegador se eliminan al cerrarlo",
"ephemeralBadge": "Efímero"
"ephemeralDescription": "El navegador es forzado a escribir los datos del perfil en memoria en lugar del disco. Los datos se eliminan al cerrar el navegador.",
"ephemeralBadge": "Efímero",
"ephemeralAlpha": "Alpha"
},
"createProfile": {
"title": "Crear Nuevo Perfil",
@@ -265,6 +287,25 @@
}
},
"sync": {
"mode": {
"title": "Sincronización de perfil",
"description": "Gestionar configuración de sincronización para \"{{name}}\"",
"disabled": "Deshabilitado",
"regular": "Sincronización regular",
"encrypted": "Sincronización cifrada E2E",
"disabledDescription": "Sin sincronización para este perfil",
"regularDescription": "Sincronización rápida, sin cifrar",
"encryptedDescription": "Cifrado antes de subir. El servidor nunca ve los datos en texto plano.",
"noPasswordWarning": "Contraseña E2E no configurada. Por favor establece una contraseña en Ajustes.",
"passwordRequired": "Contraseña E2E no configurada. Por favor establece una contraseña en Ajustes primero.",
"enabledToast": "Sincronización habilitada",
"disabledToast": "Sincronización deshabilitada",
"syncQueued": "Sincronización en cola",
"syncNow": "Sincronizar ahora",
"lastSynced": "Última sincronización",
"notConfigured": "Servicio de sincronización no configurado.",
"configureService": "Configurar servicio de sincronización"
},
"title": "Servicio de Sincronización",
"config": "Configuración de Sincronización",
"serverUrl": "URL del Servidor",
@@ -486,7 +527,111 @@
"wayfern": "Wayfern"
},
"fingerprint": {
"crossOsWarning": "La suplantación de huella digital para un sistema operativo diferente es menos fiable porque es imposible imitar perfectamente todos los componentes subyacentes. Usar con precaución."
"crossOsWarning": "La suplantación de huella digital para un sistema operativo diferente es menos fiable porque es imposible imitar perfectamente todos los componentes subyacentes. Usar con precaución.",
"crossOsLimitations": "La suplantación de huella digital entre sistemas operativos tiene limitaciones. Las APIs a nivel de sistema pueden seguir reflejando su sistema operativo real y algunas funciones pueden tener un rendimiento reducido.",
"osLabel": "Huella digital del sistema operativo",
"selectOSPlaceholder": "Seleccionar sistema operativo",
"generateRandomOnLaunch": "Generar huella digital aleatoria en cada inicio",
"generateRandomDescription": "Cuando está activado, se generará una nueva huella digital cada vez que se inicie el navegador.",
"generateRandomDescriptionAuto": "Cuando está activado, se generará una nueva huella digital cada vez que se inicie el navegador. La huella digital generada se guarda como referencia.",
"autoLocationDescription": "Configurar automáticamente la información de ubicación basándose en la configuración del proxy o en su conexión si no se proporciona un proxy",
"editingDisabledRunning": "La edición de huellas digitales está desactivada porque el perfil se está ejecutando actualmente. Detenga el perfil para realizar cambios.",
"editingDisabledRandomized": "La edición de huellas digitales está desactivada porque la generación aleatoria de huellas digitales está activada. Desactive la opción anterior para editar manualmente la configuración de la huella digital.",
"advancedWarning": "Advertencia: Solo edite estos parámetros si sabe lo que está haciendo. Los valores incorrectos pueden romper sitios web, hacer que lo detecten y provocar errores difíciles de depurar.",
"basicWarning": "Advertencia: Solo edite estos parámetros si sabe lo que está haciendo.",
"automatic": "Automático",
"manual": "Manual",
"blockingOptions": "Opciones de bloqueo",
"blockImages": "Bloquear imágenes",
"blockWebRTC": "Bloquear WebRTC",
"blockWebGL": "Bloquear WebGL",
"navigatorProperties": "Propiedades del navegador",
"userAgent": "User Agent",
"userAgentAndPlatform": "User Agent y plataforma",
"platform": "Plataforma",
"platformVersion": "Versión de plataforma",
"appVersion": "Versión de la aplicación",
"osCpu": "OS CPU",
"hardwareConcurrency": "Concurrencia de hardware",
"maxTouchPoints": "Puntos táctiles máximos",
"doNotTrack": "Do Not Track",
"selectDntPlaceholder": "Seleccionar valor DNT",
"dntAllowed": "0 (rastreo permitido)",
"dntNotAllowed": "1 (rastreo no permitido)",
"dntUnspecified": "no especificado",
"language": "Idioma",
"primaryLanguage": "Idioma principal (navigator.language)",
"languages": "Idiomas (JSON array)",
"languageAndLocale": "Idioma y configuración regional",
"screenProperties": "Propiedades de pantalla",
"screenWidth": "Ancho de pantalla",
"screenHeight": "Alto de pantalla",
"availableWidth": "Ancho disponible",
"availableHeight": "Alto disponible",
"colorDepth": "Profundidad de color",
"pixelDepth": "Profundidad de píxel",
"devicePixelRatio": "Relación de píxeles del dispositivo",
"windowProperties": "Propiedades de ventana",
"outerWidth": "Ancho exterior",
"outerHeight": "Alto exterior",
"innerWidth": "Ancho interior",
"innerHeight": "Alto interior",
"screenX": "Screen X",
"screenY": "Screen Y",
"geolocation": "Geolocalización",
"timezoneAndGeolocation": "Zona horaria y geolocalización",
"timezoneGeolocationDescription": "Estos valores anulan las APIs de zona horaria y geolocalización del navegador.",
"latitude": "Latitud",
"longitude": "Longitud",
"timezone": "Zona horaria",
"timezoneIana": "Zona horaria (IANA)",
"timezoneOffset": "Desfase (minutos desde UTC)",
"accuracy": "Precisión (metros)",
"locale": "Configuración regional",
"region": "Región",
"script": "Script",
"webglProperties": "Propiedades de WebGL",
"webglVendor": "WebGL Vendor",
"webglRenderer": "WebGL Renderer",
"webglParameters": "Parámetros de WebGL",
"webglParametersJson": "Parámetros de WebGL (JSON)",
"webgl2Parameters": "Parámetros de WebGL2",
"webglShaderPrecisionFormats": "Formatos de precisión de WebGL Shader",
"webgl2ShaderPrecisionFormats": "Formatos de precisión de WebGL2 Shader",
"canvasFingerprint": "Canvas Fingerprint",
"canvasNoiseSeed": "Canvas Noise Seed",
"canvasNoiseSeedDescription": "Esta semilla se usa para generar una huella digital de Canvas consistente pero única. Cada perfil debe tener una semilla diferente.",
"fonts": "Fuentes",
"fontsJson": "Fuentes (JSON array)",
"battery": "Batería",
"charging": "Cargando",
"chargingTime": "Tiempo de carga",
"dischargingTime": "Tiempo de descarga",
"batteryLevel": "Nivel (0-1)",
"screenResolution": "Resolución de pantalla",
"maxWidth": "Ancho máximo",
"maxHeight": "Alto máximo",
"minWidth": "Ancho mínimo",
"minHeight": "Alto mínimo",
"hardwareProperties": "Propiedades de hardware",
"deviceMemory": "Memoria del dispositivo (GB)",
"audioProperties": "Propiedades de audio",
"sampleRate": "Frecuencia de muestreo",
"maxChannelCount": "Número máximo de canales",
"vendorInfo": "Información del proveedor",
"vendor": "Proveedor",
"vendorSub": "Vendor Sub",
"productSub": "Product Sub",
"brand": "Marca",
"brandVersion": "Versión de marca",
"proFeature": "Esta es una función Pro"
},
"warnings": {
"windowResizeTitle": "Dimensiones de ventana personalizadas",
"windowResizeDescription": "Cambiar las dimensiones de la ventana del navegador puede aumentar la posibilidad de que los sitios web detecten que la información del navegador está falsificada.",
"dontShowAgain": "No mostrar esto de nuevo",
"continue": "Continuar",
"cancel": "Cancelar"
},
"syncAll": {
"title": "Activar sincronización para elementos existentes",
@@ -508,6 +653,10 @@
"cannotModify": "No se pueden modificar los ajustes de sincronización de un perfil de otro sistema operativo"
},
"cookies": {
"management": {
"title": "Gestión de Cookies",
"menuItem": "Gestión de Cookies"
},
"import": {
"title": "Importar Cookies",
"description": "Importar cookies desde un archivo en formato Netscape o JSON.",
@@ -532,6 +681,7 @@
"fingerprintLocked": "La edición de huellas digitales es una función Pro",
"cookieCopyLocked": "La copia de cookies es una función Pro",
"cookieImportLocked": "La importación de cookies es una función Pro",
"cookieExportLocked": "La exportación de cookies es una función Pro"
"cookieExportLocked": "La exportación de cookies es una función Pro",
"cookieManagementLocked": "La gestión de cookies es una función Pro"
}
}
+155 -5
View File
@@ -106,6 +106,22 @@
"description": "Configurez l'API locale et MCP (Model Context Protocol) pour l'intégration avec des outils externes et des assistants IA.",
"openSettings": "Ouvrir les paramètres d'intégration"
},
"encryption": {
"title": "Chiffrement de synchronisation",
"description": "Définissez un mot de passe pour activer la synchronisation chiffrée E2E. Si vous perdez ce mot de passe, les profils chiffrés ne pourront pas être récupérés.",
"passwordSet": "Actif",
"passwordSetDescription": "Le mot de passe de chiffrement E2E est défini",
"noPassword": "Aucun mot de passe défini",
"passwordPlaceholder": "Mot de passe (min. 8 caractères)",
"confirmPlaceholder": "Confirmer le mot de passe",
"setPassword": "Définir le mot de passe",
"changePassword": "Changer le mot de passe",
"removePassword": "Supprimer le mot de passe",
"removed": "Mot de passe de chiffrement supprimé",
"passwordSaved": "Mot de passe de chiffrement défini",
"passwordMismatch": "Les mots de passe ne correspondent pas",
"passwordTooShort": "Le mot de passe doit contenir au moins 8 caractères"
},
"commercial": {
"title": "Licence commerciale",
"trialActive": "Essai: {{days}} jours, {{hours}} heures restantes",
@@ -157,11 +173,17 @@
"delete": "Supprimer",
"copyCookies": "Copier les cookies",
"configure": "Configurer",
"clone": "Cloner le profil"
"clone": "Cloner le profil",
"viewNetwork": "Voir le Réseau",
"syncSettings": "Paramètres de Synchronisation",
"assignToGroup": "Assigner au Groupe",
"changeFingerprint": "Changer l'Empreinte",
"copyCookiesToProfile": "Copier les Cookies vers le Profil"
},
"ephemeral": "Éphémère",
"ephemeralDescription": "Les données du navigateur sont supprimées à la fermeture",
"ephemeralBadge": "Éphémère"
"ephemeralDescription": "Le navigateur est forcé d'écrire les données du profil en mémoire au lieu du disque. Les données sont supprimées à la fermeture du navigateur.",
"ephemeralBadge": "Éphémère",
"ephemeralAlpha": "Alpha"
},
"createProfile": {
"title": "Créer un nouveau profil",
@@ -265,6 +287,25 @@
}
},
"sync": {
"mode": {
"title": "Synchronisation du profil",
"description": "Gérer les paramètres de synchronisation pour \"{{name}}\"",
"disabled": "Désactivé",
"regular": "Synchronisation régulière",
"encrypted": "Synchronisation chiffrée E2E",
"disabledDescription": "Pas de synchronisation pour ce profil",
"regularDescription": "Synchronisation rapide, non chiffrée",
"encryptedDescription": "Chiffré avant l'envoi. Le serveur ne voit jamais les données en clair.",
"noPasswordWarning": "Mot de passe E2E non défini. Veuillez définir un mot de passe dans les Paramètres.",
"passwordRequired": "Mot de passe E2E non défini. Veuillez d'abord définir un mot de passe dans les Paramètres.",
"enabledToast": "Synchronisation activée",
"disabledToast": "Synchronisation désactivée",
"syncQueued": "Synchronisation en file d'attente",
"syncNow": "Synchroniser maintenant",
"lastSynced": "Dernière synchronisation",
"notConfigured": "Service de synchronisation non configuré.",
"configureService": "Configurer le service de synchronisation"
},
"title": "Service de synchronisation",
"config": "Configuration de la synchronisation",
"serverUrl": "URL du serveur",
@@ -486,7 +527,111 @@
"wayfern": "Wayfern"
},
"fingerprint": {
"crossOsWarning": "L'usurpation d'empreinte pour un système d'exploitation différent est moins fiable car il est impossible d'imiter parfaitement tous les composants sous-jacents. À utiliser avec précaution."
"crossOsWarning": "L'usurpation d'empreinte pour un système d'exploitation différent est moins fiable car il est impossible d'imiter parfaitement tous les composants sous-jacents. À utiliser avec précaution.",
"crossOsLimitations": "L'usurpation d'empreinte inter-OS a des limitations. Les APIs au niveau du système peuvent toujours refléter votre système d'exploitation réel et certaines fonctionnalités peuvent avoir des performances réduites.",
"osLabel": "Empreinte du système d'exploitation",
"selectOSPlaceholder": "Sélectionner le système d'exploitation",
"generateRandomOnLaunch": "Générer une empreinte aléatoire à chaque lancement",
"generateRandomDescription": "Lorsque cette option est activée, une nouvelle empreinte sera générée à chaque lancement du navigateur.",
"generateRandomDescriptionAuto": "Lorsque cette option est activée, une nouvelle empreinte sera générée à chaque lancement du navigateur. L'empreinte générée est sauvegardée pour référence.",
"autoLocationDescription": "Configurer automatiquement les informations de localisation en fonction de la configuration du proxy ou de votre connexion si aucun proxy n'est fourni",
"editingDisabledRunning": "La modification de l'empreinte est désactivée car le profil est en cours d'exécution. Arrêtez le profil pour effectuer des modifications.",
"editingDisabledRandomized": "La modification de l'empreinte est désactivée car la génération aléatoire d'empreinte est activée. Désactivez l'option ci-dessus pour modifier manuellement la configuration de l'empreinte.",
"advancedWarning": "Avertissement : Ne modifiez ces paramètres que si vous savez ce que vous faites. Des valeurs incorrectes peuvent casser des sites web, vous faire détecter et provoquer des bugs difficiles à résoudre.",
"basicWarning": "Avertissement : Ne modifiez ces paramètres que si vous savez ce que vous faites.",
"automatic": "Automatique",
"manual": "Manuel",
"blockingOptions": "Options de blocage",
"blockImages": "Bloquer les images",
"blockWebRTC": "Bloquer WebRTC",
"blockWebGL": "Bloquer WebGL",
"navigatorProperties": "Propriétés du navigateur",
"userAgent": "User Agent",
"userAgentAndPlatform": "User Agent et plateforme",
"platform": "Plateforme",
"platformVersion": "Version de la plateforme",
"appVersion": "Version de l'application",
"osCpu": "OS CPU",
"hardwareConcurrency": "Concurrence matérielle",
"maxTouchPoints": "Points tactiles maximum",
"doNotTrack": "Do Not Track",
"selectDntPlaceholder": "Sélectionner la valeur DNT",
"dntAllowed": "0 (suivi autorisé)",
"dntNotAllowed": "1 (suivi non autorisé)",
"dntUnspecified": "non spécifié",
"language": "Langue",
"primaryLanguage": "Langue principale (navigator.language)",
"languages": "Langues (JSON array)",
"languageAndLocale": "Langue et paramètres régionaux",
"screenProperties": "Propriétés de l'écran",
"screenWidth": "Largeur de l'écran",
"screenHeight": "Hauteur de l'écran",
"availableWidth": "Largeur disponible",
"availableHeight": "Hauteur disponible",
"colorDepth": "Profondeur de couleur",
"pixelDepth": "Profondeur de pixel",
"devicePixelRatio": "Ratio de pixels de l'appareil",
"windowProperties": "Propriétés de la fenêtre",
"outerWidth": "Largeur extérieure",
"outerHeight": "Hauteur extérieure",
"innerWidth": "Largeur intérieure",
"innerHeight": "Hauteur intérieure",
"screenX": "Screen X",
"screenY": "Screen Y",
"geolocation": "Géolocalisation",
"timezoneAndGeolocation": "Fuseau horaire et géolocalisation",
"timezoneGeolocationDescription": "Ces valeurs remplacent les APIs de fuseau horaire et de géolocalisation du navigateur.",
"latitude": "Latitude",
"longitude": "Longitude",
"timezone": "Fuseau horaire",
"timezoneIana": "Fuseau horaire (IANA)",
"timezoneOffset": "Décalage (minutes depuis UTC)",
"accuracy": "Précision (mètres)",
"locale": "Paramètres régionaux",
"region": "Région",
"script": "Script",
"webglProperties": "Propriétés WebGL",
"webglVendor": "WebGL Vendor",
"webglRenderer": "WebGL Renderer",
"webglParameters": "Paramètres WebGL",
"webglParametersJson": "Paramètres WebGL (JSON)",
"webgl2Parameters": "Paramètres WebGL2",
"webglShaderPrecisionFormats": "Formats de précision WebGL Shader",
"webgl2ShaderPrecisionFormats": "Formats de précision WebGL2 Shader",
"canvasFingerprint": "Canvas Fingerprint",
"canvasNoiseSeed": "Canvas Noise Seed",
"canvasNoiseSeedDescription": "Cette graine est utilisée pour générer une empreinte Canvas cohérente mais unique. Chaque profil doit avoir une graine différente.",
"fonts": "Polices",
"fontsJson": "Polices (JSON array)",
"battery": "Batterie",
"charging": "En charge",
"chargingTime": "Temps de charge",
"dischargingTime": "Temps de décharge",
"batteryLevel": "Niveau (0-1)",
"screenResolution": "Résolution de l'écran",
"maxWidth": "Largeur maximale",
"maxHeight": "Hauteur maximale",
"minWidth": "Largeur minimale",
"minHeight": "Hauteur minimale",
"hardwareProperties": "Propriétés matérielles",
"deviceMemory": "Mémoire de l'appareil (Go)",
"audioProperties": "Propriétés audio",
"sampleRate": "Fréquence d'échantillonnage",
"maxChannelCount": "Nombre maximum de canaux",
"vendorInfo": "Informations du fournisseur",
"vendor": "Fournisseur",
"vendorSub": "Vendor Sub",
"productSub": "Product Sub",
"brand": "Marque",
"brandVersion": "Version de la marque",
"proFeature": "Ceci est une fonctionnalité Pro"
},
"warnings": {
"windowResizeTitle": "Dimensions de fenêtre personnalisées",
"windowResizeDescription": "Modifier les dimensions de la fenêtre du navigateur peut augmenter les chances de détection par les sites web que les informations du navigateur sont falsifiées.",
"dontShowAgain": "Ne plus afficher",
"continue": "Continuer",
"cancel": "Annuler"
},
"syncAll": {
"title": "Activer la synchronisation pour les éléments existants",
@@ -508,6 +653,10 @@
"cannotModify": "Impossible de modifier les paramètres de synchronisation d'un profil d'un autre système d'exploitation"
},
"cookies": {
"management": {
"title": "Gestion des Cookies",
"menuItem": "Gestion des Cookies"
},
"import": {
"title": "Importer des Cookies",
"description": "Importer des cookies depuis un fichier au format Netscape ou JSON.",
@@ -532,6 +681,7 @@
"fingerprintLocked": "La modification d'empreinte est une fonctionnalité Pro",
"cookieCopyLocked": "La copie de cookies est une fonctionnalité Pro",
"cookieImportLocked": "L'importation de cookies est une fonctionnalité Pro",
"cookieExportLocked": "L'exportation de cookies est une fonctionnalité Pro"
"cookieExportLocked": "L'exportation de cookies est une fonctionnalité Pro",
"cookieManagementLocked": "La gestion des cookies est une fonctionnalité Pro"
}
}
+155 -5
View File
@@ -106,6 +106,22 @@
"description": "外部ツールやAIアシスタントと統合するためのローカルAPIとMCP(モデルコンテキストプロトコル)を設定します。",
"openSettings": "統合設定を開く"
},
"encryption": {
"title": "同期暗号化",
"description": "E2E暗号化同期を有効にするためのパスワードを設定してください。このパスワードを紛失すると、暗号化されたプロファイルは復元できません。",
"passwordSet": "有効",
"passwordSetDescription": "E2E暗号化パスワードが設定されています",
"noPassword": "パスワード未設定",
"passwordPlaceholder": "パスワード(8文字以上)",
"confirmPlaceholder": "パスワードを確認",
"setPassword": "パスワードを設定",
"changePassword": "パスワードを変更",
"removePassword": "パスワードを削除",
"removed": "暗号化パスワードが削除されました",
"passwordSaved": "暗号化パスワードが設定されました",
"passwordMismatch": "パスワードが一致しません",
"passwordTooShort": "パスワードは8文字以上である必要があります"
},
"commercial": {
"title": "商用ライセンス",
"trialActive": "トライアル: 残り {{days}} 日 {{hours}} 時間",
@@ -157,11 +173,17 @@
"delete": "削除",
"copyCookies": "Cookieをコピー",
"configure": "設定",
"clone": "プロファイルを複製"
"clone": "プロファイルを複製",
"viewNetwork": "ネットワークを表示",
"syncSettings": "同期設定",
"assignToGroup": "グループに割り当て",
"changeFingerprint": "フィンガープリントを変更",
"copyCookiesToProfile": "Cookieをプロファイルにコピー"
},
"ephemeral": "一時的",
"ephemeralDescription": "ブラウザを閉じるとデータ削除されます",
"ephemeralBadge": "一時的"
"ephemeralDescription": "ブラウザはプロファイルデータをディスクではなくメモリに書き込むよう強制されます。ブラウザを閉じるとデータ削除されます",
"ephemeralBadge": "一時的",
"ephemeralAlpha": "Alpha"
},
"createProfile": {
"title": "新しいプロファイルを作成",
@@ -265,6 +287,25 @@
}
},
"sync": {
"mode": {
"title": "プロファイル同期",
"description": "\"{{name}}\" の同期設定を管理",
"disabled": "無効",
"regular": "通常同期",
"encrypted": "E2E暗号化同期",
"disabledDescription": "このプロファイルの同期なし",
"regularDescription": "高速同期、暗号化なし",
"encryptedDescription": "アップロード前に暗号化。サーバーは平文データを見ることができません。",
"noPasswordWarning": "E2Eパスワードが設定されていません。設定でパスワードを設定してください。",
"passwordRequired": "E2Eパスワードが設定されていません。まず設定でパスワードを設定してください。",
"enabledToast": "同期が有効になりました",
"disabledToast": "同期が無効になりました",
"syncQueued": "同期がキューに追加されました",
"syncNow": "今すぐ同期",
"lastSynced": "最終同期",
"notConfigured": "同期サービスが設定されていません。",
"configureService": "同期サービスを設定"
},
"title": "同期サービス",
"config": "同期設定",
"serverUrl": "サーバーURL",
@@ -486,7 +527,111 @@
"wayfern": "Wayfern"
},
"fingerprint": {
"crossOsWarning": "異なるオペレーティングシステムのフィンガープリント偽装は、すべての基盤コンポーネントを完璧に模倣することが不可能なため、信頼性が低くなります。注意してご使用ください。"
"crossOsWarning": "異なるオペレーティングシステムのフィンガープリント偽装は、すべての基盤コンポーネントを完璧に模倣することが不可能なため、信頼性が低くなります。注意してご使用ください。",
"crossOsLimitations": "クロスOSフィンガープリントには制限があります。システムレベルのAPIは実際のオペレーティングシステムを反映する場合があり、一部の機能のパフォーマンスが低下する可能性があります。",
"osLabel": "オペレーティングシステムのフィンガープリント",
"selectOSPlaceholder": "オペレーティングシステムを選択",
"generateRandomOnLaunch": "起動ごとにランダムなフィンガープリントを生成",
"generateRandomDescription": "有効にすると、ブラウザの起動ごとに新しいフィンガープリントが生成されます。",
"generateRandomDescriptionAuto": "有効にすると、ブラウザの起動ごとに新しいフィンガープリントが生成されます。生成されたフィンガープリントは参照用に保存されます。",
"autoLocationDescription": "プロキシ設定に基づいて位置情報を自動的に設定します。プロキシが提供されていない場合は接続情報を使用します。",
"editingDisabledRunning": "プロファイルが現在実行中のため、フィンガープリントの編集は無効です。変更するにはプロファイルを停止してください。",
"editingDisabledRandomized": "ランダムフィンガープリント生成が有効なため、フィンガープリントの編集は無効です。手動で編集するには上記のオプションを無効にしてください。",
"advancedWarning": "警告: これらのパラメータは、内容を理解している場合にのみ編集してください。不正な値はウェブサイトの動作を妨げたり、検出されたり、デバッグが困難なバグにつながる可能性があります。",
"basicWarning": "警告: これらのパラメータは、内容を理解している場合にのみ編集してください。",
"automatic": "自動",
"manual": "手動",
"blockingOptions": "ブロックオプション",
"blockImages": "画像をブロック",
"blockWebRTC": "WebRTCをブロック",
"blockWebGL": "WebGLをブロック",
"navigatorProperties": "Navigatorプロパティ",
"userAgent": "User Agent",
"userAgentAndPlatform": "User Agent & Platform",
"platform": "Platform",
"platformVersion": "Platform Version",
"appVersion": "App Version",
"osCpu": "OS CPU",
"hardwareConcurrency": "Hardware Concurrency",
"maxTouchPoints": "最大タッチポイント数",
"doNotTrack": "Do Not Track",
"selectDntPlaceholder": "DNT値を選択",
"dntAllowed": "0(トラッキング許可)",
"dntNotAllowed": "1(トラッキング不許可)",
"dntUnspecified": "未指定",
"language": "言語",
"primaryLanguage": "プライマリ言語 (navigator.language)",
"languages": "言語一覧 (JSON配列)",
"languageAndLocale": "言語とロケール",
"screenProperties": "画面プロパティ",
"screenWidth": "画面幅",
"screenHeight": "画面高さ",
"availableWidth": "利用可能な幅",
"availableHeight": "利用可能な高さ",
"colorDepth": "色深度",
"pixelDepth": "ピクセル深度",
"devicePixelRatio": "デバイスピクセル比",
"windowProperties": "ウィンドウプロパティ",
"outerWidth": "外側の幅",
"outerHeight": "外側の高さ",
"innerWidth": "内側の幅",
"innerHeight": "内側の高さ",
"screenX": "Screen X",
"screenY": "Screen Y",
"geolocation": "ジオロケーション",
"timezoneAndGeolocation": "タイムゾーンとジオロケーション",
"timezoneGeolocationDescription": "これらの値はブラウザのタイムゾーンとジオロケーションAPIを上書きします。",
"latitude": "緯度",
"longitude": "経度",
"timezone": "タイムゾーン",
"timezoneIana": "タイムゾーン (IANA)",
"timezoneOffset": "オフセット(UTCからの分数)",
"accuracy": "精度(メートル)",
"locale": "ロケール",
"region": "地域",
"script": "スクリプト",
"webglProperties": "WebGLプロパティ",
"webglVendor": "WebGL Vendor",
"webglRenderer": "WebGL Renderer",
"webglParameters": "WebGLパラメータ",
"webglParametersJson": "WebGLパラメータ (JSON)",
"webgl2Parameters": "WebGL2パラメータ",
"webglShaderPrecisionFormats": "WebGL Shader Precision Formats",
"webgl2ShaderPrecisionFormats": "WebGL2 Shader Precision Formats",
"canvasFingerprint": "Canvas Fingerprint",
"canvasNoiseSeed": "Canvas Noise Seed",
"canvasNoiseSeedDescription": "このシードは一貫性がありながらもユニークなCanvasフィンガープリントを生成するために使用されます。各プロファイルには異なるシードを設定してください。",
"fonts": "フォント",
"fontsJson": "フォント (JSON配列)",
"battery": "バッテリー",
"charging": "充電中",
"chargingTime": "充電時間",
"dischargingTime": "放電時間",
"batteryLevel": "レベル (0-1)",
"screenResolution": "画面解像度",
"maxWidth": "最大幅",
"maxHeight": "最大高さ",
"minWidth": "最小幅",
"minHeight": "最小高さ",
"hardwareProperties": "ハードウェアプロパティ",
"deviceMemory": "デバイスメモリ (GB)",
"audioProperties": "オーディオプロパティ",
"sampleRate": "サンプルレート",
"maxChannelCount": "最大チャンネル数",
"vendorInfo": "ベンダー情報",
"vendor": "ベンダー",
"vendorSub": "Vendor Sub",
"productSub": "Product Sub",
"brand": "ブランド",
"brandVersion": "ブランドバージョン",
"proFeature": "これはPro機能です"
},
"warnings": {
"windowResizeTitle": "カスタムウィンドウサイズ",
"windowResizeDescription": "ブラウザウィンドウのサイズを変更すると、ブラウザ情報が偽装されていることをウェブサイトに検出される可能性が高くなります。",
"dontShowAgain": "今後表示しない",
"continue": "続行",
"cancel": "キャンセル"
},
"syncAll": {
"title": "既存アイテムの同期を有効にする",
@@ -508,6 +653,10 @@
"cannotModify": "他のOSのプロファイルの同期設定は変更できません"
},
"cookies": {
"management": {
"title": "Cookie管理",
"menuItem": "Cookie管理"
},
"import": {
"title": "Cookieのインポート",
"description": "NetscapeまたはJSON形式のファイルからCookieをインポートします。",
@@ -532,6 +681,7 @@
"fingerprintLocked": "フィンガープリント編集はプロ機能です",
"cookieCopyLocked": "Cookieのコピーはプロ機能です",
"cookieImportLocked": "Cookieのインポートはプロ機能です",
"cookieExportLocked": "Cookieのエクスポートはプロ機能です"
"cookieExportLocked": "Cookieのエクスポートはプロ機能です",
"cookieManagementLocked": "Cookie管理はプロ機能です"
}
}
+155 -5
View File
@@ -106,6 +106,22 @@
"description": "Configure a API Local e MCP (Protocolo de Contexto de Modelo) para integração com ferramentas externas e assistentes de IA.",
"openSettings": "Abrir Configurações de Integrações"
},
"encryption": {
"title": "Criptografia de sincronização",
"description": "Defina uma senha para habilitar a sincronização criptografada E2E. Se você perder esta senha, os perfis criptografados não poderão ser recuperados.",
"passwordSet": "Ativo",
"passwordSetDescription": "A senha de criptografia E2E está definida",
"noPassword": "Sem senha definida",
"passwordPlaceholder": "Senha (mín. 8 caracteres)",
"confirmPlaceholder": "Confirmar senha",
"setPassword": "Definir senha",
"changePassword": "Alterar senha",
"removePassword": "Remover senha",
"removed": "Senha de criptografia removida",
"passwordSaved": "Senha de criptografia definida",
"passwordMismatch": "As senhas não coincidem",
"passwordTooShort": "A senha deve ter pelo menos 8 caracteres"
},
"commercial": {
"title": "Licença Comercial",
"trialActive": "Teste: {{days}} dias, {{hours}} horas restantes",
@@ -157,11 +173,17 @@
"delete": "Excluir",
"copyCookies": "Copiar Cookies",
"configure": "Configurar",
"clone": "Clonar perfil"
"clone": "Clonar perfil",
"viewNetwork": "Ver Rede",
"syncSettings": "Configurações de Sincronização",
"assignToGroup": "Atribuir ao Grupo",
"changeFingerprint": "Alterar Impressão Digital",
"copyCookiesToProfile": "Copiar Cookies para o Perfil"
},
"ephemeral": "Efêmero",
"ephemeralDescription": "Os dados do navegador são excluídos ao fechar",
"ephemeralBadge": "Efêmero"
"ephemeralDescription": "O navegador é forçado a gravar os dados do perfil na memória em vez do disco. Os dados são excluídos ao fechar o navegador.",
"ephemeralBadge": "Efêmero",
"ephemeralAlpha": "Alpha"
},
"createProfile": {
"title": "Criar Novo Perfil",
@@ -265,6 +287,25 @@
}
},
"sync": {
"mode": {
"title": "Sincronização de perfil",
"description": "Gerenciar configurações de sincronização para \"{{name}}\"",
"disabled": "Desabilitado",
"regular": "Sincronização regular",
"encrypted": "Sincronização criptografada E2E",
"disabledDescription": "Sem sincronização para este perfil",
"regularDescription": "Sincronização rápida, sem criptografia",
"encryptedDescription": "Criptografado antes do upload. O servidor nunca vê os dados em texto simples.",
"noPasswordWarning": "Senha E2E não definida. Por favor defina uma senha nas Configurações.",
"passwordRequired": "Senha E2E não definida. Por favor defina uma senha nas Configurações primeiro.",
"enabledToast": "Sincronização habilitada",
"disabledToast": "Sincronização desabilitada",
"syncQueued": "Sincronização na fila",
"syncNow": "Sincronizar agora",
"lastSynced": "Última sincronização",
"notConfigured": "Serviço de sincronização não configurado.",
"configureService": "Configurar serviço de sincronização"
},
"title": "Serviço de Sincronização",
"config": "Configuração de Sincronização",
"serverUrl": "URL do Servidor",
@@ -486,7 +527,111 @@
"wayfern": "Wayfern"
},
"fingerprint": {
"crossOsWarning": "A falsificação de impressão digital para um sistema operacional diferente é menos confiável porque é impossível imitar perfeitamente todos os componentes subjacentes. Use com cautela."
"crossOsWarning": "A falsificação de impressão digital para um sistema operacional diferente é menos confiável porque é impossível imitar perfeitamente todos os componentes subjacentes. Use com cautela.",
"crossOsLimitations": "A impressão digital entre sistemas operacionais tem limitações. APIs de nível de sistema ainda podem refletir seu sistema operacional real, e alguns recursos podem ter desempenho reduzido.",
"osLabel": "Impressão Digital do Sistema Operacional",
"selectOSPlaceholder": "Selecionar sistema operacional",
"generateRandomOnLaunch": "Gerar impressão digital aleatória a cada inicialização",
"generateRandomDescription": "Quando ativado, uma nova impressão digital será gerada cada vez que o navegador for iniciado.",
"generateRandomDescriptionAuto": "Quando ativado, uma nova impressão digital será gerada cada vez que o navegador for iniciado. A impressão digital gerada é salva para referência.",
"autoLocationDescription": "Configurar automaticamente as informações de localização com base na configuração do proxy ou na sua conexão se nenhum proxy for fornecido",
"editingDisabledRunning": "A edição de impressão digital está desativada porque o perfil está em execução. Pare o perfil para fazer alterações.",
"editingDisabledRandomized": "A edição de impressão digital está desativada porque a geração aleatória de impressão digital está ativada. Desative a opção acima para editar manualmente a configuração da impressão digital.",
"advancedWarning": "Aviso: Edite estes parâmetros apenas se souber o que está fazendo. Valores incorretos podem quebrar sites, fazer com que detectem você e levar a bugs difíceis de depurar.",
"basicWarning": "Aviso: Edite estes parâmetros apenas se souber o que está fazendo.",
"automatic": "Automático",
"manual": "Manual",
"blockingOptions": "Opções de Bloqueio",
"blockImages": "Bloquear Imagens",
"blockWebRTC": "Bloquear WebRTC",
"blockWebGL": "Bloquear WebGL",
"navigatorProperties": "Propriedades do Navigator",
"userAgent": "User Agent",
"userAgentAndPlatform": "User Agent & Platform",
"platform": "Platform",
"platformVersion": "Platform Version",
"appVersion": "App Version",
"osCpu": "OS CPU",
"hardwareConcurrency": "Hardware Concurrency",
"maxTouchPoints": "Pontos de Toque Máximos",
"doNotTrack": "Do Not Track",
"selectDntPlaceholder": "Selecionar valor DNT",
"dntAllowed": "0 (rastreamento permitido)",
"dntNotAllowed": "1 (rastreamento não permitido)",
"dntUnspecified": "não especificado",
"language": "Idioma",
"primaryLanguage": "Idioma Principal (navigator.language)",
"languages": "Idiomas (JSON array)",
"languageAndLocale": "Idioma e Localidade",
"screenProperties": "Propriedades da Tela",
"screenWidth": "Largura da Tela",
"screenHeight": "Altura da Tela",
"availableWidth": "Largura Disponível",
"availableHeight": "Altura Disponível",
"colorDepth": "Profundidade de Cor",
"pixelDepth": "Profundidade de Pixel",
"devicePixelRatio": "Proporção de Pixels do Dispositivo",
"windowProperties": "Propriedades da Janela",
"outerWidth": "Largura Externa",
"outerHeight": "Altura Externa",
"innerWidth": "Largura Interna",
"innerHeight": "Altura Interna",
"screenX": "Screen X",
"screenY": "Screen Y",
"geolocation": "Geolocalização",
"timezoneAndGeolocation": "Fuso Horário e Geolocalização",
"timezoneGeolocationDescription": "Estes valores substituem as APIs de fuso horário e geolocalização do navegador.",
"latitude": "Latitude",
"longitude": "Longitude",
"timezone": "Fuso Horário",
"timezoneIana": "Fuso Horário (IANA)",
"timezoneOffset": "Deslocamento (minutos a partir do UTC)",
"accuracy": "Precisão (metros)",
"locale": "Localidade",
"region": "Região",
"script": "Script",
"webglProperties": "Propriedades WebGL",
"webglVendor": "WebGL Vendor",
"webglRenderer": "WebGL Renderer",
"webglParameters": "Parâmetros WebGL",
"webglParametersJson": "Parâmetros WebGL (JSON)",
"webgl2Parameters": "Parâmetros WebGL2",
"webglShaderPrecisionFormats": "WebGL Shader Precision Formats",
"webgl2ShaderPrecisionFormats": "WebGL2 Shader Precision Formats",
"canvasFingerprint": "Canvas Fingerprint",
"canvasNoiseSeed": "Canvas Noise Seed",
"canvasNoiseSeedDescription": "Este seed é usado para gerar uma impressão digital Canvas consistente, mas única. Cada perfil deve ter um seed diferente.",
"fonts": "Fontes",
"fontsJson": "Fontes (JSON array)",
"battery": "Bateria",
"charging": "Carregando",
"chargingTime": "Tempo de Carregamento",
"dischargingTime": "Tempo de Descarregamento",
"batteryLevel": "Nível (0-1)",
"screenResolution": "Resolução da Tela",
"maxWidth": "Largura Máxima",
"maxHeight": "Altura Máxima",
"minWidth": "Largura Mínima",
"minHeight": "Altura Mínima",
"hardwareProperties": "Propriedades de Hardware",
"deviceMemory": "Memória do Dispositivo (GB)",
"audioProperties": "Propriedades de Áudio",
"sampleRate": "Taxa de Amostragem",
"maxChannelCount": "Contagem Máxima de Canais",
"vendorInfo": "Informações do Fabricante",
"vendor": "Fabricante",
"vendorSub": "Vendor Sub",
"productSub": "Product Sub",
"brand": "Marca",
"brandVersion": "Versão da Marca",
"proFeature": "Este é um recurso Pro"
},
"warnings": {
"windowResizeTitle": "Dimensões de janela personalizadas",
"windowResizeDescription": "Alterar as dimensões da janela do navegador pode aumentar a chance de detecção pelos sites de que as informações do navegador estão falsificadas.",
"dontShowAgain": "Não mostrar novamente",
"continue": "Continuar",
"cancel": "Cancelar"
},
"syncAll": {
"title": "Ativar sincronização para itens existentes",
@@ -508,6 +653,10 @@
"cannotModify": "Não é possível modificar as configurações de sincronização de um perfil de outro sistema operacional"
},
"cookies": {
"management": {
"title": "Gerenciamento de Cookies",
"menuItem": "Gerenciamento de Cookies"
},
"import": {
"title": "Importar Cookies",
"description": "Importar cookies de um arquivo no formato Netscape ou JSON.",
@@ -532,6 +681,7 @@
"fingerprintLocked": "A edição de impressão digital é um recurso Pro",
"cookieCopyLocked": "A cópia de cookies é um recurso Pro",
"cookieImportLocked": "A importação de cookies é um recurso Pro",
"cookieExportLocked": "A exportação de cookies é um recurso Pro"
"cookieExportLocked": "A exportação de cookies é um recurso Pro",
"cookieManagementLocked": "O gerenciamento de cookies é um recurso Pro"
}
}
+155 -5
View File
@@ -106,6 +106,22 @@
"description": "Настройте локальный API и MCP (Model Context Protocol) для интеграции с внешними инструментами и AI-ассистентами.",
"openSettings": "Открыть настройки интеграций"
},
"encryption": {
"title": "Шифрование синхронизации",
"description": "Установите пароль для включения E2E зашифрованной синхронизации. Если вы потеряете этот пароль, зашифрованные профили не могут быть восстановлены.",
"passwordSet": "Активно",
"passwordSetDescription": "Пароль шифрования E2E установлен",
"noPassword": "Пароль не установлен",
"passwordPlaceholder": "Пароль (мин. 8 символов)",
"confirmPlaceholder": "Подтвердите пароль",
"setPassword": "Установить пароль",
"changePassword": "Изменить пароль",
"removePassword": "Удалить пароль",
"removed": "Пароль шифрования удалён",
"passwordSaved": "Пароль шифрования установлен",
"passwordMismatch": "Пароли не совпадают",
"passwordTooShort": "Пароль должен содержать не менее 8 символов"
},
"commercial": {
"title": "Коммерческая лицензия",
"trialActive": "Пробный период: осталось {{days}} дней, {{hours}} часов",
@@ -157,11 +173,17 @@
"delete": "Удалить",
"copyCookies": "Копировать Cookie",
"configure": "Настроить",
"clone": "Клонировать профиль"
"clone": "Клонировать профиль",
"viewNetwork": "Просмотр сети",
"syncSettings": "Настройки синхронизации",
"assignToGroup": "Назначить группе",
"changeFingerprint": "Изменить отпечаток",
"copyCookiesToProfile": "Копировать Cookie в профиль"
},
"ephemeral": "Временный",
"ephemeralDescription": "Данные браузера удаляются при закрытии",
"ephemeralBadge": "Временный"
"ephemeralDescription": "Браузер принудительно записывает данные профиля в память вместо диска. Данные удаляются при закрытии браузера.",
"ephemeralBadge": "Временный",
"ephemeralAlpha": "Alpha"
},
"createProfile": {
"title": "Создать новый профиль",
@@ -265,6 +287,25 @@
}
},
"sync": {
"mode": {
"title": "Синхронизация профиля",
"description": "Управление настройками синхронизации для \"{{name}}\"",
"disabled": "Отключено",
"regular": "Обычная синхронизация",
"encrypted": "E2E зашифрованная синхронизация",
"disabledDescription": "Без синхронизации для этого профиля",
"regularDescription": "Быстрая синхронизация, без шифрования",
"encryptedDescription": "Шифрование перед загрузкой. Сервер никогда не видит данные в открытом виде.",
"noPasswordWarning": "Пароль E2E не установлен. Пожалуйста, установите пароль в Настройках.",
"passwordRequired": "Пароль E2E не установлен. Сначала установите пароль в Настройках.",
"enabledToast": "Синхронизация включена",
"disabledToast": "Синхронизация отключена",
"syncQueued": "Синхронизация в очереди",
"syncNow": "Синхронизировать сейчас",
"lastSynced": "Последняя синхронизация",
"notConfigured": "Сервис синхронизации не настроен.",
"configureService": "Настроить сервис синхронизации"
},
"title": "Служба синхронизации",
"config": "Настройка синхронизации",
"serverUrl": "URL сервера",
@@ -486,7 +527,111 @@
"wayfern": "Wayfern"
},
"fingerprint": {
"crossOsWarning": "Подмена отпечатка для другой операционной системы менее надёжна, так как невозможно идеально имитировать все базовые компоненты. Используйте с осторожностью."
"crossOsWarning": "Подмена отпечатка для другой операционной системы менее надёжна, так как невозможно идеально имитировать все базовые компоненты. Используйте с осторожностью.",
"crossOsLimitations": "Подмена отпечатка другой ОС имеет ограничения. Системные API могут по-прежнему отражать вашу реальную операционную систему, а некоторые функции могут работать с пониженной производительностью.",
"osLabel": "Отпечаток операционной системы",
"selectOSPlaceholder": "Выберите операционную систему",
"generateRandomOnLaunch": "Генерировать случайный отпечаток при каждом запуске",
"generateRandomDescription": "При включении новый отпечаток будет генерироваться при каждом запуске браузера.",
"generateRandomDescriptionAuto": "При включении новый отпечаток будет генерироваться при каждом запуске браузера. Сгенерированный отпечаток сохраняется для справки.",
"autoLocationDescription": "Автоматически настраивать информацию о местоположении на основе конфигурации прокси или вашего соединения, если прокси не указан",
"editingDisabledRunning": "Редактирование отпечатка отключено, так как профиль в данный момент запущен. Остановите профиль для внесения изменений.",
"editingDisabledRandomized": "Редактирование отпечатка отключено, так как включена генерация случайного отпечатка. Отключите опцию выше для ручного редактирования конфигурации отпечатка.",
"advancedWarning": "Внимание: редактируйте эти параметры только если вы знаете, что делаете. Неправильные значения могут нарушить работу сайтов, привести к вашему обнаружению и вызвать трудноотлаживаемые ошибки.",
"basicWarning": "Внимание: редактируйте эти параметры только если вы знаете, что делаете.",
"automatic": "Автоматически",
"manual": "Вручную",
"blockingOptions": "Параметры блокировки",
"blockImages": "Блокировать изображения",
"blockWebRTC": "Блокировать WebRTC",
"blockWebGL": "Блокировать WebGL",
"navigatorProperties": "Свойства Navigator",
"userAgent": "User Agent",
"userAgentAndPlatform": "User Agent и платформа",
"platform": "Платформа",
"platformVersion": "Версия платформы",
"appVersion": "Версия приложения",
"osCpu": "OS CPU",
"hardwareConcurrency": "Количество потоков процессора",
"maxTouchPoints": "Максимальное количество точек касания",
"doNotTrack": "Do Not Track",
"selectDntPlaceholder": "Выберите значение DNT",
"dntAllowed": "0 (отслеживание разрешено)",
"dntNotAllowed": "1 (отслеживание не разрешено)",
"dntUnspecified": "не указано",
"language": "Язык",
"primaryLanguage": "Основной язык (navigator.language)",
"languages": "Языки (JSON-массив)",
"languageAndLocale": "Язык и локаль",
"screenProperties": "Свойства экрана",
"screenWidth": "Ширина экрана",
"screenHeight": "Высота экрана",
"availableWidth": "Доступная ширина",
"availableHeight": "Доступная высота",
"colorDepth": "Глубина цвета",
"pixelDepth": "Глубина пикселей",
"devicePixelRatio": "Соотношение пикселей устройства",
"windowProperties": "Свойства окна",
"outerWidth": "Внешняя ширина",
"outerHeight": "Внешняя высота",
"innerWidth": "Внутренняя ширина",
"innerHeight": "Внутренняя высота",
"screenX": "Screen X",
"screenY": "Screen Y",
"geolocation": "Геолокация",
"timezoneAndGeolocation": "Часовой пояс и геолокация",
"timezoneGeolocationDescription": "Эти значения переопределяют API часового пояса и геолокации браузера.",
"latitude": "Широта",
"longitude": "Долгота",
"timezone": "Часовой пояс",
"timezoneIana": "Часовой пояс (IANA)",
"timezoneOffset": "Смещение (минуты от UTC)",
"accuracy": "Точность (метры)",
"locale": "Локаль",
"region": "Регион",
"script": "Скрипт",
"webglProperties": "Свойства WebGL",
"webglVendor": "WebGL Vendor",
"webglRenderer": "WebGL Renderer",
"webglParameters": "Параметры WebGL",
"webglParametersJson": "Параметры WebGL (JSON)",
"webgl2Parameters": "Параметры WebGL2",
"webglShaderPrecisionFormats": "WebGL Shader Precision Formats",
"webgl2ShaderPrecisionFormats": "WebGL2 Shader Precision Formats",
"canvasFingerprint": "Отпечаток Canvas",
"canvasNoiseSeed": "Canvas Noise Seed",
"canvasNoiseSeedDescription": "Это зерно используется для генерации постоянного, но уникального отпечатка Canvas. У каждого профиля должно быть своё зерно.",
"fonts": "Шрифты",
"fontsJson": "Шрифты (JSON-массив)",
"battery": "Батарея",
"charging": "Зарядка",
"chargingTime": "Время зарядки",
"dischargingTime": "Время разрядки",
"batteryLevel": "Уровень (0-1)",
"screenResolution": "Разрешение экрана",
"maxWidth": "Макс. ширина",
"maxHeight": "Макс. высота",
"minWidth": "Мин. ширина",
"minHeight": "Мин. высота",
"hardwareProperties": "Свойства оборудования",
"deviceMemory": "Память устройства (ГБ)",
"audioProperties": "Свойства аудио",
"sampleRate": "Частота дискретизации",
"maxChannelCount": "Максимальное количество каналов",
"vendorInfo": "Информация о производителе",
"vendor": "Производитель",
"vendorSub": "Vendor Sub",
"productSub": "Product Sub",
"brand": "Бренд",
"brandVersion": "Версия бренда",
"proFeature": "Это функция Pro"
},
"warnings": {
"windowResizeTitle": "Пользовательские размеры окна",
"windowResizeDescription": "Изменение размеров окна браузера может повысить вероятность обнаружения сайтами того, что информация браузера подменена.",
"dontShowAgain": "Больше не показывать",
"continue": "Продолжить",
"cancel": "Отмена"
},
"syncAll": {
"title": "Включить синхронизацию для существующих элементов",
@@ -508,6 +653,10 @@
"cannotModify": "Невозможно изменить настройки синхронизации профиля другой ОС"
},
"cookies": {
"management": {
"title": "Управление Cookies",
"menuItem": "Управление Cookies"
},
"import": {
"title": "Импорт Cookies",
"description": "Импорт cookies из файла в формате Netscape или JSON.",
@@ -532,6 +681,7 @@
"fingerprintLocked": "Редактирование отпечатка — функция Pro",
"cookieCopyLocked": "Копирование cookies — функция Pro",
"cookieImportLocked": "Импорт cookies — функция Pro",
"cookieExportLocked": "Экспорт cookies — функция Pro"
"cookieExportLocked": "Экспорт cookies — функция Pro",
"cookieManagementLocked": "Управление cookies — функция Pro"
}
}
+155 -5
View File
@@ -106,6 +106,22 @@
"description": "配置本地 API 和 MCP(模型上下文协议)以与外部工具和 AI 助手集成。",
"openSettings": "打开集成设置"
},
"encryption": {
"title": "同步加密",
"description": "设置密码以启用E2E加密同步。如果您丢失此密码,加密的配置文件将无法恢复。",
"passwordSet": "已启用",
"passwordSetDescription": "E2E加密密码已设置",
"noPassword": "未设置密码",
"passwordPlaceholder": "密码(至少8个字符)",
"confirmPlaceholder": "确认密码",
"setPassword": "设置密码",
"changePassword": "更改密码",
"removePassword": "删除密码",
"removed": "加密密码已删除",
"passwordSaved": "加密密码已设置",
"passwordMismatch": "密码不匹配",
"passwordTooShort": "密码必须至少8个字符"
},
"commercial": {
"title": "商业许可",
"trialActive": "试用期:剩余 {{days}} 天 {{hours}} 小时",
@@ -157,11 +173,17 @@
"delete": "删除",
"copyCookies": "复制 Cookies",
"configure": "配置",
"clone": "克隆配置文件"
"clone": "克隆配置文件",
"viewNetwork": "查看网络",
"syncSettings": "同步设置",
"assignToGroup": "分配到组",
"changeFingerprint": "更改指纹",
"copyCookiesToProfile": "复制 Cookies 到配置文件"
},
"ephemeral": "临时",
"ephemeralDescription": "关闭浏览器时数据将被删除",
"ephemeralBadge": "临时"
"ephemeralDescription": "浏览器被强制将配置数据写入内存而非磁盘。关闭浏览器时数据将被删除",
"ephemeralBadge": "临时",
"ephemeralAlpha": "Alpha"
},
"createProfile": {
"title": "创建新配置文件",
@@ -265,6 +287,25 @@
}
},
"sync": {
"mode": {
"title": "配置文件同步",
"description": "管理 \"{{name}}\" 的同步设置",
"disabled": "已禁用",
"regular": "常规同步",
"encrypted": "E2E加密同步",
"disabledDescription": "此配置文件不同步",
"regularDescription": "快速同步,无加密",
"encryptedDescription": "上传前加密。服务器永远不会看到明文数据。",
"noPasswordWarning": "未设置E2E密码。请在设置中设置密码。",
"passwordRequired": "未设置E2E密码。请先在设置中设置密码。",
"enabledToast": "同步已启用",
"disabledToast": "同步已禁用",
"syncQueued": "同步已排队",
"syncNow": "立即同步",
"lastSynced": "上次同步",
"notConfigured": "同步服务未配置。",
"configureService": "配置同步服务"
},
"title": "同步服务",
"config": "同步配置",
"serverUrl": "服务器 URL",
@@ -486,7 +527,111 @@
"wayfern": "Wayfern"
},
"fingerprint": {
"crossOsWarning": "伪装不同操作系统的指纹不太可靠,因为不可能完美模拟所有底层组件。请谨慎使用。"
"crossOsWarning": "伪装不同操作系统的指纹不太可靠,因为不可能完美模拟所有底层组件。请谨慎使用。",
"crossOsLimitations": "跨操作系统指纹伪装存在局限性。系统级 API 可能仍会反映您的实际操作系统,某些功能的性能可能会降低。",
"osLabel": "操作系统指纹",
"selectOSPlaceholder": "选择操作系统",
"generateRandomOnLaunch": "每次启动时生成随机指纹",
"generateRandomDescription": "启用后,每次启动浏览器时将生成新的指纹。",
"generateRandomDescriptionAuto": "启用后,每次启动浏览器时将生成新的指纹。生成的指纹会保存以供参考。",
"autoLocationDescription": "根据代理配置或您的连接(未提供代理时)自动配置位置信息",
"editingDisabledRunning": "指纹编辑已禁用,因为配置文件正在运行中。停止配置文件后才能进行更改。",
"editingDisabledRandomized": "指纹编辑已禁用,因为已启用随机指纹生成。禁用上方选项后才能手动编辑指纹配置。",
"advancedWarning": "警告:请仅在了解自己操作的情况下编辑这些参数。错误的值可能导致网站无法正常工作、被检测到,并引发难以调试的问题。",
"basicWarning": "警告:请仅在了解自己操作的情况下编辑这些参数。",
"automatic": "自动",
"manual": "手动",
"blockingOptions": "阻止选项",
"blockImages": "阻止图片",
"blockWebRTC": "阻止 WebRTC",
"blockWebGL": "阻止 WebGL",
"navigatorProperties": "Navigator 属性",
"userAgent": "User Agent",
"userAgentAndPlatform": "User Agent 和平台",
"platform": "平台",
"platformVersion": "平台版本",
"appVersion": "应用版本",
"osCpu": "OS CPU",
"hardwareConcurrency": "硬件并发数",
"maxTouchPoints": "最大触摸点数",
"doNotTrack": "Do Not Track",
"selectDntPlaceholder": "选择 DNT 值",
"dntAllowed": "0(允许跟踪)",
"dntNotAllowed": "1(不允许跟踪)",
"dntUnspecified": "未指定",
"language": "语言",
"primaryLanguage": "主要语言 (navigator.language)",
"languages": "语言列表 (JSON 数组)",
"languageAndLocale": "语言和区域设置",
"screenProperties": "屏幕属性",
"screenWidth": "屏幕宽度",
"screenHeight": "屏幕高度",
"availableWidth": "可用宽度",
"availableHeight": "可用高度",
"colorDepth": "颜色深度",
"pixelDepth": "像素深度",
"devicePixelRatio": "设备像素比",
"windowProperties": "窗口属性",
"outerWidth": "外部宽度",
"outerHeight": "外部高度",
"innerWidth": "内部宽度",
"innerHeight": "内部高度",
"screenX": "Screen X",
"screenY": "Screen Y",
"geolocation": "地理位置",
"timezoneAndGeolocation": "时区和地理位置",
"timezoneGeolocationDescription": "这些值会覆盖浏览器的时区和地理位置 API。",
"latitude": "纬度",
"longitude": "经度",
"timezone": "时区",
"timezoneIana": "时区 (IANA)",
"timezoneOffset": "偏移量(距 UTC 分钟数)",
"accuracy": "精度(米)",
"locale": "区域设置",
"region": "地区",
"script": "脚本",
"webglProperties": "WebGL 属性",
"webglVendor": "WebGL Vendor",
"webglRenderer": "WebGL Renderer",
"webglParameters": "WebGL 参数",
"webglParametersJson": "WebGL 参数 (JSON)",
"webgl2Parameters": "WebGL2 参数",
"webglShaderPrecisionFormats": "WebGL Shader Precision Formats",
"webgl2ShaderPrecisionFormats": "WebGL2 Shader Precision Formats",
"canvasFingerprint": "Canvas 指纹",
"canvasNoiseSeed": "Canvas Noise Seed",
"canvasNoiseSeedDescription": "此种子用于生成一致但唯一的 Canvas 指纹。每个配置文件应使用不同的种子。",
"fonts": "字体",
"fontsJson": "字体 (JSON 数组)",
"battery": "电池",
"charging": "充电中",
"chargingTime": "充电时间",
"dischargingTime": "放电时间",
"batteryLevel": "电量 (0-1)",
"screenResolution": "屏幕分辨率",
"maxWidth": "最大宽度",
"maxHeight": "最大高度",
"minWidth": "最小宽度",
"minHeight": "最小高度",
"hardwareProperties": "硬件属性",
"deviceMemory": "设备内存 (GB)",
"audioProperties": "音频属性",
"sampleRate": "采样率",
"maxChannelCount": "最大通道数",
"vendorInfo": "供应商信息",
"vendor": "供应商",
"vendorSub": "Vendor Sub",
"productSub": "Product Sub",
"brand": "品牌",
"brandVersion": "品牌版本",
"proFeature": "这是 Pro 功能"
},
"warnings": {
"windowResizeTitle": "自定义窗口尺寸",
"windowResizeDescription": "更改浏览器窗口尺寸可能会增加网站检测到浏览器信息被伪装的概率。",
"dontShowAgain": "不再显示",
"continue": "继续",
"cancel": "取消"
},
"syncAll": {
"title": "为现有项目启用同步",
@@ -508,6 +653,10 @@
"cannotModify": "无法修改跨操作系统配置文件的同步设置"
},
"cookies": {
"management": {
"title": "Cookie 管理",
"menuItem": "Cookie 管理"
},
"import": {
"title": "导入 Cookies",
"description": "从 Netscape 或 JSON 格式文件导入 Cookies。",
@@ -532,6 +681,7 @@
"fingerprintLocked": "指纹编辑是 Pro 功能",
"cookieCopyLocked": "Cookie 复制是 Pro 功能",
"cookieImportLocked": "Cookie 导入是 Pro 功能",
"cookieExportLocked": "Cookie 导出是 Pro 功能"
"cookieExportLocked": "Cookie 导出是 Pro 功能",
"cookieManagementLocked": "Cookie 管理是 Pro 功能"
}
}
+14 -1
View File
@@ -3,7 +3,12 @@
* Centralized helpers for browser name mapping, icons, etc.
*/
import { FaChrome, FaExclamationTriangle, FaFirefox } from "react-icons/fa";
import {
FaChrome,
FaExclamationTriangle,
FaFire,
FaFirefox,
} from "react-icons/fa";
/**
* Map internal browser names to display names
@@ -39,6 +44,14 @@ export function getBrowserIcon(browserType: string) {
}
}
export function getProfileIcon(profile: {
browser: string;
ephemeral?: boolean;
}) {
if (profile.ephemeral) return FaFire;
return getBrowserIcon(profile.browser);
}
export const getCurrentOS = () => {
if (typeof window !== "undefined") {
const userAgent = window.navigator.userAgent;
+8 -1
View File
@@ -26,12 +26,15 @@ export interface BrowserProfile {
group_id?: string; // Reference to profile group
tags?: string[];
note?: string; // User note
sync_enabled?: boolean; // Whether sync is enabled for this profile
sync_mode?: SyncMode;
encryption_salt?: string;
last_sync?: number; // Timestamp of last successful sync (epoch seconds)
host_os?: string; // OS where profile was created ("macos", "windows", "linux")
ephemeral?: boolean;
}
export type SyncMode = "Disabled" | "Regular" | "Encrypted";
export type SyncStatus = "Disabled" | "Syncing" | "Synced" | "Error";
export interface SyncSettings {
@@ -70,6 +73,10 @@ export interface ProxyCheckResult {
is_valid: boolean;
}
export function isSyncEnabled(profile: BrowserProfile): boolean {
return profile.sync_mode != null && profile.sync_mode !== "Disabled";
}
export const CLOUD_PROXY_ID = "cloud-included-proxy";
export interface StoredProxy {