mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-05-25 17:47:48 +02:00
feat: e2e encrypted sync
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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", ());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user