feat: move background processes to its own daemon

This commit is contained in:
zhom
2026-01-11 21:01:09 +04:00
parent 6756f88955
commit eeea15c65d
39 changed files with 3466 additions and 948 deletions
+918 -83
View File
File diff suppressed because it is too large Load Diff
+15 -2
View File
@@ -24,6 +24,10 @@ path = "src/main.rs"
name = "donut-proxy"
path = "src/bin/proxy_server.rs"
[[bin]]
name = "donut-daemon"
path = "src/bin/donut_daemon.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
@@ -65,7 +69,7 @@ mime_guess = "2"
once_cell = "1"
urlencoding = "2.1"
chrono = { version = "0.4", features = ["serde"] }
axum = "0.8.8"
axum = { version = "0.8.8", features = ["ws"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors"] }
rand = "0.9.2"
@@ -92,6 +96,15 @@ tempfile = "3"
maxminddb = "0.24"
quick-xml = { version = "0.37", features = ["serialize"] }
# Daemon dependencies (tray icon)
tray-icon = "0.19"
muda = "0.15"
tao = "0.34"
single-instance = "0.3"
image = "0.25"
dirs = "6"
crossbeam-channel = "0.5"
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
@@ -101,7 +114,7 @@ nix = { version = "0.29", features = ["signal", "process"] }
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.10"
objc2 = "0.6.1"
objc2-app-kit = { version = "0.3.1", features = ["NSWindow"] }
objc2-app-kit = { version = "0.3.1", features = ["NSWindow", "NSApplication", "NSRunningApplication"] }
[target.'cfg(target_os = "windows")'.dependencies]
winreg = "0.55"
+45 -33
View File
@@ -5,13 +5,6 @@ set -e
TARGET="${TARGET:-$(rustc -vV 2>/dev/null | sed -n 's|host: ||p' || echo "unknown")}"
MANIFEST_DIR="$(dirname "$0")"
# Determine binary name based on target
if [[ "$TARGET" == *"windows"* ]]; then
BIN_NAME="donut-proxy.exe"
else
BIN_NAME="donut-proxy"
fi
# Determine source path
HOST_TARGET=$(rustc -vV 2>/dev/null | sed -n 's|host: ||p' || echo "$TARGET")
if [[ "$TARGET" == "$HOST_TARGET" ]] || [[ "$TARGET" == "unknown" ]]; then
@@ -30,40 +23,59 @@ else
fi
fi
SOURCE="$SRC_DIR/$BIN_NAME"
DEST_DIR="$MANIFEST_DIR/binaries"
# Tauri expects the format: donut-proxy-{target} with hyphens
DEST_NAME="donut-proxy-$TARGET"
if [[ "$TARGET" == *"windows"* ]]; then
DEST_NAME="$DEST_NAME.exe"
fi
DEST="$DEST_DIR/$DEST_NAME"
# Create binaries directory if it doesn't exist
mkdir -p "$DEST_DIR"
# Copy the binary if it exists
if [[ -f "$SOURCE" ]]; then
cp "$SOURCE" "$DEST"
echo "Copied $BIN_NAME to $DEST"
else
echo "Warning: Binary not found at $SOURCE"
echo "Building donut-proxy binary..."
cd "$MANIFEST_DIR"
BUILD_ARGS=("build" "--bin" "donut-proxy")
if [[ -n "$PROFILE" ]] && [[ "$PROFILE" == "release" ]]; then
BUILD_ARGS+=("--release")
# Function to copy a binary
copy_binary() {
local BIN_BASE_NAME="$1"
# Determine binary name based on target
if [[ "$TARGET" == *"windows"* ]]; then
BIN_NAME="${BIN_BASE_NAME}.exe"
else
BIN_NAME="$BIN_BASE_NAME"
fi
if [[ -n "$TARGET" ]] && [[ "$TARGET" != "unknown" ]] && [[ "$TARGET" != "$HOST_TARGET" ]]; then
BUILD_ARGS+=("--target" "$TARGET")
SOURCE="$SRC_DIR/$BIN_NAME"
# Tauri expects the format: binary-{target} with hyphens
DEST_NAME="${BIN_BASE_NAME}-$TARGET"
if [[ "$TARGET" == *"windows"* ]]; then
DEST_NAME="$DEST_NAME.exe"
fi
cargo "${BUILD_ARGS[@]}"
DEST="$DEST_DIR/$DEST_NAME"
# Copy the binary if it exists
if [[ -f "$SOURCE" ]]; then
cp "$SOURCE" "$DEST"
echo "Built and copied $BIN_NAME to $DEST"
echo "Copied $BIN_NAME to $DEST"
else
echo "Error: Failed to build donut-proxy binary"
exit 1
echo "Warning: Binary not found at $SOURCE"
echo "Building $BIN_BASE_NAME binary..."
cd "$MANIFEST_DIR"
BUILD_ARGS=("build" "--bin" "$BIN_BASE_NAME")
if [[ -n "$PROFILE" ]] && [[ "$PROFILE" == "release" ]]; then
BUILD_ARGS+=("--release")
fi
if [[ -n "$TARGET" ]] && [[ "$TARGET" != "unknown" ]] && [[ "$TARGET" != "$HOST_TARGET" ]]; then
BUILD_ARGS+=("--target" "$TARGET")
fi
cargo "${BUILD_ARGS[@]}"
if [[ -f "$SOURCE" ]]; then
cp "$SOURCE" "$DEST"
echo "Built and copied $BIN_NAME to $DEST"
else
echo "Error: Failed to build $BIN_BASE_NAME binary"
exit 1
fi
fi
fi
}
# Copy donut-proxy binary
copy_binary "donut-proxy"
# Copy donut-daemon binary
copy_binary "donut-daemon"
View File
+10 -2
View File
@@ -1,5 +1,7 @@
use crate::browser::ProxySettings;
use crate::camoufox_manager::CamoufoxConfig;
use crate::daemon_ws::{ws_handler, WsState};
use crate::events;
use crate::group_manager::GROUP_MANAGER;
use crate::profile::manager::ProfileManager;
use crate::proxy_manager::PROXY_MANAGER;
@@ -15,7 +17,6 @@ use axum::{
use lazy_static::lazy_static;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::Emitter;
use tokio::net::TcpListener;
use tokio::sync::{mpsc, Mutex};
use tower_http::cors::CorsLayer;
@@ -276,7 +277,7 @@ impl ApiServer {
let random_port = rand::random::<u16>().saturating_add(10000);
match TcpListener::bind(format!("127.0.0.1:{random_port}")).await {
Ok(listener) => {
let _ = app_handle.emit(
let _ = events::emit(
"api-port-conflict",
format!("API server using fallback port {random_port}"),
);
@@ -329,8 +330,15 @@ impl ApiServer {
))
.layer(middleware::from_fn(terms_check_middleware));
// Create WebSocket route with its own state (no auth required for daemon IPC)
let ws_state = WsState::new();
let ws_routes = Router::new()
.route("/events", get(ws_handler))
.with_state(ws_state);
let app = Router::new()
.nest("/v1", v1_routes)
.nest("/ws", ws_routes)
.route("/openapi.json", get(move || async move { Json(api) }))
.layer(CorsLayer::permissive())
.with_state(state);
+8 -8
View File
@@ -64,13 +64,13 @@ Includes comprehensive unit tests for:
- File format support
*/
use crate::events;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use tauri::Emitter;
#[cfg(target_os = "linux")]
#[derive(Debug, Clone)]
@@ -707,7 +707,7 @@ impl AppAutoUpdater {
.to_string();
// Emit download start event
let _ = app_handle.emit(
let _ = events::emit(
"app-update-progress",
AppUpdateProgress {
stage: "downloading".to_string(),
@@ -724,7 +724,7 @@ impl AppAutoUpdater {
.await?;
// Emit extraction start event
let _ = app_handle.emit(
let _ = events::emit(
"app-update-progress",
AppUpdateProgress {
stage: "extracting".to_string(),
@@ -739,7 +739,7 @@ impl AppAutoUpdater {
let extracted_app_path = self.extract_update(&download_path, &temp_dir).await?;
// Emit installation start event
let _ = app_handle.emit(
let _ = events::emit(
"app-update-progress",
AppUpdateProgress {
stage: "installing".to_string(),
@@ -757,7 +757,7 @@ impl AppAutoUpdater {
let _ = fs::remove_dir_all(&temp_dir);
// Emit completion event
let _ = app_handle.emit(
let _ = events::emit(
"app-update-progress",
AppUpdateProgress {
stage: "completed".to_string(),
@@ -780,7 +780,7 @@ impl AppAutoUpdater {
download_url: &str,
dest_dir: &Path,
filename: &str,
app_handle: &tauri::AppHandle,
_app_handle: &tauri::AppHandle,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
let file_path = dest_dir.join(filename);
@@ -853,7 +853,7 @@ impl AppAutoUpdater {
"Unknown".to_string()
};
let _ = app_handle.emit(
let _ = events::emit(
"app-update-progress",
AppUpdateProgress {
stage: "downloading".to_string(),
@@ -869,7 +869,7 @@ impl AppAutoUpdater {
}
// Emit final download completion
let _ = app_handle.emit(
let _ = events::emit(
"app-update-progress",
AppUpdateProgress {
stage: "downloading".to_string(),
+2 -3
View File
@@ -1,11 +1,11 @@
use crate::browser_version_manager::{BrowserVersionInfo, BrowserVersionManager};
use crate::events;
use crate::profile::{BrowserProfile, ProfileManager};
use crate::settings_manager::SettingsManager;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::PathBuf;
use tauri::Emitter;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct UpdateNotification {
@@ -199,8 +199,7 @@ impl AutoUpdater {
"affected_profiles": affected_profiles
});
if let Err(e) =
app_handle_clone.emit("browser-auto-update-available", &auto_update_event)
if let Err(e) = events::emit("browser-auto-update-available", &auto_update_event)
{
log::error!("Failed to emit auto-update event for {browser}: {e}");
} else {
+401
View File
@@ -0,0 +1,401 @@
// Donut Browser Daemon - Background process for tray icon and services
// This runs independently of the main Tauri GUI
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::env;
use std::fs;
use std::path::PathBuf;
use std::process;
use std::sync::atomic::{AtomicBool, Ordering};
use muda::MenuEvent;
use serde::{Deserialize, Serialize};
use single_instance::SingleInstance;
use tao::event::{Event, StartCause};
use tao::event_loop::{ControlFlow, EventLoopBuilder};
use tokio::runtime::Runtime;
use tray_icon::TrayIcon;
use donutbrowser_lib::daemon::{autostart, services, tray};
static SHOULD_QUIT: AtomicBool = AtomicBool::new(false);
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct DaemonState {
daemon_pid: Option<u32>,
api_port: Option<u16>,
mcp_running: bool,
version: String,
}
fn get_state_path() -> PathBuf {
autostart::get_data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("daemon-state.json")
}
fn ensure_data_dir() -> std::io::Result<()> {
if let Some(data_dir) = autostart::get_data_dir() {
fs::create_dir_all(&data_dir)?;
}
Ok(())
}
fn read_state() -> DaemonState {
let path = get_state_path();
if path.exists() {
if let Ok(content) = fs::read_to_string(&path) {
if let Ok(state) = serde_json::from_str(&content) {
return state;
}
}
}
DaemonState::default()
}
fn write_state(state: &DaemonState) -> std::io::Result<()> {
let path = get_state_path();
let content = serde_json::to_string_pretty(state)?;
fs::write(path, content)
}
fn detach_from_parent() {
#[cfg(unix)]
{
unsafe {
libc::setsid();
}
}
}
fn spawn_detached() {
#[cfg(unix)]
{
match unsafe { libc::fork() } {
-1 => {
eprintln!("Fork failed");
process::exit(1);
}
0 => {
detach_from_parent();
}
_ => {
process::exit(0);
}
}
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
use std::process::{Command, Stdio};
const DETACHED_PROCESS: u32 = 0x00000008;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
let current_exe = env::current_exe().expect("Failed to get current exe path");
let _ = Command::new(current_exe)
.arg("--daemon-internal")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP)
.spawn();
process::exit(0);
}
}
fn set_high_priority() {
#[cfg(unix)]
{
// Set high priority so the daemon is killed last under resource pressure
// Negative nice value = higher priority. Try -10, fall back to -5 if it fails.
unsafe {
if libc::setpriority(libc::PRIO_PROCESS, 0, -10) != 0 {
let _ = libc::setpriority(libc::PRIO_PROCESS, 0, -5);
}
}
}
#[cfg(windows)]
{
use windows::Win32::Foundation::CloseHandle;
use windows::Win32::System::Threading::{
GetCurrentProcess, SetPriorityClass, ABOVE_NORMAL_PRIORITY_CLASS,
};
// Set high priority so the daemon is killed last under resource pressure
unsafe {
let handle = GetCurrentProcess();
let _ = SetPriorityClass(handle, ABOVE_NORMAL_PRIORITY_CLASS);
// GetCurrentProcess returns a pseudo-handle that doesn't need to be closed,
// but we do it anyway for consistency
let _ = CloseHandle(handle);
}
}
}
fn run_daemon() {
// Set high priority so the daemon is less likely to be killed under resource pressure
set_high_priority();
// Initialize logging
env_logger::Builder::from_default_env()
.filter_level(log::LevelFilter::Info)
.format_timestamp_millis()
.init();
if let Err(e) = ensure_data_dir() {
eprintln!("Failed to create data directory: {}", e);
process::exit(1);
}
let instance =
SingleInstance::new("donut-browser-daemon").expect("Failed to create single instance lock");
if !instance.is_single() {
eprintln!("Daemon is already running");
process::exit(1);
}
log::info!("[daemon] Starting with PID {}", process::id());
// Create tokio runtime for async operations
let rt = Runtime::new().expect("Failed to create tokio runtime");
// Start services in the background
let services_result = rt.block_on(async { services::DaemonServices::start().await });
let daemon_services = match services_result {
Ok(s) => s,
Err(e) => {
log::error!("Failed to start services: {}", e);
process::exit(1);
}
};
// Write initial state
let state = DaemonState {
daemon_pid: Some(process::id()),
api_port: daemon_services.api_port,
mcp_running: daemon_services.mcp_running,
version: env!("CARGO_PKG_VERSION").to_string(),
};
if let Err(e) = write_state(&state) {
log::error!("Failed to write state: {}", e);
}
// Prepare tray menu and icon (but don't create the tray icon yet)
let tray_menu = tray::TrayMenu::new();
tray_menu.update_api_status(daemon_services.api_port);
tray_menu.update_mcp_status(daemon_services.mcp_running);
let icon = tray::load_icon();
let menu_channel = MenuEvent::receiver();
// Create the event loop
let event_loop = EventLoopBuilder::new().build();
// Store tray icon in Option - created after event loop starts
let mut tray_icon: Option<TrayIcon> = None;
// Run the event loop
event_loop.run(move |event, _, control_flow| {
*control_flow = ControlFlow::Poll;
match event {
Event::NewEvents(StartCause::Init) => {
// Hide from dock on macOS (must be done after event loop starts)
#[cfg(target_os = "macos")]
{
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy};
if let Some(mtm) = MainThreadMarker::new() {
let app = NSApplication::sharedApplication(mtm);
app.setActivationPolicy(NSApplicationActivationPolicy::Accessory);
}
}
// Create tray icon after event loop has started (required for macOS)
tray_icon = Some(tray::create_tray_icon(icon.clone(), &tray_menu.menu));
log::info!("[daemon] Tray icon created");
}
Event::MainEventsCleared => {
// Process menu events
while let Ok(event) = menu_channel.try_recv() {
if event.id == tray_menu.open_item.id() || event.id == tray_menu.preferences_item.id() {
tray::open_gui();
} else if event.id == tray_menu.quit_item.id() {
log::info!("[daemon] Quit requested");
SHOULD_QUIT.store(true, Ordering::SeqCst);
}
}
if SHOULD_QUIT.load(Ordering::SeqCst) {
// Cleanup
let mut state = read_state();
state.daemon_pid = None;
let _ = write_state(&state);
log::info!("[daemon] Exiting");
*control_flow = ControlFlow::Exit;
}
}
_ => {}
}
// Keep tray_icon alive
let _ = &tray_icon;
});
}
fn stop_daemon() {
let state = read_state();
if let Some(pid) = state.daemon_pid {
#[cfg(unix)]
{
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
eprintln!("Sent stop signal to daemon (PID {})", pid);
}
#[cfg(windows)]
{
use std::process::Command;
let _ = Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/F"])
.output();
eprintln!("Sent stop signal to daemon (PID {})", pid);
}
} else {
eprintln!("Daemon is not running");
}
}
fn show_status() {
let state = read_state();
if let Some(pid) = state.daemon_pid {
#[cfg(unix)]
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)
};
#[cfg(not(any(unix, windows)))]
let is_running = false;
if is_running {
eprintln!("Daemon is running (PID {})", pid);
if let Some(port) = state.api_port {
eprintln!(" API: Running on port {}", port);
} else {
eprintln!(" API: Stopped");
}
eprintln!(
" MCP: {}",
if state.mcp_running {
"Running"
} else {
"Stopped"
}
);
} else {
eprintln!("Daemon is not running (stale PID in state file)");
}
} else {
eprintln!("Daemon is not running");
}
}
fn print_usage() {
eprintln!("Donut Browser Daemon");
eprintln!();
eprintln!("Usage: donut-daemon <command>");
eprintln!();
eprintln!("Commands:");
eprintln!(" start Start the daemon (detaches from terminal)");
eprintln!(" stop Stop the running daemon");
eprintln!(" status Show daemon status");
eprintln!(" run Run in foreground (for debugging)");
eprintln!(" autostart Manage autostart settings");
eprintln!(" enable Enable autostart on login");
eprintln!(" disable Disable autostart on login");
eprintln!(" status Show autostart status");
}
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
print_usage();
process::exit(1);
}
match args[1].as_str() {
"start" => {
eprintln!("Starting daemon...");
spawn_detached();
run_daemon();
}
"stop" => {
stop_daemon();
}
"status" => {
show_status();
}
"run" => {
run_daemon();
}
"--daemon-internal" => {
run_daemon();
}
"autostart" => {
if args.len() < 3 {
eprintln!("Usage: donut-daemon autostart <enable|disable|status>");
process::exit(1);
}
match args[2].as_str() {
"enable" => {
if let Err(e) = autostart::enable_autostart() {
eprintln!("Failed to enable autostart: {}", e);
process::exit(1);
}
eprintln!("Autostart enabled");
}
"disable" => {
if let Err(e) = autostart::disable_autostart() {
eprintln!("Failed to disable autostart: {}", e);
process::exit(1);
}
eprintln!("Autostart disabled");
}
"status" => {
if autostart::is_autostart_enabled() {
eprintln!("Autostart is enabled");
} else {
eprintln!("Autostart is disabled");
}
}
_ => {
eprintln!("Unknown autostart command: {}", args[2]);
process::exit(1);
}
}
}
_ => {
print_usage();
process::exit(1);
}
}
}
+17 -17
View File
@@ -1,6 +1,7 @@
use crate::browser::{create_browser, BrowserType, ProxySettings};
use crate::camoufox_manager::{CamoufoxConfig, CamoufoxManager};
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
use crate::events;
use crate::platform_browser;
use crate::profile::{BrowserProfile, ProfileManager};
use crate::proxy_manager::PROXY_MANAGER;
@@ -10,7 +11,6 @@ use serde::Serialize;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use sysinfo::System;
use tauri::Emitter;
pub struct BrowserRunner {
base_dirs: BaseDirs,
pub profile_manager: &'static ProfileManager,
@@ -261,7 +261,7 @@ impl BrowserRunner {
// Emit profiles-changed to trigger frontend to reload profiles from disk
// This ensures the UI displays the newly generated fingerprint
if let Err(e) = app_handle.emit("profiles-changed", ()) {
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
@@ -271,7 +271,7 @@ impl BrowserRunner {
);
// Emit profile update event to frontend
if let Err(e) = app_handle.emit("profile-updated", &updated_profile) {
if let Err(e) = events::emit("profile-updated", &updated_profile) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
@@ -287,7 +287,7 @@ impl BrowserRunner {
is_running: updated_profile.process_id.is_some(),
};
if let Err(e) = app_handle.emit("profile-running-changed", &payload) {
if let Err(e) = events::emit("profile-running-changed", &payload) {
log::warn!("Warning: Failed to emit profile running changed event: {e}");
} else {
log::info!(
@@ -459,7 +459,7 @@ impl BrowserRunner {
);
// Emit profiles-changed to trigger frontend to reload profiles from disk
if let Err(e) = app_handle.emit("profiles-changed", ()) {
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
@@ -469,7 +469,7 @@ impl BrowserRunner {
);
// Emit profile update event to frontend
if let Err(e) = app_handle.emit("profile-updated", &updated_profile) {
if let Err(e) = events::emit("profile-updated", &updated_profile) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
@@ -485,7 +485,7 @@ impl BrowserRunner {
is_running: updated_profile.process_id.is_some(),
};
if let Err(e) = app_handle.emit("profile-running-changed", &payload) {
if let Err(e) = events::emit("profile-running-changed", &payload) {
log::warn!("Warning: Failed to emit profile running changed event: {e}");
} else {
log::info!(
@@ -710,7 +710,7 @@ impl BrowserRunner {
);
// Emit profile update event to frontend
if let Err(e) = app_handle.emit("profile-updated", &updated_profile) {
if let Err(e) = events::emit("profile-updated", &updated_profile) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
@@ -725,7 +725,7 @@ impl BrowserRunner {
is_running: updated_profile.process_id.is_some(),
};
if let Err(e) = app_handle.emit("profile-running-changed", &payload) {
if let Err(e) = events::emit("profile-running-changed", &payload) {
log::warn!("Warning: Failed to emit profile running changed event: {e}");
} else {
log::info!(
@@ -1594,7 +1594,7 @@ impl BrowserRunner {
);
// Emit profile update event to frontend
if let Err(e) = app_handle.emit("profile-updated", &updated_profile) {
if let Err(e) = events::emit("profile-updated", &updated_profile) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
@@ -1609,7 +1609,7 @@ impl BrowserRunner {
is_running: false, // Explicitly set to false since we just killed it
};
if let Err(e) = app_handle.emit("profile-running-changed", &payload) {
if let Err(e) = events::emit("profile-running-changed", &payload) {
log::warn!("Warning: Failed to emit profile running changed event: {e}");
} else {
log::info!(
@@ -1912,7 +1912,7 @@ impl BrowserRunner {
);
// Emit profile update event to frontend
if let Err(e) = app_handle.emit("profile-updated", &updated_profile) {
if let Err(e) = events::emit("profile-updated", &updated_profile) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
@@ -1927,7 +1927,7 @@ impl BrowserRunner {
is_running: false,
};
if let Err(e) = app_handle.emit("profile-running-changed", &payload) {
if let Err(e) = events::emit("profile-running-changed", &payload) {
log::warn!("Warning: Failed to emit profile running changed event: {e}");
} else {
log::info!(
@@ -2181,7 +2181,7 @@ impl BrowserRunner {
);
// Emit profile update event to frontend
if let Err(e) = app_handle.emit("profile-updated", &updated_profile) {
if let Err(e) = events::emit("profile-updated", &updated_profile) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
@@ -2196,7 +2196,7 @@ impl BrowserRunner {
is_running: false, // Explicitly set to false since we just killed it
};
if let Err(e) = app_handle.emit("profile-running-changed", &payload) {
if let Err(e) = events::emit("profile-running-changed", &payload) {
log::warn!("Warning: Failed to emit profile running changed event: {e}");
} else {
log::info!(
@@ -2502,7 +2502,7 @@ pub async fn launch_browser_profile(
is_running: false,
};
if let Err(e) = app_handle.emit("profile-running-changed", &payload) {
if let Err(e) = events::emit("profile-running-changed", &payload) {
log::warn!("Warning: Failed to emit profile running changed event: {e}");
}
@@ -2579,7 +2579,7 @@ pub async fn kill_browser_profile(
is_running: true,
};
if let Err(e) = app_handle.emit("profile-running-changed", &payload) {
if let Err(e) = events::emit("profile-running-changed", &payload) {
log::warn!("Warning: Failed to emit profile running changed event: {e}");
}
+3 -1
View File
@@ -438,7 +438,9 @@ impl CamoufoxConfigBuilder {
let ip = match geoip {
GeoIPOption::Auto => {
// Fetch public IP, optionally through proxy
geolocation::fetch_public_ip(proxy_url.as_deref()).await?
geolocation::fetch_public_ip(proxy_url.as_deref())
.await
.map_err(geolocation::GeolocationError::from)?
}
GeoIPOption::IP(ip_str) => {
if !geolocation::validate_ip(&ip_str) {
+6 -101
View File
@@ -15,6 +15,9 @@ use std::net::IpAddr;
use std::path::PathBuf;
use std::str::FromStr;
// Re-export IP utilities for backward compatibility
pub use crate::ip_utils::{fetch_public_ip, is_ipv4, is_ipv6, validate_ip, IpError};
/// Geolocation error type.
#[derive(Debug, thiserror::Error)]
pub enum GeolocationError {
@@ -41,6 +44,9 @@ pub enum GeolocationError {
#[error("Network error: {0}")]
Network(String),
#[error("IP error: {0}")]
Ip(#[from] IpError),
}
/// Locale information.
@@ -379,84 +385,6 @@ pub fn get_geolocation(ip: &str) -> Result<Geolocation, GeolocationError> {
})
}
/// Validate an IP address (IPv4 or IPv6).
pub fn validate_ip(ip: &str) -> bool {
IpAddr::from_str(ip).is_ok()
}
/// Check if an IP is IPv4.
pub fn is_ipv4(ip: &str) -> bool {
if let Ok(addr) = IpAddr::from_str(ip) {
addr.is_ipv4()
} else {
false
}
}
/// Check if an IP is IPv6.
pub fn is_ipv6(ip: &str) -> bool {
if let Ok(addr) = IpAddr::from_str(ip) {
addr.is_ipv6()
} else {
false
}
}
/// Fetch public IP address, optionally through a proxy.
pub async fn fetch_public_ip(proxy: Option<&str>) -> Result<String, GeolocationError> {
let urls = [
"https://api.ipify.org",
"https://checkip.amazonaws.com",
"https://ipinfo.io/ip",
"https://icanhazip.com",
"https://ifconfig.co/ip",
"https://ipecho.net/plain",
];
let client_builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(5));
let client = if let Some(proxy_url) = proxy {
let proxy = reqwest::Proxy::all(proxy_url)
.map_err(|e| GeolocationError::Network(format!("Invalid proxy: {}", e)))?;
client_builder
.proxy(proxy)
.build()
.map_err(|e| GeolocationError::Network(e.to_string()))?
} else {
client_builder
.build()
.map_err(|e| GeolocationError::Network(e.to_string()))?
};
let mut last_error = None;
for url in &urls {
match client.get(*url).send().await {
Ok(response) if response.status().is_success() => match response.text().await {
Ok(text) => {
let ip = text.trim().to_string();
if validate_ip(&ip) {
return Ok(ip);
}
}
Err(e) => {
last_error = Some(format!("Failed to read response from {}: {}", url, e));
}
},
Ok(response) => {
last_error = Some(format!("HTTP {} from {}", response.status(), url));
}
Err(e) => {
last_error = Some(format!("Request to {} failed: {}", url, e));
}
}
}
Err(GeolocationError::Network(last_error.unwrap_or_else(|| {
"Failed to fetch public IP from any endpoint".to_string()
})))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -500,29 +428,6 @@ mod tests {
assert_eq!(locale_no_region.as_string(), "en");
}
#[test]
fn test_validate_ip() {
assert!(validate_ip("8.8.8.8"));
assert!(validate_ip("192.168.1.1"));
assert!(validate_ip("2001:4860:4860::8888"));
assert!(!validate_ip("invalid"));
assert!(!validate_ip("256.256.256.256"));
}
#[test]
fn test_is_ipv4() {
assert!(is_ipv4("8.8.8.8"));
assert!(!is_ipv4("2001:4860:4860::8888"));
assert!(!is_ipv4("invalid"));
}
#[test]
fn test_is_ipv6() {
assert!(is_ipv6("2001:4860:4860::8888"));
assert!(!is_ipv6("8.8.8.8"));
assert!(!is_ipv6("invalid"));
}
#[test]
fn test_normalize_locale() {
let locale = normalize_locale("en-US");
+4 -3
View File
@@ -1,7 +1,8 @@
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::{AppHandle, Emitter};
use tauri::AppHandle;
use crate::events;
use crate::settings_manager::SettingsManager;
const TRIAL_DURATION_SECONDS: u64 = 14 * 24 * 60 * 60; // 2 weeks
@@ -60,7 +61,7 @@ impl CommercialLicenseManager {
}
}
async fn get_or_set_first_launch(&self, app_handle: &AppHandle) -> Result<u64, String> {
async fn get_or_set_first_launch(&self, _app_handle: &AppHandle) -> Result<u64, String> {
let settings_manager = SettingsManager::instance();
let mut settings = settings_manager
.load_settings()
@@ -80,7 +81,7 @@ impl CommercialLicenseManager {
log::info!("First launch timestamp recorded: {now}");
// Emit event to notify frontend
if let Err(e) = app_handle.emit("first-launch-recorded", now) {
if let Err(e) = events::emit("first-launch-recorded", now) {
log::warn!("Failed to emit first-launch-recorded event: {e}");
}
+247
View File
@@ -0,0 +1,247 @@
use directories::ProjectDirs;
use std::fs;
use std::io;
use std::path::PathBuf;
fn get_daemon_path() -> Option<PathBuf> {
// First try to find the daemon binary in the same directory as the current executable
if let Ok(current_exe) = std::env::current_exe() {
let daemon_path = current_exe.parent()?.join(daemon_binary_name());
if daemon_path.exists() {
return Some(daemon_path);
}
}
// Try common installation paths
#[cfg(target_os = "macos")]
{
let paths = [
PathBuf::from("/Applications/Donut Browser.app/Contents/MacOS/donut-daemon"),
dirs::home_dir()?.join("Applications/Donut Browser.app/Contents/MacOS/donut-daemon"),
];
for path in paths {
if path.exists() {
return Some(path);
}
}
}
#[cfg(target_os = "windows")]
{
let paths = [
dirs::data_local_dir()?.join("Donut Browser/donut-daemon.exe"),
PathBuf::from("C:\\Program Files\\Donut Browser\\donut-daemon.exe"),
];
for path in paths {
if path.exists() {
return Some(path);
}
}
}
#[cfg(target_os = "linux")]
{
let paths = [
PathBuf::from("/usr/bin/donut-daemon"),
PathBuf::from("/usr/local/bin/donut-daemon"),
dirs::home_dir()?.join(".local/bin/donut-daemon"),
];
for path in paths {
if path.exists() {
return Some(path);
}
}
}
None
}
fn daemon_binary_name() -> &'static str {
#[cfg(windows)]
{
"donut-daemon.exe"
}
#[cfg(not(windows))]
{
"donut-daemon"
}
}
#[cfg(target_os = "macos")]
pub fn enable_autostart() -> io::Result<()> {
let daemon_path = get_daemon_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Daemon binary not found"))?;
let plist_dir = dirs::home_dir()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Home directory not found"))?
.join("Library/LaunchAgents");
fs::create_dir_all(&plist_dir)?;
let plist_path = plist_dir.join("com.donutbrowser.daemon.plist");
let plist_content = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.donutbrowser.daemon</string>
<key>ProgramArguments</key>
<array>
<string>{}</string>
<string>start</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<false/>
<key>StandardOutPath</key>
<string>/tmp/donut-daemon.out.log</string>
<key>StandardErrorPath</key>
<string>/tmp/donut-daemon.err.log</string>
</dict>
</plist>
"#,
daemon_path.display()
);
fs::write(&plist_path, plist_content)?;
log::info!("Created launch agent at {:?}", plist_path);
Ok(())
}
#[cfg(target_os = "macos")]
pub fn disable_autostart() -> io::Result<()> {
let plist_path = dirs::home_dir()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Home directory not found"))?
.join("Library/LaunchAgents/com.donutbrowser.daemon.plist");
if plist_path.exists() {
fs::remove_file(&plist_path)?;
log::info!("Removed launch agent at {:?}", plist_path);
}
Ok(())
}
#[cfg(target_os = "macos")]
pub fn is_autostart_enabled() -> bool {
dirs::home_dir()
.map(|h| {
h.join("Library/LaunchAgents/com.donutbrowser.daemon.plist")
.exists()
})
.unwrap_or(false)
}
#[cfg(target_os = "linux")]
pub fn enable_autostart() -> io::Result<()> {
let daemon_path = get_daemon_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Daemon binary not found"))?;
let autostart_dir = dirs::config_dir()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Config directory not found"))?
.join("autostart");
fs::create_dir_all(&autostart_dir)?;
let desktop_path = autostart_dir.join("donut-daemon.desktop");
let desktop_content = format!(
r#"[Desktop Entry]
Type=Application
Name=Donut Browser Daemon
Exec={} start
Hidden=false
NoDisplay=true
X-GNOME-Autostart-enabled=true
"#,
daemon_path.display()
);
fs::write(&desktop_path, desktop_content)?;
log::info!("Created autostart entry at {:?}", desktop_path);
Ok(())
}
#[cfg(target_os = "linux")]
pub fn disable_autostart() -> io::Result<()> {
let desktop_path = dirs::config_dir()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Config directory not found"))?
.join("autostart/donut-daemon.desktop");
if desktop_path.exists() {
fs::remove_file(&desktop_path)?;
log::info!("Removed autostart entry at {:?}", desktop_path);
}
Ok(())
}
#[cfg(target_os = "linux")]
pub fn is_autostart_enabled() -> bool {
dirs::config_dir()
.map(|c| c.join("autostart/donut-daemon.desktop").exists())
.unwrap_or(false)
}
#[cfg(target_os = "windows")]
pub fn enable_autostart() -> io::Result<()> {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let daemon_path = get_daemon_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Daemon binary not found"))?;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let (key, _) = hkcu.create_subkey("Software\\Microsoft\\Windows\\CurrentVersion\\Run")?;
key.set_value(
"DonutBrowserDaemon",
&format!("\"{}\" start", daemon_path.display()),
)?;
log::info!("Added registry autostart entry");
Ok(())
}
#[cfg(target_os = "windows")]
pub fn disable_autostart() -> io::Result<()> {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
if let Ok(key) = hkcu.open_subkey_with_flags(
"Software\\Microsoft\\Windows\\CurrentVersion\\Run",
winreg::enums::KEY_WRITE,
) {
let _ = key.delete_value("DonutBrowserDaemon");
log::info!("Removed registry autostart entry");
}
Ok(())
}
#[cfg(target_os = "windows")]
pub fn is_autostart_enabled() -> bool {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
if let Ok(key) = hkcu.open_subkey("Software\\Microsoft\\Windows\\CurrentVersion\\Run") {
key.get_value::<String, _>("DonutBrowserDaemon").is_ok()
} else {
false
}
}
pub fn get_data_dir() -> Option<PathBuf> {
if let Some(proj_dirs) = ProjectDirs::from("com", "donutbrowser", "Donut Browser") {
Some(proj_dirs.data_dir().to_path_buf())
} else {
dirs::home_dir().map(|h| h.join(".donutbrowser"))
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod autostart;
pub mod services;
pub mod tray;
+51
View File
@@ -0,0 +1,51 @@
use crate::events::{self, DaemonEmitter, DaemonEvent};
use std::sync::Arc;
use tokio::sync::broadcast;
pub struct DaemonServices {
pub api_port: Option<u16>,
pub mcp_running: bool,
event_emitter: Arc<DaemonEmitter>,
}
impl DaemonServices {
pub async fn start() -> Result<Self, String> {
log::info!("Starting daemon services...");
// Create the daemon event emitter
let (emitter, _rx) = DaemonEmitter::with_capacity(256);
let emitter_arc = Arc::new(emitter);
// Set the global event emitter
if let Err(e) = events::set_global_emitter(emitter_arc.clone()) {
log::warn!("Failed to set global event emitter: {}", e);
}
// NOTE: The API server currently requires an AppHandle which is only available
// in the Tauri GUI context. For now, the daemon starts with minimal services.
// The GUI will start the API server when it connects to the daemon.
//
// TODO: Refactor API server to work without AppHandle for daemon mode
let api_port = None;
let mcp_running = false;
log::info!("Daemon services started (minimal mode - waiting for GUI connection)");
Ok(Self {
api_port,
mcp_running,
event_emitter: emitter_arc,
})
}
pub fn subscribe_events(&self) -> broadcast::Receiver<DaemonEvent> {
self.event_emitter.subscribe()
}
pub async fn stop(&mut self) {
log::info!("Stopping daemon services...");
self.api_port = None;
self.mcp_running = false;
}
}
+150
View File
@@ -0,0 +1,150 @@
use muda::{Menu, MenuItem, PredefinedMenuItem, Submenu};
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use tray_icon::{Icon, TrayIcon, TrayIconBuilder};
static GUI_RUNNING: AtomicBool = AtomicBool::new(false);
pub fn load_icon() -> Icon {
let icon_bytes = include_bytes!("../../icons/32x32.png");
let image = image::load_from_memory(icon_bytes)
.expect("Failed to load icon")
.into_rgba8();
let (width, height) = image.dimensions();
let rgba = image.into_raw();
Icon::from_rgba(rgba, width, height).expect("Failed to create icon")
}
pub struct TrayMenu {
pub menu: Menu,
pub open_item: MenuItem,
pub running_profiles_submenu: Submenu,
pub api_status_item: MenuItem,
pub mcp_status_item: MenuItem,
pub preferences_item: MenuItem,
pub quit_item: MenuItem,
}
impl Default for TrayMenu {
fn default() -> Self {
Self::new()
}
}
impl TrayMenu {
pub fn new() -> Self {
let menu = Menu::new();
let open_item = MenuItem::new("Open Donut Browser", true, None);
let running_profiles_submenu = Submenu::new("Running Profiles", true);
let no_profiles_item = MenuItem::new("No running profiles", false, None);
running_profiles_submenu.append(&no_profiles_item).unwrap();
let separator1 = PredefinedMenuItem::separator();
let api_status_item = MenuItem::new("API: Starting...", false, None);
let mcp_status_item = MenuItem::new("MCP: Starting...", false, None);
let separator2 = PredefinedMenuItem::separator();
let preferences_item = MenuItem::new("Preferences...", true, None);
let quit_item = MenuItem::new("Quit Donut Browser", true, None);
menu.append(&open_item).unwrap();
menu.append(&running_profiles_submenu).unwrap();
menu.append(&separator1).unwrap();
menu.append(&api_status_item).unwrap();
menu.append(&mcp_status_item).unwrap();
menu.append(&separator2).unwrap();
menu.append(&preferences_item).unwrap();
menu.append(&quit_item).unwrap();
Self {
menu,
open_item,
running_profiles_submenu,
api_status_item,
mcp_status_item,
preferences_item,
quit_item,
}
}
pub fn update_api_status(&self, port: Option<u16>) {
let text = match port {
Some(p) => format!("API: Running on :{}", p),
None => "API: Stopped".to_string(),
};
self.api_status_item.set_text(&text);
}
pub fn update_mcp_status(&self, running: bool) {
let text = if running {
"MCP: Running"
} else {
"MCP: Stopped"
};
self.mcp_status_item.set_text(text);
}
}
pub fn create_tray_icon(icon: Icon, menu: &Menu) -> TrayIcon {
TrayIconBuilder::new()
.with_icon(icon)
.with_tooltip("Donut Browser")
.with_menu(Box::new(menu.clone()))
.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;
}
log::info!("Opening GUI...");
#[cfg(target_os = "macos")]
{
let _ = Command::new("open").arg("-a").arg("Donut Browser").spawn();
}
#[cfg(target_os = "windows")]
{
use std::path::PathBuf;
let paths = [
dirs::data_local_dir().map(|p| p.join("Donut Browser").join("Donut Browser.exe")),
Some(PathBuf::from(
"C:\\Program Files\\Donut Browser\\Donut Browser.exe",
)),
];
for path in paths.iter().flatten() {
if path.exists() {
let _ = Command::new(path).spawn();
return;
}
}
}
#[cfg(target_os = "linux")]
{
let _ = Command::new("donutbrowser").spawn();
}
}
pub fn activate_gui() {
#[cfg(target_os = "macos")]
{
let _ = Command::new("osascript")
.args(["-e", "tell application \"Donut Browser\" to activate"])
.spawn();
}
}
pub fn set_gui_running(running: bool) {
GUI_RUNNING.store(running, Ordering::SeqCst);
}
+152
View File
@@ -0,0 +1,152 @@
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tauri::Emitter;
use tokio::sync::Mutex;
use tokio_tungstenite::{connect_async, tungstenite::Message};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WsMessage {
#[serde(rename = "type")]
pub msg_type: String,
pub event: Option<String>,
pub payload: Option<serde_json::Value>,
}
pub struct DaemonClient {
app_handle: tauri::AppHandle,
connected: Arc<AtomicBool>,
shutdown: Arc<AtomicBool>,
daemon_port: Arc<Mutex<Option<u16>>>,
}
impl DaemonClient {
pub fn new(app_handle: tauri::AppHandle) -> Self {
Self {
app_handle,
connected: Arc::new(AtomicBool::new(false)),
shutdown: Arc::new(AtomicBool::new(false)),
daemon_port: Arc::new(Mutex::new(None)),
}
}
pub fn is_connected(&self) -> bool {
self.connected.load(Ordering::SeqCst)
}
pub async fn connect(&self, port: u16) -> Result<(), String> {
*self.daemon_port.lock().await = Some(port);
let url = format!("ws://127.0.0.1:{}/ws/events", port);
log::info!("[daemon-client] Connecting to daemon at {}", url);
let (ws_stream, _) = connect_async(&url)
.await
.map_err(|e| format!("Failed to connect to daemon: {}", e))?;
self.connected.store(true, Ordering::SeqCst);
log::info!("[daemon-client] Connected to daemon");
let (mut write, mut read) = ws_stream.split();
let app_handle = self.app_handle.clone();
let connected = self.connected.clone();
let shutdown = self.shutdown.clone();
// Spawn task to handle incoming messages
tokio::spawn(async move {
while !shutdown.load(Ordering::SeqCst) {
match read.next().await {
Some(Ok(Message::Text(text))) => {
if let Ok(ws_msg) = serde_json::from_str::<WsMessage>(&text) {
match ws_msg.msg_type.as_str() {
"event" => {
if let (Some(event), Some(payload)) = (ws_msg.event, ws_msg.payload) {
// Forward event to Tauri frontend
if let Err(e) = app_handle.emit(&event, payload) {
log::error!("[daemon-client] Failed to emit event: {}", e);
}
}
}
"connected" => {
log::info!("[daemon-client] Received connection confirmation");
}
"pong" => {
log::debug!("[daemon-client] Received pong");
}
_ => {
log::debug!("[daemon-client] Unknown message type: {}", ws_msg.msg_type);
}
}
}
}
Some(Ok(Message::Ping(data))) => {
log::debug!("[daemon-client] Received ping");
if let Err(e) = write.send(Message::Pong(data)).await {
log::error!("[daemon-client] Failed to send pong: {}", e);
break;
}
}
Some(Ok(Message::Close(_))) => {
log::info!("[daemon-client] Daemon closed connection");
break;
}
Some(Err(e)) => {
log::error!("[daemon-client] WebSocket error: {}", e);
break;
}
None => {
log::info!("[daemon-client] WebSocket stream ended");
break;
}
_ => {}
}
}
connected.store(false, Ordering::SeqCst);
log::info!("[daemon-client] Disconnected from daemon");
});
Ok(())
}
pub fn disconnect(&self) {
self.shutdown.store(true, Ordering::SeqCst);
self.connected.store(false, Ordering::SeqCst);
}
}
pub async fn start_daemon_connection(app_handle: tauri::AppHandle, port: u16) -> DaemonClient {
let client = DaemonClient::new(app_handle);
if let Err(e) = client.connect(port).await {
log::error!("[daemon-client] Failed to connect: {}", e);
}
client
}
pub async fn find_and_connect_to_daemon(app_handle: tauri::AppHandle) -> Option<DaemonClient> {
// Try default port first
let default_port = 10108;
log::info!(
"[daemon-client] Looking for daemon on port {}",
default_port
);
let client = DaemonClient::new(app_handle);
match client.connect(default_port).await {
Ok(()) => Some(client),
Err(e) => {
log::warn!(
"[daemon-client] Could not connect to daemon on default port: {}",
e
);
None
}
}
}
+134
View File
@@ -0,0 +1,134 @@
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
State,
},
response::IntoResponse,
};
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::events::{DaemonEmitter, DaemonEvent};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WsMessage {
#[serde(rename = "type")]
pub msg_type: String,
pub event: Option<String>,
pub payload: Option<serde_json::Value>,
}
#[derive(Clone)]
pub struct WsState {
event_emitter: Option<Arc<DaemonEmitter>>,
}
impl WsState {
pub fn new() -> Self {
Self {
event_emitter: None,
}
}
pub fn with_emitter(emitter: Arc<DaemonEmitter>) -> Self {
Self {
event_emitter: Some(emitter),
}
}
}
impl Default for WsState {
fn default() -> Self {
Self::new()
}
}
pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<WsState>) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_socket(socket, state))
}
async fn handle_socket(socket: WebSocket, state: WsState) {
let (mut sender, mut receiver) = socket.split();
// Subscribe to daemon events if emitter is available
let mut event_rx = state.event_emitter.as_ref().map(|e| e.subscribe());
log::info!("[ws] Client connected");
// Send initial ping to confirm connection
let ping_msg = WsMessage {
msg_type: "connected".to_string(),
event: None,
payload: None,
};
if let Ok(msg_str) = serde_json::to_string(&ping_msg) {
let _ = sender.send(Message::Text(msg_str.into())).await;
}
loop {
tokio::select! {
// Handle incoming messages from client
Some(msg) = receiver.next() => {
match msg {
Ok(Message::Text(text)) => {
if let Ok(ws_msg) = serde_json::from_str::<WsMessage>(&text) {
match ws_msg.msg_type.as_str() {
"ping" => {
let pong = WsMessage {
msg_type: "pong".to_string(),
event: None,
payload: None,
};
if let Ok(msg_str) = serde_json::to_string(&pong) {
let _ = sender.send(Message::Text(msg_str.into())).await;
}
}
_ => {
log::debug!("[ws] Received unknown message type: {}", ws_msg.msg_type);
}
}
}
}
Ok(Message::Ping(data)) => {
let _ = sender.send(Message::Pong(data)).await;
}
Ok(Message::Close(_)) => {
log::info!("[ws] Client disconnected");
break;
}
Err(e) => {
log::error!("[ws] Error receiving message: {}", e);
break;
}
_ => {}
}
}
// Forward daemon events to client
Some(daemon_event) = async {
if let Some(ref mut rx) = event_rx {
rx.recv().await.ok()
} else {
std::future::pending::<Option<DaemonEvent>>().await
}
} => {
let ws_msg = WsMessage {
msg_type: "event".to_string(),
event: Some(daemon_event.event_type),
payload: Some(daemon_event.payload),
};
if let Ok(msg_str) = serde_json::to_string(&ws_msg) {
if sender.send(Message::Text(msg_str.into())).await.is_err() {
log::error!("[ws] Failed to send event to client");
break;
}
}
}
else => break,
}
}
log::info!("[ws] WebSocket connection closed");
}
+6 -6
View File
@@ -3,11 +3,11 @@ use serde::{Deserialize, Serialize};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tauri::Emitter;
use crate::api_client::ApiClient;
use crate::browser::{create_browser, BrowserType};
use crate::browser_version_manager::DownloadInfo;
use crate::events;
// Global state to track currently downloading browser-version pairs
lazy_static::lazy_static! {
@@ -433,7 +433,7 @@ impl Downloader {
pub async fn download_browser<R: tauri::Runtime>(
&self,
app_handle: &tauri::AppHandle<R>,
_app_handle: &tauri::AppHandle<R>,
browser_type: BrowserType,
version: &str,
download_info: &DownloadInfo,
@@ -561,7 +561,7 @@ impl Downloader {
stage: initial_stage,
};
let _ = app_handle.emit("download-progress", &progress);
let _ = events::emit("download-progress", &progress);
// Open file in append mode (resuming) or create new
use std::fs::OpenOptions;
@@ -620,7 +620,7 @@ impl Downloader {
stage: stage_description,
};
let _ = app_handle.emit("download-progress", &progress);
let _ = events::emit("download-progress", &progress);
last_update = now;
}
}
@@ -801,7 +801,7 @@ impl Downloader {
eta_seconds: None,
stage: "verifying".to_string(),
};
let _ = app_handle.emit("download-progress", &progress);
let _ = events::emit("download-progress", &progress);
// Verify the browser was downloaded correctly
log::info!("Verifying download for browser: {browser_str}, version: {version}");
@@ -939,7 +939,7 @@ impl Downloader {
eta_seconds: Some(0.0),
stage: "completed".to_string(),
};
let _ = app_handle.emit("download-progress", &progress);
let _ = events::emit("download-progress", &progress);
// Remove browser-version pair from downloading set
{
+171
View File
@@ -0,0 +1,171 @@
use serde::Serialize;
use std::sync::Arc;
use tokio::sync::broadcast;
/// Trait for emitting events to the frontend or connected clients.
/// This abstraction allows the same code to work in both GUI (Tauri) mode
/// and daemon mode (WebSocket broadcast).
///
/// Note: This trait uses `serde_json::Value` to be dyn-compatible.
/// Use the convenience functions `emit()` and `emit_empty()` which accept
/// any Serialize type.
pub trait EventEmitter: Send + Sync {
/// Emit an event with a JSON value payload.
fn emit_value(&self, event: &str, payload: serde_json::Value) -> Result<(), String>;
}
/// Tauri-based event emitter for GUI mode.
/// Wraps an AppHandle and emits events directly to the Tauri frontend.
#[derive(Clone)]
pub struct TauriEmitter {
app_handle: tauri::AppHandle,
}
impl TauriEmitter {
pub fn new(app_handle: tauri::AppHandle) -> Self {
Self { app_handle }
}
}
impl EventEmitter for TauriEmitter {
fn emit_value(&self, event: &str, payload: serde_json::Value) -> Result<(), String> {
use tauri::Emitter;
self
.app_handle
.emit(event, payload)
.map_err(|e| e.to_string())
}
}
/// Event message sent through the daemon's broadcast channel.
#[derive(Clone, Debug)]
pub struct DaemonEvent {
pub event_type: String,
pub payload: serde_json::Value,
}
/// Daemon-based event emitter for background daemon mode.
/// Broadcasts events to all connected WebSocket clients.
#[derive(Clone)]
pub struct DaemonEmitter {
tx: broadcast::Sender<DaemonEvent>,
}
impl DaemonEmitter {
pub fn new(tx: broadcast::Sender<DaemonEvent>) -> Self {
Self { tx }
}
/// Create a new DaemonEmitter with a default channel capacity.
pub fn with_capacity(capacity: usize) -> (Self, broadcast::Receiver<DaemonEvent>) {
let (tx, rx) = broadcast::channel(capacity);
(Self { tx }, rx)
}
/// Subscribe to events from this emitter.
pub fn subscribe(&self) -> broadcast::Receiver<DaemonEvent> {
self.tx.subscribe()
}
}
impl EventEmitter for DaemonEmitter {
fn emit_value(&self, event: &str, payload: serde_json::Value) -> Result<(), String> {
let daemon_event = DaemonEvent {
event_type: event.to_string(),
payload,
};
// Ignore send errors (no receivers connected)
let _ = self.tx.send(daemon_event);
Ok(())
}
}
/// No-op emitter for testing or when events are not needed.
#[derive(Clone, Default)]
pub struct NoopEmitter;
impl EventEmitter for NoopEmitter {
fn emit_value(&self, _event: &str, _payload: serde_json::Value) -> Result<(), String> {
Ok(())
}
}
/// Global event emitter that can be set at runtime.
/// This allows managers to emit events without knowing whether they're
/// running in GUI or daemon mode.
static GLOBAL_EMITTER: std::sync::OnceLock<Arc<dyn EventEmitter>> = std::sync::OnceLock::new();
/// Set the global event emitter. This should be called once during app startup.
/// Returns an error if the emitter has already been set.
pub fn set_global_emitter(emitter: Arc<dyn EventEmitter>) -> Result<(), String> {
GLOBAL_EMITTER
.set(emitter)
.map_err(|_| "Global emitter already set".to_string())
}
/// Get the global event emitter, or a no-op emitter if none has been set.
pub fn global_emitter() -> Arc<dyn EventEmitter> {
GLOBAL_EMITTER
.get()
.cloned()
.unwrap_or_else(|| Arc::new(NoopEmitter))
}
/// Emit an event using the global emitter.
/// This is a convenience function for use in managers.
/// Accepts any type that implements Serialize.
pub fn emit<S: Serialize>(event: &str, payload: S) -> Result<(), String> {
let value = serde_json::to_value(payload).map_err(|e| e.to_string())?;
global_emitter().emit_value(event, value)
}
/// Emit an event with no payload using the global emitter.
pub fn emit_empty(event: &str) -> Result<(), String> {
global_emitter().emit_value(event, serde_json::Value::Null)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_noop_emitter() {
let emitter = NoopEmitter;
assert!(emitter
.emit_value("test-event", serde_json::json!("payload"))
.is_ok());
}
#[test]
fn test_daemon_emitter() {
let (emitter, mut rx) = DaemonEmitter::with_capacity(16);
// Emit an event
let _ = emitter.emit_value("test-event", serde_json::json!("hello"));
// Check we received it
let event = rx.try_recv().unwrap();
assert_eq!(event.event_type, "test-event");
assert_eq!(event.payload, serde_json::json!("hello"));
}
#[test]
fn test_daemon_emitter_no_receivers() {
let (tx, _) = broadcast::channel::<DaemonEvent>(16);
let emitter = DaemonEmitter::new(tx);
// Should not error even with no receivers
assert!(emitter
.emit_value("test-event", serde_json::json!("hello"))
.is_ok());
}
#[test]
fn test_emit_convenience_function() {
// Test that emit() works with various types
assert!(emit("test", "string").is_ok());
assert!(emit("test", 42).is_ok());
assert!(emit("test", serde_json::json!({"key": "value"})).is_ok());
assert!(emit_empty("test").is_ok());
}
}
+3 -3
View File
@@ -1,10 +1,10 @@
use std::fs::{self, File};
use std::io::{self, BufReader, Read};
use std::path::{Path, PathBuf};
use tauri::Emitter;
use crate::browser::BrowserType;
use crate::downloader::DownloadProgress;
use crate::events;
#[cfg(any(target_os = "macos", target_os = "windows"))]
use std::process::Command;
@@ -100,7 +100,7 @@ impl Extractor {
pub async fn extract_browser(
&self,
app_handle: &tauri::AppHandle,
_app_handle: &tauri::AppHandle,
browser_type: BrowserType,
version: &str,
archive_path: &Path,
@@ -117,7 +117,7 @@ impl Extractor {
eta_seconds: None,
stage: "extracting".to_string(),
};
let _ = app_handle.emit("download-progress", &progress);
let _ = events::emit("download-progress", &progress);
log::info!(
"Starting extraction of {} for browser {} version {}",
+5 -5
View File
@@ -1,10 +1,10 @@
use crate::browser::GithubRelease;
use crate::events;
use crate::profile::manager::ProfileManager;
use directories::BaseDirs;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tauri::Emitter;
use tokio::fs;
use tokio::io::AsyncWriteExt;
@@ -107,10 +107,10 @@ impl GeoIPDownloader {
pub async fn download_geoip_database(
&self,
app_handle: &tauri::AppHandle,
_app_handle: &tauri::AppHandle,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Emit initial progress
let _ = app_handle.emit(
let _ = events::emit(
"geoip-download-progress",
GeoIPDownloadProgress {
stage: "downloading".to_string(),
@@ -183,7 +183,7 @@ impl GeoIPDownloader {
None
};
let _ = app_handle.emit(
let _ = events::emit(
"geoip-download-progress",
GeoIPDownloadProgress {
stage: "downloading".to_string(),
@@ -202,7 +202,7 @@ impl GeoIPDownloader {
file.flush().await?;
// Emit completion
let _ = app_handle.emit(
let _ = events::emit(
"geoip-download-progress",
GeoIPDownloadProgress {
stage: "completed".to_string(),
+7 -6
View File
@@ -4,7 +4,8 @@ use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tauri::Emitter;
use crate::events;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileGroup {
@@ -108,7 +109,7 @@ impl GroupManager {
pub fn create_group(
&self,
app_handle: &tauri::AppHandle,
_app_handle: &tauri::AppHandle,
name: String,
) -> Result<ProfileGroup, Box<dyn std::error::Error>> {
let mut groups_data = self.load_groups_data()?;
@@ -129,7 +130,7 @@ impl GroupManager {
self.save_groups_data(&groups_data)?;
// Emit event for reactive UI updates
if let Err(e) = app_handle.emit("groups-changed", ()) {
if let Err(e) = events::emit_empty("groups-changed") {
log::error!("Failed to emit groups-changed event: {e}");
}
@@ -138,7 +139,7 @@ impl GroupManager {
pub fn update_group(
&self,
app_handle: &tauri::AppHandle,
_app_handle: &tauri::AppHandle,
id: String,
name: String,
) -> Result<ProfileGroup, Box<dyn std::error::Error>> {
@@ -165,7 +166,7 @@ impl GroupManager {
self.save_groups_data(&groups_data)?;
// Emit event for reactive UI updates
if let Err(e) = app_handle.emit("groups-changed", ()) {
if let Err(e) = events::emit_empty("groups-changed") {
log::error!("Failed to emit groups-changed event: {e}");
}
@@ -251,7 +252,7 @@ impl GroupManager {
}
// Emit event for reactive UI updates
if let Err(e) = app_handle.emit("groups-changed", ()) {
if let Err(e) = events::emit_empty("groups-changed") {
log::error!("Failed to emit groups-changed event: {e}");
}
+122
View File
@@ -0,0 +1,122 @@
//! IP address utilities shared across the application.
//!
//! Provides IP validation and public IP fetching functionality.
use std::net::IpAddr;
use std::str::FromStr;
/// IP utility error type.
#[derive(Debug, thiserror::Error)]
pub enum IpError {
#[error("Network error: {0}")]
Network(String),
#[error("Invalid IP address: {0}")]
InvalidIP(String),
}
/// Validate an IP address (IPv4 or IPv6).
pub fn validate_ip(ip: &str) -> bool {
IpAddr::from_str(ip).is_ok()
}
/// Check if an IP is IPv4.
pub fn is_ipv4(ip: &str) -> bool {
if let Ok(addr) = IpAddr::from_str(ip) {
addr.is_ipv4()
} else {
false
}
}
/// Check if an IP is IPv6.
pub fn is_ipv6(ip: &str) -> bool {
if let Ok(addr) = IpAddr::from_str(ip) {
addr.is_ipv6()
} else {
false
}
}
/// Fetch public IP address, optionally through a proxy.
pub async fn fetch_public_ip(proxy: Option<&str>) -> Result<String, IpError> {
let urls = [
"https://api.ipify.org",
"https://checkip.amazonaws.com",
"https://ipinfo.io/ip",
"https://icanhazip.com",
"https://ifconfig.co/ip",
"https://ipecho.net/plain",
];
let client_builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(5));
let client = if let Some(proxy_url) = proxy {
let proxy = reqwest::Proxy::all(proxy_url)
.map_err(|e| IpError::Network(format!("Invalid proxy: {}", e)))?;
client_builder
.proxy(proxy)
.build()
.map_err(|e| IpError::Network(e.to_string()))?
} else {
client_builder
.build()
.map_err(|e| IpError::Network(e.to_string()))?
};
let mut last_error = None;
for url in &urls {
match client.get(*url).send().await {
Ok(response) if response.status().is_success() => match response.text().await {
Ok(text) => {
let ip = text.trim().to_string();
if validate_ip(&ip) {
return Ok(ip);
}
}
Err(e) => {
last_error = Some(format!("Failed to read response from {}: {}", url, e));
}
},
Ok(response) => {
last_error = Some(format!("HTTP {} from {}", response.status(), url));
}
Err(e) => {
last_error = Some(format!("Request to {} failed: {}", url, e));
}
}
}
Err(IpError::Network(last_error.unwrap_or_else(|| {
"Failed to fetch public IP from any endpoint".to_string()
})))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_ip() {
assert!(validate_ip("8.8.8.8"));
assert!(validate_ip("192.168.1.1"));
assert!(validate_ip("2001:4860:4860::8888"));
assert!(!validate_ip("invalid"));
assert!(!validate_ip("256.256.256.256"));
}
#[test]
fn test_is_ipv4() {
assert!(is_ipv4("8.8.8.8"));
assert!(!is_ipv4("2001:4860:4860::8888"));
assert!(!is_ipv4("invalid"));
}
#[test]
fn test_is_ipv6() {
assert!(is_ipv6("2001:4860:4860::8888"));
assert!(!is_ipv6("8.8.8.8"));
assert!(!is_ipv6("invalid"));
}
}
+65 -10
View File
@@ -1,7 +1,7 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
use std::env;
use std::sync::Mutex;
use tauri::{Emitter, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
use tauri::{Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_log::{Target, TargetKind};
@@ -23,6 +23,7 @@ mod downloader;
mod extraction;
mod geoip_downloader;
mod group_manager;
mod ip_utils;
mod platform_browser;
mod profile;
mod profile_importer;
@@ -38,6 +39,10 @@ mod wayfern_terms;
// mod theme_detector; // removed: theme detection handled in webview via CSS prefers-color-scheme
mod commercial_license;
mod cookie_manager;
pub mod daemon;
pub mod daemon_client;
pub mod daemon_ws;
pub mod events;
mod mcp_server;
mod tag_manager;
mod version_updater;
@@ -162,8 +167,7 @@ async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), Strin
let _ = window.set_focus();
let _ = window.unminimize();
app
.emit("show-profile-selector", url.clone())
events::emit("show-profile-selector", url.clone())
.map_err(|e| format!("Failed to emit URL open event: {e}"))?;
} else {
// Window doesn't exist yet - add to pending URLs
@@ -272,7 +276,7 @@ fn has_acknowledged_trial_expiration(app_handle: tauri::AppHandle) -> Result<boo
}
#[tauri::command]
async fn start_mcp_server(app_handle: tauri::AppHandle) -> Result<(), String> {
async fn start_mcp_server(app_handle: tauri::AppHandle) -> Result<u16, String> {
mcp_server::McpServer::instance().start(app_handle).await
}
@@ -286,6 +290,50 @@ fn get_mcp_server_status() -> bool {
mcp_server::McpServer::instance().is_running()
}
#[derive(serde::Serialize)]
struct McpConfig {
port: u16,
token: String,
config_json: String,
}
#[tauri::command]
async fn get_mcp_config(app_handle: tauri::AppHandle) -> Result<Option<McpConfig>, String> {
let mcp_server = mcp_server::McpServer::instance();
if !mcp_server.is_running() {
return Ok(None);
}
let port = mcp_server
.get_port()
.ok_or("MCP server port not available")?;
let settings_manager = settings_manager::SettingsManager::instance();
let token = settings_manager
.get_mcp_token(&app_handle)
.await
.map_err(|e| format!("Failed to get MCP token: {e}"))?
.ok_or("MCP token not found")?;
let config_json = serde_json::json!({
"mcpServers": {
"donut-browser": {
"url": format!("http://127.0.0.1:{}/mcp", port),
"headers": {
"Authorization": format!("Bearer {}", token)
}
}
}
})
.to_string();
Ok(Some(McpConfig {
port,
token,
config_json,
}))
}
#[tauri::command]
async fn is_geoip_database_available() -> Result<bool, String> {
Ok(GeoIPDownloader::is_geoip_database_available())
@@ -405,6 +453,12 @@ pub fn run() {
// Set up deep link handler
let handle = app.handle().clone();
// Initialize the global event emitter for the events module
let emitter = std::sync::Arc::new(events::TauriEmitter::new(handle.clone()));
if let Err(e) = events::set_global_emitter(emitter) {
log::warn!("Failed to set global event emitter: {e}");
}
#[cfg(windows)]
{
// For Windows, register all deep links at runtime
@@ -536,7 +590,7 @@ pub fn run() {
}
});
let app_handle_update = app.handle().clone();
let _app_handle_update = app.handle().clone();
tauri::async_runtime::spawn(async move {
log::info!("Starting app update check at startup...");
let updater = app_auto_updater::AppAutoUpdater::instance();
@@ -548,7 +602,7 @@ pub fn run() {
update_info.new_version
);
// Emit update available event to the frontend
if let Err(e) = app_handle_update.emit("app-update-available", &update_info) {
if let Err(e) = events::emit("app-update-available", &update_info) {
log::error!("Failed to emit app update event: {e}");
} else {
log::debug!("App update event emitted successfully");
@@ -695,7 +749,7 @@ pub fn run() {
is_running,
};
if let Err(e) = app_handle_status.emit("profile-running-changed", &payload) {
if let Err(e) = events::emit("profile-running-changed", &payload) {
log::warn!("Failed to emit profile running changed event: {e}");
} else {
log::debug!(
@@ -735,7 +789,7 @@ pub fn run() {
Ok(port) => {
log::info!("API server started successfully on port {port}");
// Emit success toast to frontend
if let Err(e) = app_handle_api.emit(
if let Err(e) = events::emit(
"show-toast",
crate::api_server::ToastPayload {
message: "API server started successfully".to_string(),
@@ -750,7 +804,7 @@ pub fn run() {
Err(e) => {
log::error!("Failed to start API server at startup: {e}");
// Emit error toast to frontend
if let Err(toast_err) = app_handle_api.emit(
if let Err(toast_err) = events::emit(
"show-toast",
crate::api_server::ToastPayload {
message: "Failed to start API server".to_string(),
@@ -900,7 +954,8 @@ pub fn run() {
has_acknowledged_trial_expiration,
start_mcp_server,
stop_mcp_server,
get_mcp_server_status
get_mcp_server_status,
get_mcp_config
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+182 -7
View File
@@ -1,15 +1,27 @@
#![allow(dead_code)]
use axum::{
body::Body,
extract::State,
http::{header, Request, StatusCode},
middleware::{self, Next},
response::{IntoResponse, Response},
routing::post,
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicBool, Ordering};
use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, AtomicU16, Ordering};
use std::sync::Arc;
use tauri::AppHandle;
use tokio::net::TcpListener;
use tokio::sync::Mutex as AsyncMutex;
use crate::browser::ProxySettings;
use crate::group_manager::GROUP_MANAGER;
use crate::profile::{BrowserProfile, ProfileManager};
use crate::proxy_manager::PROXY_MANAGER;
use crate::settings_manager::SettingsManager;
use crate::wayfern_terms::WayfernTermsManager;
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -44,20 +56,36 @@ pub struct McpError {
message: String,
}
const DEFAULT_MCP_PORT: u16 = 51080;
struct McpServerInner {
app_handle: Option<AppHandle>,
token: Option<String>,
shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
}
#[derive(Clone)]
struct McpHttpState {
server: &'static McpServer,
token: String,
}
pub struct McpServer {
inner: Arc<AsyncMutex<McpServerInner>>,
is_running: AtomicBool,
port: AtomicU16,
}
impl McpServer {
fn new() -> Self {
Self {
inner: Arc::new(AsyncMutex::new(McpServerInner { app_handle: None })),
inner: Arc::new(AsyncMutex::new(McpServerInner {
app_handle: None,
token: None,
shutdown_tx: None,
})),
is_running: AtomicBool::new(false),
port: AtomicU16::new(0),
}
}
@@ -69,8 +97,16 @@ impl McpServer {
self.is_running.load(Ordering::SeqCst)
}
pub async fn start(&self, app_handle: AppHandle) -> Result<(), String> {
// Check terms acceptance first
pub fn get_port(&self) -> Option<u16> {
let port = self.port.load(Ordering::SeqCst);
if port > 0 {
Some(port)
} else {
None
}
}
pub async fn start(&self, app_handle: AppHandle) -> Result<u16, String> {
if !WayfernTermsManager::instance().is_terms_accepted() {
return Err(
"Wayfern Terms and Conditions must be accepted before starting MCP server".to_string(),
@@ -81,12 +117,143 @@ impl McpServer {
return Err("MCP server is already running".to_string());
}
let settings_manager = SettingsManager::instance();
let settings = settings_manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
// Get or generate token
let existing_token = settings_manager
.get_mcp_token(&app_handle)
.await
.ok()
.flatten();
let token = if let Some(t) = existing_token {
t
} else {
settings_manager
.generate_mcp_token(&app_handle)
.await
.map_err(|e| format!("Failed to generate MCP token: {e}"))?
};
// Determine port (use saved port, or try default, or random)
let preferred_port = settings.mcp_port.unwrap_or(DEFAULT_MCP_PORT);
let actual_port = self.bind_to_available_port(preferred_port).await?;
// Save port if it changed
if settings.mcp_port != Some(actual_port) {
let mut new_settings = settings;
new_settings.mcp_port = Some(actual_port);
settings_manager
.save_settings(&new_settings)
.map_err(|e| format!("Failed to save settings: {e}"))?;
}
// Store state
let mut inner = self.inner.lock().await;
inner.app_handle = Some(app_handle);
inner.token = Some(token.clone());
// Create shutdown channel
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
inner.shutdown_tx = Some(shutdown_tx);
self.port.store(actual_port, Ordering::SeqCst);
self.is_running.store(true, Ordering::SeqCst);
log::info!("MCP server started");
Ok(())
// Start HTTP server in background
let http_state = McpHttpState {
server: McpServer::instance(),
token,
};
tokio::spawn(Self::run_http_server(actual_port, http_state, shutdown_rx));
log::info!("[mcp] Server started on port {}", actual_port);
Ok(actual_port)
}
async fn bind_to_available_port(&self, preferred: u16) -> Result<u16, String> {
let addr = SocketAddr::from(([127, 0, 0, 1], preferred));
if TcpListener::bind(addr).await.is_ok() {
return Ok(preferred);
}
// Try random ports in 51000-51999 range
for _ in 0..10 {
let port = 51000 + (rand::random::<u16>() % 1000);
let addr = SocketAddr::from(([127, 0, 0, 1], port));
if TcpListener::bind(addr).await.is_ok() {
return Ok(port);
}
}
Err("Could not find available port for MCP server".to_string())
}
async fn run_http_server(
port: u16,
state: McpHttpState,
shutdown_rx: tokio::sync::oneshot::Receiver<()>,
) {
let app = Router::new()
.route("/mcp", post(Self::handle_mcp_post))
.layer(middleware::from_fn_with_state(
state.clone(),
Self::auth_middleware,
))
.with_state(state);
let addr = SocketAddr::from(([127, 0, 0, 1], port));
let listener = match TcpListener::bind(addr).await {
Ok(l) => l,
Err(e) => {
log::error!("[mcp] Failed to bind to port {}: {}", port, e);
return;
}
};
log::info!(
"[mcp] HTTP server listening on http://127.0.0.1:{}/mcp",
port
);
let server = axum::serve(listener, app).with_graceful_shutdown(async {
let _ = shutdown_rx.await;
log::info!("[mcp] HTTP server shutting down");
});
if let Err(e) = server.await {
log::error!("[mcp] HTTP server error: {}", e);
}
}
async fn auth_middleware(
State(state): State<McpHttpState>,
req: Request<Body>,
next: Next,
) -> Result<Response, StatusCode> {
let auth_header = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|h| h.to_str().ok());
let token = auth_header.and_then(|h| h.strip_prefix("Bearer "));
if token != Some(&state.token) {
return Err(StatusCode::UNAUTHORIZED);
}
Ok(next.run(req).await)
}
async fn handle_mcp_post(
State(state): State<McpHttpState>,
Json(request): Json<McpRequest>,
) -> impl IntoResponse {
let response = state.server.handle_request(request).await;
Json(response)
}
pub async fn stop(&self) -> Result<(), String> {
@@ -96,9 +263,17 @@ impl McpServer {
let mut inner = self.inner.lock().await;
inner.app_handle = None;
inner.token = None;
// Send shutdown signal
if let Some(tx) = inner.shutdown_tx.take() {
let _ = tx.send(());
}
self.port.store(0, Ordering::SeqCst);
self.is_running.store(false, Ordering::SeqCst);
log::info!("MCP server stopped");
log::info!("[mcp] Server stopped");
Ok(())
}
+25 -25
View File
@@ -2,6 +2,7 @@ use crate::api_client::is_browser_version_nightly;
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::BrowserProfile;
use crate::proxy_manager::PROXY_MANAGER;
use crate::wayfern_manager::WayfernConfig;
@@ -9,7 +10,6 @@ use directories::BaseDirs;
use std::fs::{self, create_dir_all};
use std::path::{Path, PathBuf};
use sysinfo::{Pid, System};
use tauri::Emitter;
pub struct ProfileManager {
base_dirs: BaseDirs,
@@ -357,7 +357,7 @@ impl ProfileManager {
}
// Emit profile creation event
if let Err(e) = app_handle.emit("profiles-changed", ()) {
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
@@ -410,7 +410,7 @@ impl ProfileManager {
pub fn rename_profile(
&self,
app_handle: &tauri::AppHandle,
_app_handle: &tauri::AppHandle,
profile_id: &str,
new_name: &str,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
@@ -443,7 +443,7 @@ impl ProfileManager {
});
// Emit profile rename event
if let Err(e) = app_handle.emit("profiles-changed", ()) {
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
@@ -532,7 +532,7 @@ impl ProfileManager {
}
// Emit profile deletion event
if let Err(e) = app_handle.emit("profiles-changed", ()) {
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
@@ -541,7 +541,7 @@ impl ProfileManager {
pub fn update_profile_version(
&self,
app_handle: &tauri::AppHandle,
_app_handle: &tauri::AppHandle,
profile_id: &str,
version: &str,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
@@ -585,7 +585,7 @@ impl ProfileManager {
self.save_profile(&profile)?;
// Emit profile update event
if let Err(e) = app_handle.emit("profiles-changed", ()) {
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
@@ -641,7 +641,7 @@ impl ProfileManager {
});
// Emit profile group assignment event
if let Err(e) = app_handle.emit("profiles-changed", ()) {
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
@@ -650,7 +650,7 @@ impl ProfileManager {
pub fn update_profile_tags(
&self,
app_handle: &tauri::AppHandle,
_app_handle: &tauri::AppHandle,
profile_id: &str,
tags: Vec<String>,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
@@ -681,7 +681,7 @@ impl ProfileManager {
});
// Emit profile tags update event
if let Err(e) = app_handle.emit("profiles-changed", ()) {
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
@@ -690,7 +690,7 @@ impl ProfileManager {
pub fn update_profile_note(
&self,
app_handle: &tauri::AppHandle,
_app_handle: &tauri::AppHandle,
profile_id: &str,
note: Option<String>,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
@@ -710,7 +710,7 @@ impl ProfileManager {
self.save_profile(&profile)?;
// Emit profile note update event
if let Err(e) = app_handle.emit("profiles-changed", ()) {
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
@@ -773,7 +773,7 @@ impl ProfileManager {
}
// Emit profile deletion event
if let Err(e) = app_handle.emit("profiles-changed", ()) {
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
@@ -833,7 +833,7 @@ impl ProfileManager {
);
// Emit profile config update event
if let Err(e) = app_handle.emit("profiles-changed", ()) {
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
@@ -893,7 +893,7 @@ impl ProfileManager {
);
// Emit profile config update event
if let Err(e) = app_handle.emit("profiles-changed", ()) {
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
@@ -981,12 +981,12 @@ impl ProfileManager {
}
// Emit profile update event so frontend UIs can refresh immediately (e.g. proxy manager)
if let Err(e) = app_handle.emit("profile-updated", &profile) {
if let Err(e) = events::emit("profile-updated", &profile) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
// Emit general profiles changed event for profile list updates
if let Err(e) = app_handle.emit("profiles-changed", ()) {
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
@@ -1154,7 +1154,7 @@ impl ProfileManager {
}
// Emit profile update event to frontend
if let Err(e) = app_handle.emit("profile-updated", &merged) {
if let Err(e) = events::emit("profile-updated", &merged) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
}
@@ -1165,7 +1165,7 @@ impl ProfileManager {
// Check Camoufox status using CamoufoxManager
async fn check_camoufox_status(
&self,
app_handle: &tauri::AppHandle,
_app_handle: &tauri::AppHandle,
profile: &BrowserProfile,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let launcher = self.camoufox_manager;
@@ -1199,7 +1199,7 @@ impl ProfileManager {
}
// Emit profile update event to frontend
if let Err(e) = app_handle.emit("profile-updated", &latest) {
if let Err(e) = events::emit("profile-updated", &latest) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
@@ -1234,7 +1234,7 @@ impl ProfileManager {
log::warn!("Warning: Failed to clear Camoufox profile process info: {e}");
}
if let Err(e) = app_handle.emit("profile-updated", &latest) {
if let Err(e) = events::emit("profile-updated", &latest) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
}
@@ -1267,7 +1267,7 @@ impl ProfileManager {
}
// Emit profile update event to frontend
if let Err(e3) = app_handle.emit("profile-updated", &latest) {
if let Err(e3) = events::emit("profile-updated", &latest) {
log::warn!("Warning: Failed to emit profile update event: {e3}");
}
}
@@ -1280,7 +1280,7 @@ impl ProfileManager {
// Check Wayfern status using WayfernManager
async fn check_wayfern_status(
&self,
app_handle: &tauri::AppHandle,
_app_handle: &tauri::AppHandle,
profile: &BrowserProfile,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let manager = self.wayfern_manager;
@@ -1314,7 +1314,7 @@ impl ProfileManager {
}
// Emit profile update event to frontend
if let Err(e) = app_handle.emit("profile-updated", &latest) {
if let Err(e) = events::emit("profile-updated", &latest) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
@@ -1349,7 +1349,7 @@ impl ProfileManager {
log::warn!("Warning: Failed to clear Wayfern profile process info: {e}");
}
if let Err(e) = app_handle.emit("profile-updated", &latest) {
if let Err(e) = events::emit("profile-updated", &latest) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
}
+16 -95
View File
@@ -6,10 +6,11 @@ use std::fs;
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::Emitter;
use tauri_plugin_shell::ShellExt;
use crate::browser::ProxySettings;
use crate::events;
use crate::ip_utils;
// Store active proxy information
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -308,7 +309,7 @@ impl ProxyManager {
// Create a new stored proxy
pub fn create_stored_proxy(
&self,
app_handle: &tauri::AppHandle,
_app_handle: &tauri::AppHandle,
name: String,
proxy_settings: ProxySettings,
) -> Result<StoredProxy, String> {
@@ -332,7 +333,7 @@ impl ProxyManager {
}
// Emit event for reactive UI updates
if let Err(e) = app_handle.emit("proxies-changed", ()) {
if let Err(e) = events::emit_empty("proxies-changed") {
log::error!("Failed to emit proxies-changed event: {e}");
}
@@ -353,7 +354,7 @@ impl ProxyManager {
// Update a stored proxy
pub fn update_stored_proxy(
&self,
app_handle: &tauri::AppHandle,
_app_handle: &tauri::AppHandle,
proxy_id: &str,
name: Option<String>,
proxy_settings: Option<ProxySettings>,
@@ -399,7 +400,7 @@ impl ProxyManager {
}
// Emit event for reactive UI updates
if let Err(e) = app_handle.emit("proxies-changed", ()) {
if let Err(e) = events::emit_empty("proxies-changed") {
log::error!("Failed to emit proxies-changed event: {e}");
}
@@ -453,7 +454,7 @@ impl ProxyManager {
}
// Emit event for reactive UI updates
if let Err(e) = app_handle.emit("proxies-changed", ()) {
if let Err(e) = events::emit_empty("proxies-changed") {
log::error!("Failed to emit proxies-changed event: {e}");
}
@@ -489,27 +490,6 @@ impl ProxyManager {
url
}
// Validate IP address (IPv4 or IPv6)
fn validate_ip(ip: &str) -> bool {
// IPv4 validation
if ip.matches('.').count() == 3 {
let parts: Vec<&str> = ip.split('.').collect();
if parts.len() == 4 {
return parts.iter().all(|part| part.parse::<u8>().is_ok());
}
}
// IPv6 validation (simplified - checks for colons and hex digits)
if ip.matches(':').count() >= 2 {
let parts: Vec<&str> = ip.split(':').collect();
return parts
.iter()
.all(|part| part.is_empty() || part.chars().all(|c| c.is_ascii_hexdigit()));
}
false
}
// Check if a proxy is valid by making HTTP requests through it
pub async fn check_proxy_validity(
&self,
@@ -518,67 +498,10 @@ impl ProxyManager {
) -> Result<ProxyCheckResult, String> {
let proxy_url = Self::build_proxy_url(proxy_settings);
// List of IP check endpoints to try
let ip_check_urls = vec![
"https://api.ipify.org",
"https://checkip.amazonaws.com",
"https://ipinfo.io/ip",
"https://icanhazip.com",
"https://ifconfig.co/ip",
];
// Create HTTP client with proxy
// reqwest::Proxy::all expects http/https URLs, but we need to handle socks proxies differently
let proxy = match proxy_settings.proxy_type.as_str() {
"socks4" | "socks5" => {
// For SOCKS proxies, reqwest doesn't support them directly via Proxy::all
// We'll need to use a different approach or return an error
return Err("SOCKS proxy validation not yet supported".to_string());
}
_ => reqwest::Proxy::all(&proxy_url).map_err(|e| format!("Failed to create proxy: {e}"))?,
};
let client = reqwest::Client::builder()
.proxy(proxy)
.timeout(std::time::Duration::from_secs(5))
.build()
.map_err(|e| format!("Failed to create HTTP client: {e}"))?;
// Try each endpoint until one succeeds
let mut last_error = None;
let mut ip: Option<String> = None;
for url_str in ip_check_urls {
match client.get(url_str).send().await {
Ok(response) => {
if response.status().is_success() {
match response.text().await {
Ok(ip_text) => {
let ip_str = ip_text.trim();
if Self::validate_ip(ip_str) {
ip = Some(ip_str.to_string());
break;
} else {
last_error = Some(format!("Invalid IP address returned: {ip_str}"));
}
}
Err(e) => {
last_error = Some(format!("Failed to read response from {url_str}: {e}"));
}
}
} else {
last_error = Some(format!("HTTP error from {url_str}: {}", response.status()));
}
}
Err(e) => {
last_error = Some(format!("Request to {url_str} failed: {e}"));
}
}
}
let ip = match ip {
Some(ip) => ip,
None => {
// Fetch public IP through the proxy using shared IP utilities
let ip = match ip_utils::fetch_public_ip(Some(&proxy_url)).await {
Ok(ip) => ip,
Err(e) => {
// Save failed check result
let failed_result = ProxyCheckResult {
ip: String::new(),
@@ -589,9 +512,7 @@ impl ProxyManager {
is_valid: false,
};
let _ = self.save_proxy_check_cache(proxy_id, &failed_result);
return Err(
last_error.unwrap_or_else(|| "Failed to get public IP from any endpoint".to_string()),
);
return Err(format!("Failed to fetch public IP: {e}"));
}
};
@@ -889,7 +810,7 @@ impl ProxyManager {
}
// Emit event for reactive UI updates
if let Err(e) = app_handle.emit("proxies-changed", ()) {
if let Err(e) = events::emit_empty("proxies-changed") {
log::error!("Failed to emit proxies-changed event: {e}");
}
@@ -947,7 +868,7 @@ impl ProxyManager {
map.remove(profile_id);
// Emit event for reactive UI updates
if let Err(e) = app_handle.emit("proxies-changed", ()) {
if let Err(e) = events::emit_empty("proxies-changed") {
log::error!("Failed to emit proxies-changed event: {e}");
}
@@ -974,7 +895,7 @@ impl ProxyManager {
// Only clean up orphaned config files where the proxy process itself is dead
pub async fn cleanup_dead_proxies(
&self,
app_handle: tauri::AppHandle,
_app_handle: tauri::AppHandle,
) -> Result<Vec<u32>, String> {
// Don't stop proxies for dead browser processes - let them run indefinitely
// The proxy processes are idle and don't consume CPU when not in use
@@ -1075,7 +996,7 @@ impl ProxyManager {
}
// Emit event for reactive UI updates
if let Err(e) = app_handle.emit("proxies-changed", ()) {
if let Err(e) = events::emit_empty("proxies-changed") {
log::error!("Failed to emit proxies-changed event: {e}");
}
+196 -2
View File
@@ -46,6 +46,10 @@ pub struct AppSettings {
pub commercial_trial_acknowledged: bool, // Has user dismissed the trial expiration modal
#[serde(default)]
pub mcp_enabled: bool, // Enable MCP (Model Context Protocol) server
#[serde(default)]
pub mcp_port: Option<u16>, // Port for MCP server (default 51080)
#[serde(default)]
pub mcp_token: Option<String>, // Displayed token for user to copy (not persisted, loaded from encrypted file)
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
@@ -75,6 +79,8 @@ impl Default for AppSettings {
first_launch_timestamp: None,
commercial_trial_acknowledged: false,
mcp_enabled: false,
mcp_port: None,
mcp_token: None,
}
}
}
@@ -400,6 +406,162 @@ impl SettingsManager {
Ok(())
}
pub async fn generate_mcp_token(
&self,
app_handle: &tauri::AppHandle,
) -> Result<String, Box<dyn std::error::Error>> {
let token_bytes: [u8; 32] = {
use rand::RngCore;
let mut rng = rand::rng();
let mut bytes = [0u8; 32];
rng.fill_bytes(&mut bytes);
bytes
};
use base64::{engine::general_purpose, Engine as _};
let token = general_purpose::URL_SAFE_NO_PAD.encode(token_bytes);
self.store_mcp_token(app_handle, &token).await?;
Ok(token)
}
pub async fn store_mcp_token(
&self,
_app_handle: &tauri::AppHandle,
token: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let token_file = self.get_settings_dir().join("mcp_token.dat");
if let Some(parent) = token_file.parent() {
std::fs::create_dir_all(parent)?;
}
let vault_password = Self::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, token.as_bytes())
.map_err(|e| format!("Encryption failed: {e}"))?;
let mut file_data = Vec::new();
file_data.extend_from_slice(b"DBMCP"); // 5-byte header for MCP token
file_data.push(2u8); // Version 2 (Argon2 + AES-GCM)
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(token_file, file_data)?;
Ok(())
}
pub async fn get_mcp_token(
&self,
_app_handle: &tauri::AppHandle,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
let token_file = self.get_settings_dir().join("mcp_token.dat");
if !token_file.exists() {
return Ok(None);
}
let file_data = std::fs::read(token_file)?;
if file_data.len() < 6 || &file_data[0..5] != b"DBMCP" {
return Ok(None);
}
let version = file_data[5];
if version != 2 {
return Ok(None);
}
let mut offset = 6;
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_bytes = &file_data[offset..offset + salt_len];
let salt_str = std::str::from_utf8(salt_bytes).map_err(|_| "Invalid salt encoding")?;
let salt = SaltString::from_b64(salt_str).map_err(|_| "Invalid salt format")?;
offset += salt_len;
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 length")?;
let nonce = 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],
file_data[offset + 1],
file_data[offset + 2],
file_data[offset + 3],
]) 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 = Self::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(|_| "Decryption failed")?;
match String::from_utf8(plaintext) {
Ok(token) => Ok(Some(token)),
Err(_) => Ok(None),
}
}
pub async fn remove_mcp_token(
&self,
_app_handle: &tauri::AppHandle,
) -> Result<(), Box<dyn std::error::Error>> {
let token_file = self.get_settings_dir().join("mcp_token.dat");
if token_file.exists() {
std::fs::remove_file(token_file)?;
}
Ok(())
}
pub async fn store_sync_token(
&self,
_app_handle: &tauri::AppHandle,
@@ -564,12 +726,17 @@ pub async fn get_app_settings(app_handle: tauri::AppHandle) -> Result<AppSetting
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
// Always load token for display purposes if it exists
// Always load tokens for display purposes if they exist
settings.api_token = manager
.get_api_token(&app_handle)
.await
.map_err(|e| format!("Failed to load API token: {e}"))?;
settings.mcp_token = manager
.get_mcp_token(&app_handle)
.await
.map_err(|e| format!("Failed to load MCP token: {e}"))?;
Ok(settings)
}
@@ -580,6 +747,7 @@ pub async fn save_app_settings(
) -> Result<AppSettings, String> {
let manager = SettingsManager::instance();
// Handle API token
if settings.api_enabled {
if let Some(ref token) = settings.api_token {
manager
@@ -595,7 +763,6 @@ pub async fn save_app_settings(
}
}
// If API is being disabled, remove the token
if !settings.api_enabled {
manager
.remove_api_token(&app_handle)
@@ -604,8 +771,33 @@ pub async fn save_app_settings(
settings.api_token = None;
}
// Handle MCP token
if settings.mcp_enabled {
if let Some(ref token) = settings.mcp_token {
manager
.store_mcp_token(&app_handle, token)
.await
.map_err(|e| format!("Failed to store MCP token: {e}"))?;
} else {
let token = manager
.generate_mcp_token(&app_handle)
.await
.map_err(|e| format!("Failed to generate MCP token: {e}"))?;
settings.mcp_token = Some(token);
}
}
if !settings.mcp_enabled {
manager
.remove_mcp_token(&app_handle)
.await
.map_err(|e| format!("Failed to remove MCP token: {e}"))?;
settings.mcp_token = None;
}
let mut persist_settings = settings.clone();
persist_settings.api_token = None;
persist_settings.mcp_token = None;
manager
.save_settings(&persist_settings)
.map_err(|e| format!("Failed to save settings: {e}"))?;
@@ -765,6 +957,8 @@ mod tests {
first_launch_timestamp: None,
commercial_trial_acknowledged: false,
mcp_enabled: false,
mcp_port: None,
mcp_token: None,
};
// Save settings
+34 -34
View File
@@ -1,6 +1,7 @@
use super::client::SyncClient;
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::ProfileManager;
use crate::settings_manager::SettingsManager;
@@ -9,7 +10,6 @@ use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::sync::Arc;
use tauri::Emitter;
use tokio::sync::Semaphore;
pub struct SyncEngine {
@@ -58,7 +58,7 @@ impl SyncEngine {
profile_id
);
let _ = app_handle.emit(
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
@@ -93,7 +93,7 @@ impl SyncEngine {
if diff.is_empty() {
log::info!("Profile {} is already in sync", profile_id);
let _ = app_handle.emit(
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
@@ -170,9 +170,9 @@ impl SyncEngine {
.as_secs(),
);
let _ = profile_manager.save_profile(&updated_profile);
let _ = app_handle.emit("profiles-changed", ());
let _ = events::emit("profiles-changed", ());
let _ = app_handle.emit(
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
@@ -245,7 +245,7 @@ impl SyncEngine {
async fn upload_profile_files(
&self,
app_handle: &tauri::AppHandle,
_app_handle: &tauri::AppHandle,
profile_id: &str,
profile_dir: &Path,
files: &[super::manifest::ManifestFileEntry],
@@ -326,7 +326,7 @@ impl SyncEngine {
let _ = handle.await;
}
let _ = app_handle.emit(
let _ = events::emit(
"profile-sync-progress",
serde_json::json!({
"profile_id": profile_id,
@@ -341,7 +341,7 @@ impl SyncEngine {
async fn download_profile_files(
&self,
app_handle: &tauri::AppHandle,
_app_handle: &tauri::AppHandle,
profile_id: &str,
profile_dir: &Path,
files: &[super::manifest::ManifestFileEntry],
@@ -416,7 +416,7 @@ impl SyncEngine {
let _ = handle.await;
}
let _ = app_handle.emit(
let _ = events::emit(
"profile-sync-progress",
serde_json::json!({
"profile_id": profile_id,
@@ -554,9 +554,9 @@ impl SyncEngine {
})?;
// Emit event for UI update
if let Some(handle) = app_handle {
let _ = handle.emit("stored-proxies-changed", ());
let _ = handle.emit(
if let Some(_handle) = app_handle {
let _ = events::emit("stored-proxies-changed", ());
let _ = events::emit(
"proxy-sync-status",
serde_json::json!({
"id": proxy_id,
@@ -682,9 +682,9 @@ impl SyncEngine {
}
// Emit event for UI update
if let Some(handle) = app_handle {
let _ = handle.emit("groups-changed", ());
let _ = handle.emit(
if let Some(_handle) = app_handle {
let _ = events::emit("groups-changed", ());
let _ = events::emit(
"group-sync-status",
serde_json::json!({
"id": group_id,
@@ -856,8 +856,8 @@ impl SyncEngine {
.save_profile(&profile)
.map_err(|e| SyncError::IoError(format!("Failed to save downloaded profile: {e}")))?;
let _ = app_handle.emit("profiles-changed", ());
let _ = app_handle.emit(
let _ = events::emit("profiles-changed", ());
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
@@ -959,7 +959,7 @@ pub fn is_group_used_by_synced_profile(group_id: &str) -> bool {
/// Enable sync for proxy if not already enabled
pub async fn enable_proxy_sync_if_needed(
proxy_id: &str,
app_handle: &tauri::AppHandle,
_app_handle: &tauri::AppHandle,
) -> Result<(), String> {
let proxy_manager = &crate::proxy_manager::PROXY_MANAGER;
let proxies = proxy_manager.get_stored_proxies();
@@ -978,7 +978,7 @@ pub async fn enable_proxy_sync_if_needed(
std::fs::write(&proxy_file, &json)
.map_err(|e| format!("Failed to update proxy file {}: {e}", proxy_file.display()))?;
let _ = app_handle.emit("stored-proxies-changed", ());
let _ = events::emit("stored-proxies-changed", ());
log::info!("Auto-enabled sync for proxy {}", proxy_id);
}
@@ -988,7 +988,7 @@ pub async fn enable_proxy_sync_if_needed(
/// Enable sync for group if not already enabled
pub async fn enable_group_sync_if_needed(
group_id: &str,
app_handle: &tauri::AppHandle,
_app_handle: &tauri::AppHandle,
) -> Result<(), String> {
let group = {
let group_manager = crate::group_manager::GROUP_MANAGER.lock().unwrap();
@@ -1011,7 +1011,7 @@ pub async fn enable_group_sync_if_needed(
}
}
let _ = app_handle.emit("groups-changed", ());
let _ = events::emit("groups-changed", ());
log::info!("Auto-enabled sync for group {}", group_id);
}
@@ -1044,7 +1044,7 @@ pub async fn set_profile_sync_enabled(
.map_err(|e| format!("Failed to load settings: {e}"))?;
if settings.sync_server_url.is_none() {
let _ = app_handle.emit(
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
@@ -1057,7 +1057,7 @@ pub async fn set_profile_sync_enabled(
let token = manager.get_sync_token(&app_handle).await.ok().flatten();
if token.is_none() {
let _ = app_handle.emit(
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
@@ -1079,13 +1079,13 @@ pub async fn set_profile_sync_enabled(
.save_profile(&profile)
.map_err(|e| format!("Failed to save profile: {e}"))?;
let _ = app_handle.emit("profiles-changed", ());
let _ = events::emit("profiles-changed", ());
if enabled {
// Check if profile is running to determine status
let is_running = profile.process_id.is_some();
let _ = app_handle.emit(
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
@@ -1118,7 +1118,7 @@ pub async fn set_profile_sync_enabled(
log::warn!("Scheduler not initialized, sync will not start");
}
} else {
let _ = app_handle.emit(
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
@@ -1132,7 +1132,7 @@ pub async fn set_profile_sync_enabled(
#[tauri::command]
pub async fn request_profile_sync(
app_handle: tauri::AppHandle,
_app_handle: tauri::AppHandle,
profile_id: String,
) -> Result<(), String> {
// Validate profile exists and sync is enabled
@@ -1155,7 +1155,7 @@ pub async fn request_profile_sync(
// Queue sync via scheduler
if let Some(scheduler) = super::get_global_scheduler() {
let is_running = profile.process_id.is_some();
let _ = app_handle.emit(
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
@@ -1251,10 +1251,10 @@ pub async fn set_proxy_sync_enabled(
std::fs::write(&proxy_file, &json)
.map_err(|e| format!("Failed to update proxy file {}: {e}", proxy_file.display()))?;
let _ = app_handle.emit("stored-proxies-changed", ());
let _ = events::emit("stored-proxies-changed", ());
if enabled {
let _ = app_handle.emit(
let _ = events::emit(
"proxy-sync-status",
serde_json::json!({
"id": proxy_id,
@@ -1266,7 +1266,7 @@ pub async fn set_proxy_sync_enabled(
scheduler.queue_proxy_sync(proxy_id).await;
}
} else {
let _ = app_handle.emit(
let _ = events::emit(
"proxy-sync-status",
serde_json::json!({
"id": proxy_id,
@@ -1330,10 +1330,10 @@ pub async fn set_group_sync_enabled(
}
}
let _ = app_handle.emit("groups-changed", ());
let _ = events::emit("groups-changed", ());
if enabled {
let _ = app_handle.emit(
let _ = events::emit(
"group-sync-status",
serde_json::json!({
"id": group_id,
@@ -1345,7 +1345,7 @@ pub async fn set_group_sync_enabled(
scheduler.queue_group_sync(group_id).await;
}
} else {
let _ = app_handle.emit(
let _ = events::emit(
"group-sync-status",
serde_json::json!({
"id": group_id,
+12 -12
View File
@@ -1,12 +1,12 @@
use super::engine::SyncEngine;
use super::subscription::SyncWorkItem;
use crate::events;
use crate::profile::ProfileManager;
use once_cell::sync::OnceCell;
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tauri::Emitter;
use tokio::sync::mpsc;
use tokio::sync::Mutex;
use tokio::time::sleep;
@@ -204,7 +204,7 @@ impl SyncScheduler {
}
}
pub async fn sync_all_enabled_profiles(&self, app_handle: &tauri::AppHandle) {
pub async fn sync_all_enabled_profiles(&self, _app_handle: &tauri::AppHandle) {
log::info!("Starting initial sync for all enabled profiles...");
let profiles = {
@@ -235,7 +235,7 @@ impl SyncScheduler {
let is_running = profile.process_id.is_some();
// Emit initial status
let _ = app_handle.emit(
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
@@ -324,7 +324,7 @@ impl SyncScheduler {
}
log::info!("Executing queued sync for profile {}", profile_id);
let _ = app_handle.emit(
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
@@ -370,7 +370,7 @@ impl SyncScheduler {
match result {
Ok(()) => {
log::info!("Profile {} synced successfully", profile_id);
let _ = app_handle.emit(
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
@@ -380,7 +380,7 @@ impl SyncScheduler {
}
Err(e) => {
log::error!("Failed to sync profile {}: {}", profile_id, e);
let _ = app_handle.emit(
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
"profile_id": profile_id,
@@ -419,7 +419,7 @@ impl SyncScheduler {
Ok(engine) => {
for proxy_id in proxies_to_sync {
log::info!("Syncing proxy {}", proxy_id);
let _ = app_handle.emit(
let _ = events::emit(
"proxy-sync-status",
serde_json::json!({
"id": proxy_id,
@@ -431,7 +431,7 @@ impl SyncScheduler {
.await
{
Ok(()) => {
let _ = app_handle.emit(
let _ = events::emit(
"proxy-sync-status",
serde_json::json!({
"id": proxy_id,
@@ -441,7 +441,7 @@ impl SyncScheduler {
}
Err(e) => {
log::error!("Failed to sync proxy {}: {}", proxy_id, e);
let _ = app_handle.emit(
let _ = events::emit(
"proxy-sync-status",
serde_json::json!({
"id": proxy_id,
@@ -485,7 +485,7 @@ impl SyncScheduler {
Ok(engine) => {
for group_id in groups_to_sync {
log::info!("Syncing group {}", group_id);
let _ = app_handle.emit(
let _ = events::emit(
"group-sync-status",
serde_json::json!({
"id": group_id,
@@ -497,7 +497,7 @@ impl SyncScheduler {
.await
{
Ok(()) => {
let _ = app_handle.emit(
let _ = events::emit(
"group-sync-status",
serde_json::json!({
"id": group_id,
@@ -507,7 +507,7 @@ impl SyncScheduler {
}
Err(e) => {
log::error!("Failed to sync group {}: {}", group_id, e);
let _ = app_handle.emit(
let _ = events::emit(
"group-sync-status",
serde_json::json!({
"id": group_id,
+3 -3
View File
@@ -1,10 +1,10 @@
use crate::events;
use crate::settings_manager::SettingsManager;
use reqwest::Client;
use serde::Deserialize;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tauri::Emitter;
use tokio::sync::mpsc;
use tokio::time::sleep;
@@ -122,7 +122,7 @@ impl SyncSubscription {
token: &str,
work_tx: &mpsc::UnboundedSender<SyncWorkItem>,
running: &Arc<AtomicBool>,
app_handle: &tauri::AppHandle,
_app_handle: &tauri::AppHandle,
) -> Result<(), String> {
let url = format!("{base_url}/v1/objects/subscribe");
@@ -142,7 +142,7 @@ impl SyncSubscription {
}
log::info!("Connected to sync subscription at {url}");
let _ = app_handle.emit("sync-subscription-status", "connected");
let _ = events::emit("sync-subscription-status", "connected");
let mut buffer = String::new();
let mut bytes_stream = response.bytes_stream();
+7 -7
View File
@@ -5,12 +5,12 @@ use std::path::PathBuf;
use std::sync::Arc;
use std::sync::OnceLock;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tauri::Emitter;
use tokio::sync::Mutex;
use tokio::time::interval;
use crate::auto_updater::AutoUpdater;
use crate::browser_version_manager::BrowserVersionManager;
use crate::events;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct VersionUpdateProgress {
@@ -244,7 +244,7 @@ impl VersionUpdater {
// Try to emit error event if we have an app handle
let updater_guard = updater.lock().await;
if let Some(ref app_handle) = updater_guard.app_handle {
if let Some(ref _app_handle) = updater_guard.app_handle {
let progress = VersionUpdateProgress {
current_browser: "".to_string(),
total_browsers: 0,
@@ -253,7 +253,7 @@ impl VersionUpdater {
browser_new_versions: 0,
status: "error".to_string(),
};
let _ = app_handle.emit("version-update-progress", &progress);
let _ = events::emit("version-update-progress", &progress);
}
}
}
@@ -279,7 +279,7 @@ impl VersionUpdater {
status: "updating".to_string(),
};
if let Err(e) = app_handle.emit("version-update-progress", &initial_progress) {
if let Err(e) = events::emit("version-update-progress", &initial_progress) {
log::error!("Failed to emit initial progress: {e}");
}
@@ -296,7 +296,7 @@ impl VersionUpdater {
status: "updating".to_string(),
};
if let Err(e) = app_handle.emit("version-update-progress", &progress) {
if let Err(e) = events::emit("version-update-progress", &progress) {
log::error!("Failed to emit progress for {browser}: {e}");
}
@@ -322,7 +322,7 @@ impl VersionUpdater {
status: "updating".to_string(),
};
if let Err(e) = app_handle.emit("version-update-progress", &progress) {
if let Err(e) = events::emit("version-update-progress", &progress) {
log::error!("Failed to emit progress with versions for {browser}: {e}");
}
}
@@ -348,7 +348,7 @@ impl VersionUpdater {
status: "completed".to_string(),
};
if let Err(e) = app_handle.emit("version-update-progress", &final_progress) {
if let Err(e) = events::emit("version-update-progress", &final_progress) {
eprintln!("Failed to emit completion progress: {e}");
}
+1 -1
View File
@@ -19,7 +19,7 @@
"active": true,
"targets": ["app", "dmg", "nsis", "deb", "rpm", "appimage"],
"category": "Productivity",
"externalBin": ["binaries/donut-proxy"],
"externalBin": ["binaries/donut-proxy", "binaries/donut-daemon"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
+14
View File
@@ -14,6 +14,7 @@ import { GroupBadges } from "@/components/group-badges";
import { GroupManagementDialog } from "@/components/group-management-dialog";
import HomeHeader from "@/components/home-header";
import { ImportProfileDialog } from "@/components/import-profile-dialog";
import { IntegrationsDialog } from "@/components/integrations-dialog";
import { PermissionDialog } from "@/components/permission-dialog";
import { ProfilesDataTable } from "@/components/profile-data-table";
import { ProfileSelectorDialog } from "@/components/profile-selector-dialog";
@@ -88,6 +89,7 @@ export default function Home() {
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
const [integrationsDialogOpen, setIntegrationsDialogOpen] = useState(false);
const [importProfileDialogOpen, setImportProfileDialogOpen] = useState(false);
const [proxyManagementDialogOpen, setProxyManagementDialogOpen] =
useState(false);
@@ -805,6 +807,7 @@ export default function Home() {
onProxyManagementDialogOpen={setProxyManagementDialogOpen}
onSettingsDialogOpen={setSettingsDialogOpen}
onSyncConfigDialogOpen={setSyncConfigDialogOpen}
onIntegrationsDialogOpen={setIntegrationsDialogOpen}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
/>
@@ -855,6 +858,17 @@ export default function Home() {
onClose={() => {
setSettingsDialogOpen(false);
}}
onIntegrationsOpen={() => {
setSettingsDialogOpen(false);
setIntegrationsDialogOpen(true);
}}
/>
<IntegrationsDialog
isOpen={integrationsDialogOpen}
onClose={() => {
setIntegrationsDialogOpen(false);
}}
/>
<ImportProfileDialog
+11 -1
View File
@@ -1,7 +1,7 @@
import { FaDownload } from "react-icons/fa";
import { FiWifi } from "react-icons/fi";
import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go";
import { LuCloud, LuSearch, LuUsers, LuX } from "react-icons/lu";
import { LuCloud, LuPlug, LuSearch, LuUsers, LuX } from "react-icons/lu";
import { Logo } from "./icons/logo";
import { Button } from "./ui/button";
import { CardTitle } from "./ui/card";
@@ -21,6 +21,7 @@ type Props = {
onImportProfileDialogOpen: (open: boolean) => void;
onCreateProfileDialogOpen: (open: boolean) => void;
onSyncConfigDialogOpen: (open: boolean) => void;
onIntegrationsDialogOpen: (open: boolean) => void;
searchQuery: string;
onSearchQueryChange: (query: string) => void;
};
@@ -32,6 +33,7 @@ const HomeHeader = ({
onImportProfileDialogOpen,
onCreateProfileDialogOpen,
onSyncConfigDialogOpen,
onIntegrationsDialogOpen,
searchQuery,
onSearchQueryChange,
}: Props) => {
@@ -128,6 +130,14 @@ const HomeHeader = ({
<LuCloud className="mr-2 w-4 h-4" />
Sync Service
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onIntegrationsDialogOpen(true);
}}
>
<LuPlug className="mr-2 w-4 h-4" />
Integrations
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onImportProfileDialogOpen(true);
+390
View File
@@ -0,0 +1,390 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { Eye, EyeOff } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { CopyToClipboard } from "./ui/copy-to-clipboard";
interface AppSettings {
api_enabled: boolean;
api_port: number;
api_token?: string;
mcp_enabled: boolean;
mcp_port?: number;
mcp_token?: string;
}
interface McpConfig {
port: number;
token: string;
config_json: string;
}
interface IntegrationsDialogProps {
isOpen: boolean;
onClose: () => void;
}
export function IntegrationsDialog({
isOpen,
onClose,
}: IntegrationsDialogProps) {
const [settings, setSettings] = useState<AppSettings>({
api_enabled: false,
api_port: 10108,
api_token: undefined,
mcp_enabled: false,
mcp_port: undefined,
mcp_token: undefined,
});
const [apiServerPort, setApiServerPort] = useState<number | null>(null);
const [mcpConfig, setMcpConfig] = useState<McpConfig | null>(null);
const [_mcpRunning, setMcpRunning] = useState(false);
const [showApiToken, setShowApiToken] = useState(false);
const [showMcpToken, setShowMcpToken] = useState(false);
const [isApiStarting, setIsApiStarting] = useState(false);
const [isMcpStarting, setIsMcpStarting] = useState(false);
const { termsAccepted } = useWayfernTerms();
const loadSettings = useCallback(async () => {
try {
const loaded = await invoke<AppSettings>("get_app_settings");
setSettings(loaded);
} catch (e) {
console.error("Failed to load settings:", e);
}
}, []);
const loadMcpConfig = useCallback(async () => {
try {
const config = await invoke<McpConfig | null>("get_mcp_config");
setMcpConfig(config);
} catch (e) {
console.error("Failed to get MCP config:", e);
}
}, []);
const loadMcpServerStatus = useCallback(async () => {
try {
const isRunning = await invoke<boolean>("get_mcp_server_status");
setMcpRunning(isRunning);
} catch (e) {
console.error("Failed to get MCP server status:", e);
}
}, []);
const loadApiServerStatus = useCallback(async () => {
try {
const port = await invoke<number | null>("get_api_server_status");
setApiServerPort(port);
} catch (e) {
console.error("Failed to get API server status:", e);
}
}, []);
useEffect(() => {
if (isOpen) {
loadSettings();
loadApiServerStatus();
loadMcpConfig();
loadMcpServerStatus();
}
}, [
isOpen,
loadSettings,
loadApiServerStatus,
loadMcpConfig,
loadMcpServerStatus,
]);
const handleApiToggle = async (enabled: boolean) => {
setIsApiStarting(true);
try {
if (enabled) {
const port = await invoke<number>("start_api_server", {
port: settings.api_port,
});
setApiServerPort(port);
const next = await invoke<AppSettings>("save_app_settings", {
settings: { ...settings, api_enabled: true },
});
setSettings(next);
showSuccessToast(`API server started on port ${port}`);
} else {
await invoke("stop_api_server");
setApiServerPort(null);
const next = await invoke<AppSettings>("save_app_settings", {
settings: { ...settings, api_enabled: false, api_token: null },
});
setSettings(next);
showSuccessToast("API server stopped");
}
} catch (e) {
console.error("Failed to toggle API:", e);
showErrorToast("Failed to toggle API server", {
description: e instanceof Error ? e.message : "Unknown error",
});
} finally {
setIsApiStarting(false);
}
};
const handleMcpToggle = async (enabled: boolean) => {
setIsMcpStarting(true);
try {
if (enabled) {
const port = await invoke<number>("start_mcp_server");
const next = await invoke<AppSettings>("save_app_settings", {
settings: { ...settings, mcp_enabled: true, mcp_port: port },
});
setSettings(next);
loadMcpConfig();
showSuccessToast(`MCP server started on port ${port}`);
} else {
await invoke("stop_mcp_server");
const next = await invoke<AppSettings>("save_app_settings", {
settings: { ...settings, mcp_enabled: false },
});
setSettings(next);
setMcpConfig(null);
showSuccessToast("MCP server stopped");
}
} catch (e) {
console.error("Failed to toggle MCP server:", e);
showErrorToast("Failed to toggle MCP server", {
description: e instanceof Error ? e.message : "Unknown error",
});
} finally {
setIsMcpStarting(false);
}
};
const obfuscateToken = (token: string) =>
"•".repeat(Math.min(token.length, 32));
const getFormattedMcpConfig = () => {
if (!mcpConfig) return "";
return JSON.stringify(
{
mcpServers: {
"donut-browser": {
url: `http://127.0.0.1:${mcpConfig.port}/mcp`,
headers: {
Authorization: `Bearer ${mcpConfig.token}`,
},
},
},
},
null,
2,
);
};
const getObfuscatedMcpConfig = () => {
if (!mcpConfig) return "";
return JSON.stringify(
{
mcpServers: {
"donut-browser": {
url: `http://127.0.0.1:${mcpConfig.port}/mcp`,
headers: {
Authorization: `Bearer ${obfuscateToken(mcpConfig.token)}`,
},
},
},
},
null,
2,
);
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>Integrations</DialogTitle>
</DialogHeader>
<Tabs defaultValue="api" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="api">Local API</TabsTrigger>
<TabsTrigger value="mcp">MCP (AI Assistants)</TabsTrigger>
</TabsList>
<TabsContent value="api" className="space-y-4 mt-4">
<div className="flex items-center space-x-2">
<Checkbox
id="api-enabled"
checked={apiServerPort !== null}
disabled={isApiStarting}
onCheckedChange={handleApiToggle}
/>
<div className="grid gap-1.5 leading-none">
<Label
htmlFor="api-enabled"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Enable Local API Server
</Label>
<p className="text-xs text-muted-foreground">
Allow managing profiles, groups, and proxies via REST API.
</p>
</div>
</div>
{settings.api_enabled && (
<div className="space-y-4 p-4 rounded-md border bg-muted/40">
<div className="space-y-2">
<Label className="text-sm font-medium">Port</Label>
<div className="flex items-center space-x-2">
<Input
value={apiServerPort ?? settings.api_port}
readOnly
className="w-24 font-mono"
/>
<span className="text-xs text-muted-foreground">
Server is running
</span>
</div>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">
Authentication Token
</Label>
<div className="flex items-center space-x-2">
<div className="relative flex-1">
<Input
type={showApiToken ? "text" : "password"}
value={settings.api_token ?? ""}
readOnly
className="font-mono pr-10"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={() => setShowApiToken(!showApiToken)}
>
{showApiToken ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
<CopyToClipboard
text={settings.api_token ?? ""}
successMessage="Token copied"
/>
</div>
<p className="text-xs text-muted-foreground">
Include in Authorization header: Bearer {"<token>"}
</p>
</div>
</div>
)}
</TabsContent>
<TabsContent value="mcp" className="space-y-4 mt-4">
<div className="flex items-center space-x-2">
<Checkbox
id="mcp-enabled"
checked={settings.mcp_enabled && mcpConfig !== null}
disabled={!termsAccepted || isMcpStarting}
onCheckedChange={handleMcpToggle}
/>
<div className="grid gap-1.5 leading-none">
<Label
htmlFor="mcp-enabled"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Enable MCP Server (Model Context Protocol)
</Label>
<p className="text-xs text-muted-foreground">
Allow AI assistants like Claude Desktop to control browsers.
{!termsAccepted && (
<span className="ml-1 text-orange-600">
(Accept Wayfern terms in Settings first)
</span>
)}
</p>
</div>
</div>
{mcpConfig && (
<div className="space-y-4 p-4 rounded-md border bg-muted/40">
<div className="space-y-2">
<Label className="text-sm font-medium">
Claude Desktop Configuration
</Label>
<p className="text-xs text-muted-foreground">
Copy this configuration to your Claude Desktop config file
at{" "}
<code className="bg-muted px-1 rounded">
~/.config/claude/claude_desktop_config.json
</code>
</p>
</div>
<div className="relative">
<pre className="p-3 text-xs font-mono rounded-md bg-background border overflow-x-auto whitespace-pre">
{showMcpToken
? getFormattedMcpConfig()
: getObfuscatedMcpConfig()}
</pre>
<div className="absolute top-2 right-2 flex items-center space-x-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => setShowMcpToken(!showMcpToken)}
>
{showMcpToken ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
</Button>
<CopyToClipboard
text={getFormattedMcpConfig()}
successMessage="Configuration copied"
/>
</div>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Available Tools</Label>
<ul className="list-disc ml-5 space-y-0.5 text-xs text-muted-foreground">
<li>list_profiles - List browser profiles</li>
<li>run_profile - Launch a browser</li>
<li>kill_profile - Stop a running browser</li>
<li>get_profile_status - Check if browser is running</li>
<li>list_groups, create_group, etc. - Manage groups</li>
<li>list_proxies, create_proxy, etc. - Manage proxies</li>
</ul>
</div>
</div>
)}
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}
+9 -60
View File
@@ -68,6 +68,7 @@ import {
getBrowserIcon,
getCurrentOS,
} from "@/lib/browser-utils";
import { formatRelativeTime } from "@/lib/flag-utils";
import { trimName } from "@/lib/name-utils";
import { cn } from "@/lib/utils";
import type {
@@ -1911,59 +1912,26 @@ export function ProfilesDataTable({
id: "sync",
header: "",
size: 24,
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
cell: ({ row }) => {
const profile = row.original;
if (!profile.sync_enabled) {
if (!profile.sync_enabled && profile.last_sync) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex justify-center items-center w-3 h-3">
<span className="w-2 h-2 rounded-full bg-muted-foreground/30" />
<span className="w-2 h-2 rounded-full bg-orange-500" />
</span>
</TooltipTrigger>
<TooltipContent>Sync disabled</TooltipContent>
<TooltipContent>
Sync is disabled, last sync{" "}
{formatRelativeTime(profile.last_sync)}
</TooltipContent>
</Tooltip>
);
}
const syncStatus = meta.syncStatuses[profile.id];
const isSyncing = syncStatus === "syncing";
const isWaiting = syncStatus === "waiting";
const isSynced =
syncStatus === "synced" || (!syncStatus && profile.last_sync);
const isError = syncStatus === "error";
let dotClass = "bg-yellow-500";
let tooltipText = "Sync pending";
if (isSyncing) {
dotClass = "bg-yellow-500 animate-pulse";
tooltipText = "Syncing...";
} else if (isWaiting) {
dotClass = "bg-yellow-500";
tooltipText = "Waiting for profile to stop";
} else if (isError) {
dotClass = "bg-red-500";
tooltipText = "Sync error";
} else if (isSynced) {
dotClass = "bg-green-500";
tooltipText = profile.last_sync
? `Last synced: ${new Date(profile.last_sync * 1000).toLocaleString()}`
: "Synced";
}
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex justify-center items-center w-3 h-3">
<span className={`w-2 h-2 rounded-full ${dotClass}`} />
</span>
</TooltipTrigger>
<TooltipContent>{tooltipText}</TooltipContent>
</Tooltip>
);
return null;
},
},
{
@@ -2031,25 +1999,6 @@ export function ProfilesDataTable({
Copy Cookies to Profile
</DropdownMenuItem>
)}
{meta.onOpenProfileSyncDialog && (
<DropdownMenuItem
onClick={() => {
meta.onOpenProfileSyncDialog?.(profile);
}}
>
Sync Settings
</DropdownMenuItem>
)}
{meta.onToggleProfileSync && (
<DropdownMenuItem
onClick={() => {
meta.onToggleProfileSync?.(profile);
}}
disabled={isDisabled}
>
{profile.sync_enabled ? "Disable Sync" : "Enable Sync"}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => {
setProfileToDelete(profile);
+21 -418
View File
@@ -7,7 +7,6 @@ import { useCallback, useEffect, useState } from "react";
import { BsCamera, BsMic } from "react-icons/bs";
import { LoadingButton } from "@/components/loading-button";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
ColorPicker,
ColorPickerAlpha,
@@ -40,7 +39,6 @@ import {
import { useCommercialTrial } from "@/hooks/use-commercial-trial";
import type { PermissionType } from "@/hooks/use-permissions";
import { usePermissions } from "@/hooks/use-permissions";
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
import {
getThemeByColors,
getThemeById,
@@ -48,7 +46,6 @@ import {
THEMES,
} from "@/lib/themes";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { CopyToClipboard } from "./ui/copy-to-clipboard";
import { RippleButton } from "./ui/ripple";
interface AppSettings {
@@ -76,9 +73,14 @@ interface PermissionInfo {
interface SettingsDialogProps {
isOpen: boolean;
onClose: () => void;
onIntegrationsOpen?: () => void;
}
export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
export function SettingsDialog({
isOpen,
onClose,
onIntegrationsOpen,
}: SettingsDialogProps) {
const [settings, setSettings] = useState<AppSettings>({
set_as_default_browser: false,
theme: "system",
@@ -109,7 +111,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
const [requestingPermission, setRequestingPermission] =
useState<PermissionType | null>(null);
const [isMacOS, setIsMacOS] = useState(false);
const [apiServerPort, setApiServerPort] = useState<number | null>(null);
const { setTheme } = useTheme();
const {
@@ -117,10 +118,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
isMicrophoneAccessGranted,
isCameraAccessGranted,
} = usePermissions();
const { termsAccepted } = useWayfernTerms();
const { trialStatus } = useCommercialTrial();
const [mcpEnabled, setMcpEnabled] = useState(false);
const [isMcpStarting, setIsMcpStarting] = useState(false);
const getPermissionIcon = useCallback((type: PermissionType) => {
switch (type) {
@@ -352,48 +350,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
} catch {}
}
// Handle API server start/stop based on settings
const wasApiEnabled = originalSettings.api_enabled;
const isApiEnabled = settingsToSave.api_enabled;
if (isApiEnabled && !wasApiEnabled) {
// Start API server
try {
const port = await invoke<number>("start_api_server", {
port: settingsToSave.api_port,
});
setApiServerPort(port);
showSuccessToast(`Local API started on port ${port}`);
} catch (error) {
console.error("Failed to start API server:", error);
showErrorToast("Failed to start API server", {
description:
error instanceof Error ? error.message : "Unknown error occurred",
});
// Revert the API enabled setting if start failed
settingsToSave.api_enabled = false;
const revertedSettings = await invoke<AppSettings>(
"save_app_settings",
{ settings: settingsToSave },
);
setSettings(revertedSettings);
settingsToSave = revertedSettings;
}
} else if (!isApiEnabled && wasApiEnabled) {
// Stop API server
try {
await invoke("stop_api_server");
setApiServerPort(null);
showSuccessToast("Local API stopped");
} catch (error) {
console.error("Failed to stop API server:", error);
showErrorToast("Failed to stop API server", {
description:
error instanceof Error ? error.message : "Unknown error occurred",
});
}
}
setOriginalSettings(settingsToSave);
onClose();
} catch (error) {
@@ -401,7 +357,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
} finally {
setIsSaving(false);
}
}, [onClose, setTheme, settings, customThemeState, originalSettings]);
}, [onClose, setTheme, settings, customThemeState]);
const updateSetting = useCallback(
(
@@ -413,26 +369,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
[],
);
const loadApiServerStatus = useCallback(async () => {
try {
const port = await invoke<number | null>("get_api_server_status");
setApiServerPort(port);
} catch (error) {
console.error("Failed to load API server status:", error);
setApiServerPort(null);
}
}, []);
const loadMcpServerStatus = useCallback(async () => {
try {
const isRunning = await invoke<boolean>("get_mcp_server_status");
setMcpEnabled(isRunning);
} catch (error) {
console.error("Failed to load MCP server status:", error);
setMcpEnabled(false);
}
}, []);
const handleClose = useCallback(() => {
// Restore original theme when closing without saving
if (originalSettings.theme === "custom" && originalSettings.custom_theme) {
@@ -470,8 +406,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
if (isOpen) {
loadSettings().catch(console.error);
checkDefaultBrowserStatus().catch(console.error);
loadApiServerStatus().catch(console.error);
loadMcpServerStatus().catch(console.error);
// Check if we're on macOS
const userAgent = navigator.userAgent;
@@ -492,14 +426,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
clearInterval(intervalId);
};
}
}, [
isOpen,
loadPermissions,
checkDefaultBrowserStatus,
loadSettings,
loadApiServerStatus,
loadMcpServerStatus,
]);
}, [isOpen, loadPermissions, checkDefaultBrowserStatus, loadSettings]);
// Update permissions when the permission states change
useEffect(() => {
@@ -790,279 +717,20 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
</div>
)}
{/* Local API Section */}
{/* Integrations Section */}
<div className="space-y-4">
<Label className="text-base font-medium">Local API</Label>
<div className="flex items-center space-x-2">
<Checkbox
id="api-enabled"
checked={settings.api_enabled}
onCheckedChange={async (checked: boolean) => {
updateSetting("api_enabled", checked);
try {
if (checked) {
// Ask backend to enable API and return settings with token
const next = await invoke<AppSettings>(
"save_app_settings",
{
settings: { ...settings, api_enabled: true },
},
);
setSettings(next);
} else {
const next = await invoke<AppSettings>(
"save_app_settings",
{
settings: {
...settings,
api_enabled: false,
api_token: null,
},
},
);
setSettings(next);
}
} catch (e) {
console.error("Failed to toggle API:", e);
}
}}
/>
<div className="grid gap-1.5 leading-none">
<Label
htmlFor="api-enabled"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
(ALPHA) Enable Local API Server
</Label>
<p className="text-xs text-muted-foreground">
Allow managing the application data externally via REST API.
Server will start on port 10108 or a random port if
unavailable.
{apiServerPort && (
<span className="ml-1 font-medium text-green-600">
(Currently running on port {apiServerPort})
</span>
)}
</p>
</div>
</div>
{settings.api_enabled && settings.api_token && (
<div className="space-y-2">
<Label className="text-sm font-medium">
API Authentication Token
</Label>
<div className="flex items-center space-x-2">
<input
type="text"
value={settings.api_token}
readOnly
className="flex-1 px-3 py-2 font-mono text-sm rounded-md border bg-muted"
/>
<CopyToClipboard
text={settings.api_token || ""}
successMessage="API token copied to clipboard"
/>
</div>
<p className="text-xs text-muted-foreground">
Include this token in the Authorization header as "Bearer{" "}
{settings.api_token}" for all API requests.
</p>
{/* Temporary in-app API docs */}
<div className="p-3 mt-3 space-y-2 text-xs leading-relaxed rounded-md border bg-muted/40">
<div className="font-medium">
Temporary in-app API docs (alpha)
</div>
<div>
<div>
Base URL:{" "}
<code className="font-mono">{`http://127.0.0.1:${apiServerPort ?? settings.api_port ?? 10108}/v1`}</code>
</div>
<div>
Auth:{" "}
<code className="font-mono">
Authorization: Bearer {settings.api_token}
</code>
</div>
</div>
<div className="space-y-1">
<div className="font-medium">Profiles</div>
<ul className="list-disc ml-5 space-y-0.5">
<li>
<code className="font-mono">GET /profiles</code> list
profiles
</li>
<li>
<code className="font-mono">
GET /profiles/{"{"}id{"}"}
</code>{" "}
get one
</li>
<li>
<code className="font-mono">POST /profiles</code>
create
<span className="ml-1 text-muted-foreground">
(required: name, browser, version; optional:
release_type, proxy_id, camoufox_config, group_id,
tags)
</span>
</li>
<li>
<code className="font-mono">
PUT /profiles/{"{"}id{"}"}
</code>{" "}
update
<span className="ml-1 text-muted-foreground">
(any of: name, version, proxy_id, camoufox_config,
group_id, tags)
</span>
</li>
<li>
<code className="font-mono">
DELETE /profiles/{"{"}id{"}"}
</code>{" "}
delete
</li>
<li>
<code className="font-mono">
POST /profiles/{"{"}id{"}"}/run
</code>{" "}
launch with remote debugging
<span className="ml-1 text-muted-foreground">
(body: {"{"}url?, headless?{"}"})
</span>
</li>
<li>
<code className="font-mono">
POST /profiles/{"{"}id{"}"}/open-url
</code>{" "}
open URL in running profile
<span className="ml-1 text-muted-foreground">
(body: {"{"}url{"}"})
</span>
</li>
<li>
<code className="font-mono">
POST /profiles/{"{"}id{"}"}/kill
</code>{" "}
stop browser process
</li>
</ul>
</div>
<div className="space-y-1">
<div className="font-medium">Groups</div>
<ul className="list-disc ml-5 space-y-0.5">
<li>
<code className="font-mono">GET /groups</code> list
</li>
<li>
<code className="font-mono">
GET /groups/{"{"}id{"}"}
</code>{" "}
get one
</li>
<li>
<code className="font-mono">POST /groups</code> create
<span className="ml-1 text-muted-foreground">
(required: name)
</span>
</li>
<li>
<code className="font-mono">
PUT /groups/{"{"}id{"}"}
</code>{" "}
rename
<span className="ml-1 text-muted-foreground">
(required: name)
</span>
</li>
<li>
<code className="font-mono">
DELETE /groups/{"{"}id{"}"}
</code>{" "}
delete
</li>
</ul>
</div>
<div className="space-y-1">
<div className="font-medium">Tags</div>
<ul className="list-disc ml-5 space-y-0.5">
<li>
<code className="font-mono">GET /tags</code> list
</li>
</ul>
</div>
<div className="space-y-1">
<div className="font-medium">Proxies</div>
<ul className="list-disc ml-5 space-y-0.5">
<li>
<code className="font-mono">GET /proxies</code> list
</li>
<li>
<code className="font-mono">
GET /proxies/{"{"}id{"}"}
</code>{" "}
get one
</li>
<li>
<code className="font-mono">POST /proxies</code>
create
<span className="ml-1 text-muted-foreground">
(required: name, proxy_settings object)
</span>
</li>
<li>
<code className="font-mono">
PUT /proxies/{"{"}id{"}"}
</code>{" "}
update
<span className="ml-1 text-muted-foreground">
(optional: name, proxy_settings)
</span>
</li>
<li>
<code className="font-mono">
DELETE /proxies/{"{"}id{"}"}
</code>{" "}
delete
</li>
</ul>
</div>
<div className="space-y-1">
<div className="font-medium">Browsers</div>
<ul className="list-disc ml-5 space-y-0.5">
<li>
<code className="font-mono">
POST /browsers/download
</code>{" "}
download
<span className="ml-1 text-muted-foreground">
(required: browser, version)
</span>
</li>
<li>
<code className="font-mono">
GET /browsers/{"{"}browser{"}"}/versions
</code>{" "}
list versions
</li>
<li>
<code className="font-mono">
GET /browsers/{"{"}browser{"}"}/versions/{"{"}version
{"}"}/downloaded
</code>{" "}
is downloaded
</li>
</ul>
</div>
<div className="text-muted-foreground">
These docs are temporary and will be replaced with full
documentation later.
</div>
</div>
</div>
)}
<Label className="text-base font-medium">Integrations</Label>
<p className="text-xs text-muted-foreground">
Configure Local API and MCP (Model Context Protocol) for
integrating with external tools and AI assistants.
</p>
<RippleButton
variant="outline"
className="w-full"
onClick={onIntegrationsOpen}
>
Open Integrations Settings
</RippleButton>
</div>
{/* Commercial License Section */}
@@ -1094,71 +762,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
</div>
</div>
{/* MCP Server Section */}
<div className="space-y-4">
<Label className="text-base font-medium">MCP Server</Label>
<div className="flex items-center space-x-2">
<Checkbox
id="mcp-enabled"
checked={mcpEnabled}
disabled={!termsAccepted || isMcpStarting}
onCheckedChange={async (checked: boolean) => {
setIsMcpStarting(true);
try {
if (checked) {
await invoke("start_mcp_server");
setMcpEnabled(true);
showSuccessToast("MCP server started");
} else {
await invoke("stop_mcp_server");
setMcpEnabled(false);
showSuccessToast("MCP server stopped");
}
} catch (e) {
console.error("Failed to toggle MCP server:", e);
showErrorToast("Failed to toggle MCP server", {
description:
e instanceof Error ? e.message : "Unknown error",
});
} finally {
setIsMcpStarting(false);
}
}}
/>
<div className="grid gap-1.5 leading-none">
<Label
htmlFor="mcp-enabled"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Enable MCP Server (Model Context Protocol)
</Label>
<p className="text-xs text-muted-foreground">
Allow AI assistants to control Wayfern and Camoufox browsers
via MCP.
{!termsAccepted && (
<span className="ml-1 text-orange-600">
(Accept terms first)
</span>
)}
</p>
</div>
</div>
{mcpEnabled && (
<div className="p-3 space-y-2 text-xs rounded-md border bg-muted/40">
<div className="font-medium">Available MCP Tools</div>
<ul className="list-disc ml-5 space-y-0.5 text-muted-foreground">
<li>list_profiles - List Wayfern/Camoufox profiles</li>
<li>run_profile - Launch a browser profile</li>
<li>kill_profile - Stop a running browser</li>
<li>get_profile - Get profile details</li>
<li>list_proxies - List configured proxies</li>
</ul>
</div>
)}
</div>
{/* Advanced Section */}
<div className="space-y-4">
<Label className="text-base font-medium">Advanced</Label>