From c98e12900f3dd46b8cc2bdf089f50dec0744ddfa Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Sat, 16 Aug 2025 11:42:15 +0400 Subject: [PATCH] feat: add local api support --- src-tauri/Cargo.lock | 111 ++++- src-tauri/Cargo.toml | 6 +- src-tauri/src/api_client.rs | 12 +- src-tauri/src/api_server.rs | 701 +++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 57 ++- src-tauri/src/settings_manager.rs | 12 + src/components/settings-dialog.tsx | 101 ++++- 7 files changed, 985 insertions(+), 15 deletions(-) create mode 100644 src-tauri/src/api_server.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0a7c8ae..d2e50e4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -290,6 +290,60 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "backtrace" version = "0.3.75" @@ -1024,6 +1078,7 @@ name = "donutbrowser" version = "0.9.4" dependencies = [ "async-trait", + "axum", "base64 0.22.1", "bzip2", "chrono", @@ -1039,6 +1094,7 @@ dependencies = [ "msi-extract", "objc2 0.6.1", "objc2-app-kit 0.3.1", + "rand 0.9.2", "reqwest", "serde", "serde_json", @@ -2488,6 +2544,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.7.5" @@ -3516,6 +3578,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -3536,6 +3608,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -3554,6 +3636,15 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.3", +] + [[package]] name = "rand_hc" version = "0.2.0" @@ -3995,6 +4086,16 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" +dependencies = [ + "itoa", + "serde", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -4361,9 +4462,9 @@ dependencies = [ [[package]] name = "sysinfo" -version = "0.36.1" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252800745060e7b9ffb7b2badbd8b31cfa4aa2e61af879d0a3bf2a317c20217d" +checksum = "07cec4dc2d2e357ca1e610cfb07de2fa7a10fc3e9fe89f72545f3d244ea87753" dependencies = [ "libc", "memchr", @@ -4845,7 +4946,7 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5111,6 +5212,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -5159,6 +5261,7 @@ version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -5682,7 +5785,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 97b2fda..f68b63d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -32,7 +32,7 @@ tauri-plugin-macos-permissions = "2" directories = "6" reqwest = { version = "0.12", features = ["json", "stream"] } tokio = { version = "1", features = ["full", "sync"] } -sysinfo = "0.36" +sysinfo = "0.37" lazy_static = "1.4" base64 = "0.22" async-trait = "0.1" @@ -47,6 +47,10 @@ msi-extract = "0" uuid = { version = "1.0", features = ["v4", "serde"] } url = "2.5" chrono = { version = "0.4", features = ["serde"] } +axum = "0.8.4" +tower = "0.5" +tower-http = { version = "0.6", features = ["cors"] } +rand = "0.9.2" [target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies] tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } diff --git a/src-tauri/src/api_client.rs b/src-tauri/src/api_client.rs index 4c60f10..7c0f2f2 100644 --- a/src-tauri/src/api_client.rs +++ b/src-tauri/src/api_client.rs @@ -339,7 +339,7 @@ impl ApiClient { .timeout(std::time::Duration::from_secs(30)) .build() .unwrap_or_else(|_| Client::new()); - + Self { client, firefox_api_base: "https://product-details.mozilla.org/1.0".to_string(), @@ -347,8 +347,7 @@ impl ApiClient { github_api_base: "https://api.github.com".to_string(), chromium_api_base: "https://commondatastorage.googleapis.com/chromium-browser-snapshots" .to_string(), - tor_archive_base: "https://archive.torproject.org/tor-package-archive/torbrowser" - .to_string(), + tor_archive_base: "https://archive.torproject.org/tor-package-archive/torbrowser".to_string(), } } @@ -653,7 +652,7 @@ impl ApiClient { let response = self .client - .get(url) + .get(&url) .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36") .send() .await?; @@ -661,9 +660,10 @@ impl ApiClient { if !response.status().is_success() { let error_msg = format!( "Failed to fetch Firefox Developer Edition versions: {} - URL: {}", - response.status(), url + response.status(), + url ); - eprintln!("{}", error_msg); + eprintln!("{error_msg}"); return Err(error_msg.into()); } diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs new file mode 100644 index 0000000..6cf92d5 --- /dev/null +++ b/src-tauri/src/api_server.rs @@ -0,0 +1,701 @@ +use crate::group_manager::GROUP_MANAGER; +use crate::profile::manager::ProfileManager; +use crate::proxy_manager::PROXY_MANAGER; +use crate::tag_manager::TAG_MANAGER; +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::Json, + routing::{delete, get, post, put}, + Router, +}; +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; + +// API Types +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ApiProfile { + pub id: String, + pub name: String, + pub browser: String, + pub version: String, + pub proxy_id: Option, + pub process_id: Option, + pub last_launch: Option, + pub release_type: String, + pub camoufox_config: Option, + pub group_id: Option, + pub tags: Vec, + pub is_running: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiProfilesResponse { + pub profiles: Vec, + pub total: usize, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiProfileResponse { + pub profile: ApiProfile, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateProfileRequest { + pub name: String, + pub browser: String, + pub version: Option, + pub proxy_id: Option, + pub release_type: Option, + pub camoufox_config: Option, + pub group_id: Option, + pub tags: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdateProfileRequest { + pub name: Option, + pub browser: Option, + pub version: Option, + pub proxy_id: Option, + pub release_type: Option, + pub camoufox_config: Option, + pub group_id: Option, + pub tags: Option>, +} + +#[derive(Clone)] +struct ApiServerState { + app_handle: tauri::AppHandle, +} + +#[derive(Debug, Serialize, Deserialize)] +struct ApiGroupResponse { + id: String, + name: String, + profile_count: usize, +} + +#[derive(Debug, Deserialize)] +struct CreateGroupRequest { + name: String, +} + +#[derive(Debug, Deserialize)] +struct UpdateGroupRequest { + name: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct ApiProxyResponse { + id: String, + name: String, + proxy_settings: serde_json::Value, +} + +#[derive(Debug, Deserialize)] +struct CreateProxyRequest { + name: String, + proxy_settings: serde_json::Value, +} + +#[derive(Debug, Deserialize)] +struct UpdateProxyRequest { + name: Option, + proxy_settings: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToastPayload { + pub message: String, + pub variant: String, + pub title: String, + pub description: Option, +} + +pub struct ApiServer { + port: Option, + shutdown_tx: Option>, + task_handle: Option>, +} + +impl ApiServer { + fn new() -> Self { + Self { + port: None, + shutdown_tx: None, + task_handle: None, + } + } + + fn get_port(&self) -> Option { + self.port + } + + async fn start( + &mut self, + app_handle: tauri::AppHandle, + preferred_port: u16, + ) -> Result { + // Stop existing server if running + self.stop().await.ok(); + + let (shutdown_tx, mut shutdown_rx) = mpsc::channel(1); + let state = ApiServerState { + app_handle: app_handle.clone(), + }; + + // Try preferred port first, then random port + let listener = match TcpListener::bind(format!("127.0.0.1:{preferred_port}")).await { + Ok(listener) => listener, + Err(_) => { + // Port conflict, try random port + let random_port = rand::random::().saturating_add(10000); + match TcpListener::bind(format!("127.0.0.1:{random_port}")).await { + Ok(listener) => { + let _ = app_handle.emit( + "api-port-conflict", + format!("API server using fallback port {random_port}"), + ); + listener + } + Err(e) => return Err(format!("Failed to bind to any port: {e}")), + } + } + }; + + let actual_port = listener + .local_addr() + .map_err(|e| format!("Failed to get local address: {e}"))? + .port(); + + // Create router with CORS + let app = Router::new() + .route("/profiles", get(get_profiles)) + .route("/profiles", post(create_profile)) + .route("/profiles/{id}", get(get_profile)) + .route("/profiles/{id}", put(update_profile)) + .route("/profiles/{id}", delete(delete_profile)) + .route("/groups", get(get_groups).post(create_group)) + .route( + "/groups/{id}", + get(get_group).put(update_group).delete(delete_group), + ) + .route("/tags", get(get_tags)) + .route("/proxies", get(get_proxies).post(create_proxy)) + .route( + "/proxies/{id}", + get(get_proxy).put(update_proxy).delete(delete_proxy), + ) + .layer(CorsLayer::permissive()) + .with_state(state); + + // Start server task + let task_handle = tokio::spawn(async move { + let server = axum::serve(listener, app); + tokio::select! { + _ = server => {}, + _ = shutdown_rx.recv() => {}, + } + }); + + self.port = Some(actual_port); + self.shutdown_tx = Some(shutdown_tx); + self.task_handle = Some(task_handle); + + Ok(actual_port) + } + + async fn stop(&mut self) -> Result<(), String> { + if let Some(shutdown_tx) = self.shutdown_tx.take() { + let _ = shutdown_tx.send(()).await; + } + + if let Some(handle) = self.task_handle.take() { + handle.abort(); + } + + self.port = None; + Ok(()) + } +} + +// Global API server instance +lazy_static! { + pub static ref API_SERVER: Arc> = Arc::new(Mutex::new(ApiServer::new())); +} + +// Tauri commands +#[tauri::command] +pub async fn start_api_server_internal( + port: u16, + app_handle: &tauri::AppHandle, +) -> Result { + let mut server_guard = API_SERVER.lock().await; + server_guard.start(app_handle.clone(), port).await +} + +#[tauri::command] +pub async fn stop_api_server() -> Result<(), String> { + let mut server_guard = API_SERVER.lock().await; + server_guard.stop().await +} + +#[tauri::command] +pub async fn start_api_server( + port: Option, + app_handle: tauri::AppHandle, +) -> Result { + let actual_port = port.unwrap_or(10108); + start_api_server_internal(actual_port, &app_handle).await +} + +#[tauri::command] +pub async fn get_api_server_status() -> Result, String> { + let server_guard = API_SERVER.lock().await; + Ok(server_guard.get_port()) +} + +// API Handlers - Profiles +async fn get_profiles() -> Result, StatusCode> { + let profile_manager = ProfileManager::instance(); + match profile_manager.list_profiles() { + Ok(profiles) => { + let api_profiles: Vec = profiles + .iter() + .map(|profile| ApiProfile { + id: profile.id.to_string(), + name: profile.name.clone(), + browser: profile.browser.clone(), + version: profile.version.clone(), + proxy_id: profile.proxy_id.clone(), + process_id: profile.process_id, + last_launch: profile.last_launch, + release_type: profile.release_type.clone(), + camoufox_config: profile + .camoufox_config + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + group_id: profile.group_id.clone(), + tags: profile.tags.clone(), + is_running: false, // For now, set to false - can add running status later + }) + .collect(); + + Ok(Json(ApiProfilesResponse { + profiles: api_profiles, + total: profiles.len(), + })) + } + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} + +async fn get_profile( + Path(id): Path, + State(_state): State, +) -> Result, StatusCode> { + let profile_manager = ProfileManager::instance(); + match profile_manager.list_profiles() { + Ok(profiles) => { + if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == id) { + Ok(Json(ApiProfileResponse { + profile: ApiProfile { + id: profile.id.to_string(), + name: profile.name.clone(), + browser: profile.browser.clone(), + version: profile.version.clone(), + proxy_id: profile.proxy_id.clone(), + process_id: profile.process_id, + last_launch: profile.last_launch, + release_type: profile.release_type.clone(), + camoufox_config: profile + .camoufox_config + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + group_id: profile.group_id.clone(), + tags: profile.tags.clone(), + is_running: false, // Simplified for now to avoid async complexity + }, + })) + } else { + Err(StatusCode::NOT_FOUND) + } + } + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} + +async fn create_profile( + State(state): State, + Json(request): Json, +) -> Result, StatusCode> { + let profile_manager = ProfileManager::instance(); + + // Parse camoufox config if provided + let camoufox_config = if let Some(config) = &request.camoufox_config { + serde_json::from_value(config.clone()).ok() + } else { + None + }; + + // Create profile using the async create_profile_with_group method + match profile_manager + .create_profile_with_group( + &state.app_handle, + &request.name, + &request.browser, + request.version.as_deref().unwrap_or("stable"), + request.release_type.as_deref().unwrap_or("release"), + request.proxy_id.clone(), + camoufox_config, + request.group_id.clone(), + ) + .await + { + Ok(mut profile) => { + // Apply tags if provided + if let Some(tags) = &request.tags { + if profile_manager + .update_profile_tags(&profile.name, tags.clone()) + .is_err() + { + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + profile.tags = tags.clone(); + } + + // Update tag manager with new tags + if let Ok(profiles) = profile_manager.list_profiles() { + let _ = crate::tag_manager::TAG_MANAGER + .lock() + .map(|manager| manager.rebuild_from_profiles(&profiles)); + } + + Ok(Json(ApiProfileResponse { + profile: ApiProfile { + id: profile.id.to_string(), + name: profile.name, + browser: profile.browser, + version: profile.version, + proxy_id: profile.proxy_id, + process_id: profile.process_id, + last_launch: profile.last_launch, + release_type: profile.release_type, + camoufox_config: profile + .camoufox_config + .as_ref() + .and_then(|c| serde_json::to_value(c).ok()), + group_id: profile.group_id, + tags: profile.tags, + is_running: false, + }, + })) + } + Err(_) => Err(StatusCode::BAD_REQUEST), + } +} + +async fn update_profile( + Path(id): Path, + State(state): State, + Json(request): Json, +) -> Result, StatusCode> { + let profile_manager = ProfileManager::instance(); + + // Update profile fields + if let Some(name) = request.name { + if profile_manager.rename_profile(&id, &name).is_err() { + return Err(StatusCode::BAD_REQUEST); + } + } + + if let Some(version) = request.version { + if profile_manager + .update_profile_version(&id, &version) + .is_err() + { + return Err(StatusCode::BAD_REQUEST); + } + } + + if let Some(proxy_id) = request.proxy_id { + if profile_manager + .update_profile_proxy(state.app_handle.clone(), &id, Some(proxy_id)) + .await + .is_err() + { + return Err(StatusCode::BAD_REQUEST); + } + } + + if let Some(camoufox_config) = request.camoufox_config { + let config: Result = + serde_json::from_value(camoufox_config); + match config { + Ok(config) => { + if profile_manager + .update_camoufox_config(state.app_handle.clone(), &id, config) + .await + .is_err() + { + return Err(StatusCode::BAD_REQUEST); + } + } + Err(_) => return Err(StatusCode::BAD_REQUEST), + } + } + + if let Some(group_id) = request.group_id { + if profile_manager + .assign_profiles_to_group(vec![id.clone()], Some(group_id)) + .is_err() + { + return Err(StatusCode::BAD_REQUEST); + } + } + + if let Some(tags) = request.tags { + if profile_manager.update_profile_tags(&id, tags).is_err() { + return Err(StatusCode::BAD_REQUEST); + } + + // Update tag manager with new tags from all profiles + if let Ok(profiles) = profile_manager.list_profiles() { + let _ = crate::tag_manager::TAG_MANAGER + .lock() + .map(|manager| manager.rebuild_from_profiles(&profiles)); + } + } + + // Return updated profile + get_profile(Path(id), State(state)).await +} + +async fn delete_profile( + Path(id): Path, + State(_state): State, +) -> Result { + let profile_manager = ProfileManager::instance(); + match profile_manager.delete_profile(&id) { + Ok(_) => Ok(StatusCode::NO_CONTENT), + Err(_) => Err(StatusCode::BAD_REQUEST), + } +} + +// API Handlers - Groups +async fn get_groups( + State(_state): State, +) -> Result>, StatusCode> { + match GROUP_MANAGER.lock() { + Ok(manager) => { + match manager.get_all_groups() { + Ok(groups) => { + let api_groups = groups + .into_iter() + .map(|group| ApiGroupResponse { + id: group.id, + name: group.name, + profile_count: 0, // Would need profile list to calculate this + }) + .collect(); + Ok(Json(api_groups)) + } + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + } + } + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} + +async fn get_group( + Path(id): Path, + State(_state): State, +) -> Result, StatusCode> { + match GROUP_MANAGER.lock() { + Ok(manager) => match manager.get_all_groups() { + Ok(groups) => { + if let Some(group) = groups.into_iter().find(|g| g.id == id) { + Ok(Json(ApiGroupResponse { + id: group.id, + name: group.name, + profile_count: 0, + })) + } else { + Err(StatusCode::NOT_FOUND) + } + } + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + }, + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} + +async fn create_group( + State(_state): State, + Json(request): Json, +) -> Result, StatusCode> { + match GROUP_MANAGER.lock() { + Ok(manager) => match manager.create_group(request.name) { + Ok(group) => Ok(Json(ApiGroupResponse { + id: group.id, + name: group.name, + profile_count: 0, + })), + Err(_) => Err(StatusCode::BAD_REQUEST), + }, + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} + +async fn update_group( + Path(id): Path, + State(_state): State, + Json(request): Json, +) -> Result, StatusCode> { + match GROUP_MANAGER.lock() { + Ok(manager) => match manager.update_group(id.clone(), request.name) { + Ok(group) => Ok(Json(ApiGroupResponse { + id: group.id, + name: group.name, + profile_count: 0, + })), + Err(_) => Err(StatusCode::BAD_REQUEST), + }, + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} + +async fn delete_group( + Path(id): Path, + State(_state): State, +) -> Result { + match GROUP_MANAGER.lock() { + Ok(manager) => match manager.delete_group(id.clone()) { + Ok(_) => Ok(StatusCode::NO_CONTENT), + Err(_) => Err(StatusCode::BAD_REQUEST), + }, + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} + +// API Handlers - Tags +async fn get_tags(State(_state): State) -> Result>, StatusCode> { + match TAG_MANAGER.lock() { + Ok(manager) => match manager.get_all_tags() { + Ok(tags) => Ok(Json(tags)), + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + }, + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} + +// API Handlers - Proxies +async fn get_proxies( + State(_state): State, +) -> Result>, StatusCode> { + let proxies = PROXY_MANAGER.get_stored_proxies(); + Ok(Json( + proxies + .into_iter() + .map(|p| ApiProxyResponse { + id: p.id, + name: p.name, + proxy_settings: serde_json::to_value(p.proxy_settings).unwrap_or_default(), + }) + .collect(), + )) +} + +async fn get_proxy( + Path(id): Path, + State(_state): State, +) -> Result, StatusCode> { + let proxies = PROXY_MANAGER.get_stored_proxies(); + if let Some(proxy) = proxies.into_iter().find(|p| p.id == id) { + Ok(Json(ApiProxyResponse { + id: proxy.id, + name: proxy.name, + proxy_settings: serde_json::to_value(proxy.proxy_settings).unwrap_or_default(), + })) + } else { + Err(StatusCode::NOT_FOUND) + } +} + +async fn create_proxy( + State(_state): State, + Json(request): Json, +) -> Result, StatusCode> { + // Convert JSON value to ProxySettings + match serde_json::from_value(request.proxy_settings.clone()) { + Ok(proxy_settings) => { + match PROXY_MANAGER.create_stored_proxy(request.name.clone(), proxy_settings) { + Ok(_) => { + // Find the created proxy to return it + let proxies = PROXY_MANAGER.get_stored_proxies(); + if let Some(proxy) = proxies.into_iter().find(|p| p.name == request.name) { + Ok(Json(ApiProxyResponse { + id: proxy.id, + name: proxy.name, + proxy_settings: request.proxy_settings, + })) + } else { + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } + Err(_) => Err(StatusCode::BAD_REQUEST), + } + } + Err(_) => Err(StatusCode::BAD_REQUEST), + } +} + +async fn update_proxy( + Path(id): Path, + State(_state): State, + Json(request): Json, +) -> Result, StatusCode> { + let proxies = PROXY_MANAGER.get_stored_proxies(); + if let Some(proxy) = proxies.into_iter().find(|p| p.id == id) { + let new_name = request.name.unwrap_or(proxy.name.clone()); + let new_proxy_settings = if let Some(settings_json) = request.proxy_settings { + match serde_json::from_value(settings_json) { + Ok(settings) => settings, + Err(_) => return Err(StatusCode::BAD_REQUEST), + } + } else { + proxy.proxy_settings.clone() + }; + + match PROXY_MANAGER.update_stored_proxy( + &id, + Some(new_name.clone()), + Some(new_proxy_settings.clone()), + ) { + Ok(_) => Ok(Json(ApiProxyResponse { + id, + name: new_name, + proxy_settings: serde_json::to_value(new_proxy_settings).unwrap_or_default(), + })), + Err(_) => Err(StatusCode::BAD_REQUEST), + } + } else { + Err(StatusCode::NOT_FOUND) + } +} + +async fn delete_proxy( + Path(id): Path, + State(_state): State, +) -> Result { + match PROXY_MANAGER.delete_stored_proxy(&id) { + Ok(_) => Ok(StatusCode::NO_CONTENT), + Err(_) => Err(StatusCode::BAD_REQUEST), + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a29bbfa..6081d16 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ use tauri_plugin_deep_link::DeepLinkExt; static PENDING_URLS: Mutex> = Mutex::new(Vec::new()); mod api_client; +mod api_server; mod app_auto_updater; mod auto_updater; mod browser; @@ -29,8 +30,6 @@ mod settings_manager; mod tag_manager; mod version_updater; -extern crate lazy_static; - use browser_runner::{ check_browser_exists, check_browser_status, check_missing_binaries, check_missing_geoip_database, create_browser_profile_new, delete_profile, download_browser, ensure_all_binaries_exist, @@ -72,6 +71,8 @@ use geoip_downloader::GeoIPDownloader; use browser_version_manager::get_browser_release_types; +use api_server::{get_api_server_status, start_api_server, stop_api_server}; + // Trait to extend WebviewWindow with transparent titlebar functionality pub trait WindowExt { #[cfg(target_os = "macos")] @@ -490,6 +491,55 @@ pub fn run() { // Nodecar warm-up is now triggered from the frontend to allow UI blocking overlay + // Start API server if enabled in settings + let app_handle_api = app.handle().clone(); + tauri::async_runtime::spawn(async move { + match crate::settings_manager::get_app_settings().await { + Ok(settings) => { + if settings.api_enabled { + println!("API is enabled in settings, starting API server..."); + match crate::api_server::start_api_server_internal(settings.api_port, &app_handle_api) + .await + { + Ok(port) => { + println!("API server started successfully on port {port}"); + // Emit success toast to frontend + if let Err(e) = app_handle_api.emit( + "show-toast", + crate::api_server::ToastPayload { + message: "API server started successfully".to_string(), + variant: "success".to_string(), + title: "Local API Started".to_string(), + description: Some(format!("API server running on port {port}")), + }, + ) { + eprintln!("Failed to emit API start toast: {e}"); + } + } + Err(e) => { + eprintln!("Failed to start API server at startup: {e}"); + // Emit error toast to frontend + if let Err(toast_err) = app_handle_api.emit( + "show-toast", + crate::api_server::ToastPayload { + message: "Failed to start API server".to_string(), + variant: "error".to_string(), + title: "Failed to Start Local API".to_string(), + description: Some(format!("Error: {e}")), + }, + ) { + eprintln!("Failed to emit API error toast: {toast_err}"); + } + } + } + } + } + Err(e) => { + eprintln!("Failed to load app settings for API startup: {e}"); + } + } + }); + Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -551,6 +601,9 @@ pub fn run() { is_geoip_database_available, download_geoip_database, warm_up_nodecar, + start_api_server, + stop_api_server, + get_api_server_status, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/settings_manager.rs b/src-tauri/src/settings_manager.rs index 9ff755c..d55ff98 100644 --- a/src-tauri/src/settings_manager.rs +++ b/src-tauri/src/settings_manager.rs @@ -29,18 +29,28 @@ pub struct AppSettings { pub theme: String, // "light", "dark", or "system" #[serde(default)] pub custom_theme: Option>, // CSS var name -> value (e.g., "--background": "#1a1b26") + #[serde(default)] + pub api_enabled: bool, + #[serde(default = "default_api_port")] + pub api_port: u16, } fn default_theme() -> String { "system".to_string() } +fn default_api_port() -> u16 { + 10108 +} + impl Default for AppSettings { fn default() -> Self { Self { set_as_default_browser: false, theme: "system".to_string(), custom_theme: None, + api_enabled: false, + api_port: 10108, } } } @@ -325,6 +335,8 @@ mod tests { set_as_default_browser: true, theme: "dark".to_string(), custom_theme: None, + api_enabled: false, + api_port: 10108, }; // Save settings diff --git a/src/components/settings-dialog.tsx b/src/components/settings-dialog.tsx index c4befe5..9d269b5 100644 --- a/src/components/settings-dialog.tsx +++ b/src/components/settings-dialog.tsx @@ -7,6 +7,7 @@ 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, @@ -51,6 +52,8 @@ interface AppSettings { set_as_default_browser: boolean; theme: string; custom_theme?: Record; + api_enabled: boolean; + api_port: number; } interface CustomThemeState { @@ -76,11 +79,15 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { set_as_default_browser: false, theme: "system", custom_theme: undefined, + api_enabled: false, + api_port: 10108, }); const [originalSettings, setOriginalSettings] = useState({ set_as_default_browser: false, theme: "system", custom_theme: undefined, + api_enabled: false, + api_port: 10108, }); const [customThemeState, setCustomThemeState] = useState({ selectedThemeId: null, @@ -96,6 +103,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { const [requestingPermission, setRequestingPermission] = useState(null); const [isMacOS, setIsMacOS] = useState(false); + const [apiServerPort, setApiServerPort] = useState(null); const { setTheme } = useTheme(); const { @@ -285,6 +293,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { }, [getPermissionDisplayName, requestPermission], ); + const handleSave = useCallback(async () => { setIsSaving(true); try { @@ -326,6 +335,43 @@ 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("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; + await invoke("save_app_settings", { settings: settingsToSave }); + } + } 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) { @@ -333,7 +379,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { } finally { setIsSaving(false); } - }, [onClose, setTheme, settings, customThemeState]); + }, [onClose, setTheme, settings, customThemeState, originalSettings]); const updateSetting = useCallback( ( @@ -345,6 +391,16 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { [], ); + const loadApiServerStatus = useCallback(async () => { + try { + const port = await invoke("get_api_server_status"); + setApiServerPort(port); + } catch (error) { + console.error("Failed to load API server status:", error); + setApiServerPort(null); + } + }, []); + const handleClose = useCallback(() => { // Restore original theme when closing without saving if (originalSettings.theme === "custom" && originalSettings.custom_theme) { @@ -382,6 +438,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { if (isOpen) { loadSettings().catch(console.error); checkDefaultBrowserStatus().catch(console.error); + loadApiServerStatus().catch(console.error); // Check if we're on macOS const userAgent = navigator.userAgent; @@ -402,7 +459,13 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { clearInterval(intervalId); }; } - }, [isOpen, loadPermissions, checkDefaultBrowserStatus, loadSettings]); + }, [ + isOpen, + loadPermissions, + checkDefaultBrowserStatus, + loadSettings, + loadApiServerStatus, + ]); // Update permissions when the permission states change useEffect(() => { @@ -433,6 +496,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { // Check if settings have changed (excluding default browser setting) const hasChanges = settings.theme !== originalSettings.theme || + settings.api_enabled !== originalSettings.api_enabled || (settings.theme === "custom" && JSON.stringify(customThemeState.colors) !== JSON.stringify(originalSettings.custom_theme ?? {})) || @@ -692,6 +756,39 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { )} + {/* Local API Section */} +
+ + +
+ { + updateSetting("api_enabled", checked); + }} + /> +
+ +

+ Allows external applications to manage profiles via HTTP API. + Server will start on port 10108 or a random port if + unavailable. + {apiServerPort && ( + + (Currently running on port {apiServerPort}) + + )} +

+
+
+
+ {/* Advanced Section */}