mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-05-09 11:45:39 +02:00
feat: move background processes to its own daemon
This commit is contained in:
Generated
+918
-83
File diff suppressed because it is too large
Load Diff
+15
-2
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod autostart;
|
||||
pub mod services;
|
||||
pub mod tray;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 {}",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user