Compare commits

..

13 Commits

Author SHA1 Message Date
zhom 0d79f385bd chore: cleanup 2025-08-19 14:01:24 +04:00
zhom 25bb1dccdc chore: version bump 2025-08-19 13:57:58 +04:00
zhom 97044d58fe feat: proxy sorting 2025-08-19 13:55:40 +04:00
zhom 4748a31714 style: don't overflow 2025-08-19 13:50:24 +04:00
zhom d91c97dd85 chore: formatting 2025-08-19 13:47:19 +04:00
zhom 8e299fddd4 feat: docs inside ui 2025-08-19 13:45:19 +04:00
zhom 6c3c9fb58a chore: comment 2025-08-19 13:38:38 +04:00
zhom f5066e866b fix: pass id instead of profile name to open_url_with_profile 2025-08-19 13:38:29 +04:00
zhom e12a5661b1 refactor: use ids instead of names for all profile operations 2025-08-19 13:31:46 +04:00
zhom f8a4ec3277 refactor: require auth for local api 2025-08-19 13:31:28 +04:00
zhom 1e5664e3b2 feat: launch browsers via api and expose them to selenium 2025-08-19 09:49:39 +04:00
zhom d0fea2fec1 style: scroll area adjustments 2025-08-19 09:48:46 +04:00
zhom ce0627030d style: move geolocation and locale fields above webgl 2025-08-18 18:26:05 +04:00
26 changed files with 1396 additions and 359 deletions
+2
View File
@@ -20,6 +20,7 @@
"CFURL",
"checkin",
"chrono",
"ciphertext",
"CLICOLOR",
"clippy",
"cmdk",
@@ -31,6 +32,7 @@
"dataclasses",
"datareporting",
"datas",
"DBAPI",
"dconf",
"devedition",
"distro",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.10.1",
"version": "0.11.0",
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
+113 -1
View File
@@ -17,6 +17,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
@@ -28,6 +38,20 @@ dependencies = [
"cpufeatures",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]]
name = "aho-corasick"
version = "1.1.3"
@@ -82,6 +106,18 @@ dependencies = [
"derive_arbitrary",
]
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]]
name = "ashpd"
version = "0.9.2"
@@ -371,6 +407,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -386,6 +428,15 @@ dependencies = [
"serde",
]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -820,6 +871,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"typenum",
]
@@ -860,6 +912,15 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]]
name = "darling"
version = "0.20.11"
@@ -1075,8 +1136,10 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.10.1"
version = "0.11.0"
dependencies = [
"aes-gcm",
"argon2",
"async-trait",
"axum",
"base64 0.22.1",
@@ -1635,6 +1698,16 @@ dependencies = [
"wasi 0.14.2+wasi-0.2.4",
]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]]
name = "gimli"
version = "0.31.1"
@@ -3054,6 +3127,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "open"
version = "5.3.2"
@@ -3200,6 +3279,17 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "pathdiff"
version = "0.2.3"
@@ -3425,6 +3515,18 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "potential_utf"
version = "0.1.2"
@@ -5404,6 +5506,16 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "untrusted"
version = "0.9.0"
+5 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.10.1"
version = "0.11.0"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
@@ -29,6 +29,8 @@ tauri-plugin-shell = "2"
tauri-plugin-deep-link = "2"
tauri-plugin-dialog = "2"
tauri-plugin-macos-permissions = "2"
directories = "6"
reqwest = { version = "0.12", features = ["json", "stream"] }
tokio = { version = "1", features = ["full", "sync"] }
@@ -51,6 +53,8 @@ axum = "0.8.4"
tower = "0.5"
tower-http = { version = "0.6", features = ["cors"] }
rand = "0.9.2"
argon2 = "0.5"
aes-gcm = "0.10"
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
+8
View File
@@ -26,5 +26,13 @@ fn main() {
println!("cargo:rustc-env=BUILD_VERSION=dev-{version}");
}
// Inject vault password at build time
if let Ok(vault_password) = std::env::var("DONUT_BROWSER_VAULT_PASSWORD") {
println!("cargo:rustc-env=DONUT_BROWSER_VAULT_PASSWORD={vault_password}");
} else {
// Use default password if environment variable is not set
println!("cargo:rustc-env=DONUT_BROWSER_VAULT_PASSWORD=donutbrowser-api-vault-password");
}
tauri_build::build()
}
+179 -15
View File
@@ -3,14 +3,16 @@ 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,
extract::{Path, Query, State},
http::{HeaderMap, StatusCode},
middleware::{self, Next},
response::{Json, Response},
routing::{delete, get, post, put},
Router,
};
use lazy_static::lazy_static;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tauri::Emitter;
use tokio::net::TcpListener;
@@ -110,6 +112,19 @@ struct UpdateProxyRequest {
proxy_settings: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
struct DownloadBrowserRequest {
browser: String,
version: String,
}
#[derive(Debug, Serialize)]
struct DownloadBrowserResponse {
browser: String,
version: String,
status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToastPayload {
pub message: String,
@@ -118,6 +133,13 @@ pub struct ToastPayload {
pub description: Option<String>,
}
#[derive(Debug, Serialize)]
struct RunProfileResponse {
profile_id: String,
remote_debugging_port: u16,
headless: bool,
}
pub struct ApiServer {
port: Option<u16>,
shutdown_tx: Option<mpsc::Sender<()>>,
@@ -174,13 +196,14 @@ impl ApiServer {
.map_err(|e| format!("Failed to get local address: {e}"))?
.port();
// Create router with CORS
let app = Router::new()
// Create router with CORS, authentication, and versioning
let v1_routes = 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("/profiles/{id}/run", post(run_profile))
.route("/groups", get(get_groups).post(create_group))
.route(
"/groups/{id}",
@@ -192,6 +215,19 @@ impl ApiServer {
"/proxies/{id}",
get(get_proxy).put(update_proxy).delete(delete_proxy),
)
.route("/browsers/download", post(download_browser_api))
.route("/browsers/{browser}/versions", get(get_browser_versions))
.route(
"/browsers/{browser}/versions/{version}/downloaded",
get(check_browser_downloaded),
)
.layer(middleware::from_fn_with_state(
state.clone(),
auth_middleware,
));
let app = Router::new()
.nest("/v1", v1_routes)
.layer(CorsLayer::permissive())
.with_state(state);
@@ -225,6 +261,41 @@ impl ApiServer {
}
}
// Authentication middleware
async fn auth_middleware(
State(state): State<ApiServerState>,
headers: HeaderMap,
request: axum::extract::Request,
next: Next,
) -> Result<Response, StatusCode> {
// Get the Authorization header
let auth_header = headers
.get("Authorization")
.and_then(|h| h.to_str().ok())
.and_then(|h| h.strip_prefix("Bearer "));
let token = match auth_header {
Some(token) => token,
None => return Err(StatusCode::UNAUTHORIZED),
};
// Get the stored token
let settings_manager = crate::settings_manager::SettingsManager::instance();
let stored_token = match settings_manager.get_api_token(&state.app_handle).await {
Ok(Some(stored_token)) => stored_token,
Ok(None) => return Err(StatusCode::UNAUTHORIZED),
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
// Compare tokens
if token != stored_token {
return Err(StatusCode::UNAUTHORIZED);
}
// Token is valid, continue with the request
Ok(next.run(request).await)
}
// Global API server instance
lazy_static! {
pub static ref API_SERVER: Arc<Mutex<ApiServer>> = Arc::new(Mutex::new(ApiServer::new()));
@@ -283,7 +354,7 @@ async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
.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
is_running: profile.process_id.is_some(), // Simple check based on process_id
})
.collect();
@@ -320,7 +391,7 @@ async fn get_profile(
.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
is_running: profile.process_id.is_some(), // Simple check based on process_id
},
}))
} else {
@@ -402,7 +473,7 @@ async fn create_profile(
}
async fn update_profile(
Path(name): Path<String>,
Path(id): Path<String>,
State(state): State<ApiServerState>,
Json(request): Json<UpdateProfileRequest>,
) -> Result<Json<ApiProfileResponse>, StatusCode> {
@@ -411,7 +482,7 @@ async fn update_profile(
// Update profile fields
if let Some(new_name) = request.name {
if profile_manager
.rename_profile(&state.app_handle, &name, &new_name)
.rename_profile(&state.app_handle, &id, &new_name)
.is_err()
{
return Err(StatusCode::BAD_REQUEST);
@@ -420,7 +491,7 @@ async fn update_profile(
if let Some(version) = request.version {
if profile_manager
.update_profile_version(&state.app_handle, &name, &version)
.update_profile_version(&state.app_handle, &id, &version)
.is_err()
{
return Err(StatusCode::BAD_REQUEST);
@@ -429,7 +500,7 @@ async fn update_profile(
if let Some(proxy_id) = request.proxy_id {
if profile_manager
.update_profile_proxy(state.app_handle.clone(), &name, Some(proxy_id))
.update_profile_proxy(state.app_handle.clone(), &id, Some(proxy_id))
.await
.is_err()
{
@@ -443,7 +514,7 @@ async fn update_profile(
match config {
Ok(config) => {
if profile_manager
.update_camoufox_config(state.app_handle.clone(), &name, config)
.update_camoufox_config(state.app_handle.clone(), &id, config)
.await
.is_err()
{
@@ -456,7 +527,7 @@ async fn update_profile(
if let Some(group_id) = request.group_id {
if profile_manager
.assign_profiles_to_group(&state.app_handle, vec![name.clone()], Some(group_id))
.assign_profiles_to_group(&state.app_handle, vec![id.clone()], Some(group_id))
.is_err()
{
return Err(StatusCode::BAD_REQUEST);
@@ -465,7 +536,7 @@ async fn update_profile(
if let Some(tags) = request.tags {
if profile_manager
.update_profile_tags(&state.app_handle, &name, tags)
.update_profile_tags(&state.app_handle, &id, tags)
.is_err()
{
return Err(StatusCode::BAD_REQUEST);
@@ -480,7 +551,7 @@ async fn update_profile(
}
// Return updated profile
get_profile(Path(name), State(state)).await
get_profile(Path(id), State(state)).await
}
async fn delete_profile(
@@ -710,3 +781,96 @@ async fn delete_proxy(
Err(_) => Err(StatusCode::BAD_REQUEST),
}
}
// API Handler - Run Profile with Remote Debugging
async fn run_profile(
Path(id): Path<String>,
Query(params): Query<HashMap<String, String>>,
State(state): State<ApiServerState>,
) -> Result<Json<RunProfileResponse>, StatusCode> {
let headless = params
.get("headless")
.and_then(|v| v.parse::<bool>().ok())
.unwrap_or(false);
let profile_manager = ProfileManager::instance();
let profiles = profile_manager
.list_profiles()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let profile = profiles
.iter()
.find(|p| p.id.to_string() == id)
.ok_or(StatusCode::NOT_FOUND)?;
// Generate a random port for remote debugging
let remote_debugging_port = rand::random::<u16>().saturating_add(9000).max(9000);
// Use the same launch method as the main app, but with remote debugging enabled
match crate::browser_runner::launch_browser_profile_with_debugging(
state.app_handle.clone(),
profile.clone(),
None,
Some(remote_debugging_port),
headless,
)
.await
{
Ok(updated_profile) => Ok(Json(RunProfileResponse {
profile_id: updated_profile.id.to_string(),
remote_debugging_port,
headless,
})),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
// API Handler - Download Browser
async fn download_browser_api(
State(state): State<ApiServerState>,
Json(request): Json<DownloadBrowserRequest>,
) -> Result<Json<DownloadBrowserResponse>, StatusCode> {
let browser_runner = crate::browser_runner::BrowserRunner::instance();
match browser_runner
.download_browser_impl(
state.app_handle.clone(),
request.browser.clone(),
request.version.clone(),
)
.await
{
Ok(_) => Ok(Json(DownloadBrowserResponse {
browser: request.browser,
version: request.version,
status: "downloaded".to_string(),
})),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
// API Handler - Get Browser Versions
async fn get_browser_versions(
Path(browser): Path<String>,
State(_state): State<ApiServerState>,
) -> Result<Json<Vec<String>>, StatusCode> {
let version_manager = crate::browser_version_manager::BrowserVersionManager::instance();
match version_manager
.fetch_browser_versions_with_count(&browser, false)
.await
{
Ok(result) => Ok(Json(result.versions)),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
// API Handler - Check if Browser is Downloaded
async fn check_browser_downloaded(
Path((browser, version)): Path<(String, String)>,
State(_state): State<ApiServerState>,
) -> Result<Json<bool>, StatusCode> {
let browser_runner = crate::browser_runner::BrowserRunner::instance();
let is_downloaded = browser_runner.is_browser_downloaded(&browser, &version);
Ok(Json(is_downloaded))
}
+110 -15
View File
@@ -58,6 +58,8 @@ pub trait Browser: Send + Sync {
profile_path: &str,
proxy_settings: Option<&ProxySettings>,
url: Option<String>,
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<Vec<String>, Box<dyn std::error::Error>>;
fn is_version_downloaded(&self, version: &str, binaries_dir: &Path) -> bool;
fn prepare_executable(&self, executable_path: &Path) -> Result<(), Box<dyn std::error::Error>>;
@@ -557,11 +559,23 @@ impl Browser for FirefoxBrowser {
profile_path: &str,
_proxy_settings: Option<&ProxySettings>,
url: Option<String>,
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let mut args = vec!["-profile".to_string(), profile_path.to_string()];
// Only use -no-remote for browsers that require it for security (Mullvad, Tor)
// Regular Firefox browsers can use remote commands for better URL handling
// Add remote debugging if requested
if let Some(port) = remote_debugging_port {
args.push("--start-debugger-server".to_string());
args.push(port.to_string());
}
// Add headless mode if requested
if headless {
args.push("--headless".to_string());
}
// Use -no-remote for browsers that require it for security (Mullvad, Tor) or when remote debugging
match self.browser_type {
BrowserType::MullvadBrowser | BrowserType::TorBrowser => {
args.push("-no-remote".to_string());
@@ -570,7 +584,11 @@ impl Browser for FirefoxBrowser {
| BrowserType::FirefoxDeveloper
| BrowserType::Zen
| BrowserType::Camoufox => {
// Don't use -no-remote so we can communicate with existing instances
// Use -no-remote when remote debugging to avoid conflicts
if remote_debugging_port.is_some() {
args.push("-no-remote".to_string());
}
// Don't use -no-remote for normal launches so we can communicate with existing instances
}
_ => {}
}
@@ -659,6 +677,8 @@ impl Browser for ChromiumBrowser {
profile_path: &str,
proxy_settings: Option<&ProxySettings>,
url: Option<String>,
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let mut args = vec![
format!("--user-data-dir={}", profile_path),
@@ -670,9 +690,19 @@ impl Browser for ChromiumBrowser {
"--disable-updater".to_string(),
];
// Add remote debugging if requested
if let Some(port) = remote_debugging_port {
args.push("--remote-debugging-address=0.0.0.0".to_string());
args.push(format!("--remote-debugging-port={port}"));
}
// Add headless mode if requested
if headless {
args.push("--headless".to_string());
}
// Add proxy configuration if provided
if let Some(proxy) = proxy_settings {
// Apply proxy settings
args.push(format!(
"--proxy-server=http://{}:{}",
proxy.host, proxy.port
@@ -758,6 +788,8 @@ impl Browser for CamoufoxBrowser {
profile_path: &str,
_proxy_settings: Option<&ProxySettings>,
url: Option<String>,
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
// For Camoufox, we handle launching through the camoufox launcher
// This method won't be used directly, but we provide basic Firefox args as fallback
@@ -767,6 +799,17 @@ impl Browser for CamoufoxBrowser {
"-no-remote".to_string(),
];
// Add remote debugging if requested
if let Some(port) = remote_debugging_port {
args.push("--start-debugger-server".to_string());
args.push(port.to_string());
}
// Add headless mode if requested
if headless {
args.push("--headless".to_string());
}
if let Some(url) = url {
args.push(url);
}
@@ -962,15 +1005,15 @@ mod tests {
#[test]
fn test_firefox_launch_args() {
// Test regular Firefox (should not use -no-remote)
// Test regular Firefox (should not use -no-remote for normal launch)
let browser = FirefoxBrowser::new(BrowserType::Firefox);
let args = browser
.create_launch_args("/path/to/profile", None, None)
.create_launch_args("/path/to/profile", None, None, None, false)
.expect("Failed to create launch args for Firefox");
assert_eq!(args, vec!["-profile", "/path/to/profile"]);
assert!(
!args.contains(&"-no-remote".to_string()),
"Firefox should not use -no-remote"
"Firefox should not use -no-remote for normal launch"
);
let args = browser
@@ -978,6 +1021,8 @@ mod tests {
"/path/to/profile",
None,
Some("https://example.com".to_string()),
None,
false,
)
.expect("Failed to create launch args for Firefox with URL");
assert_eq!(
@@ -985,29 +1030,55 @@ mod tests {
vec!["-profile", "/path/to/profile", "https://example.com"]
);
// Test Mullvad Browser (should use -no-remote)
// Test Firefox with remote debugging (should use -no-remote)
let args = browser
.create_launch_args("/path/to/profile", None, None, Some(9222), false)
.expect("Failed to create launch args for Firefox with remote debugging");
assert!(
args.contains(&"-no-remote".to_string()),
"Firefox should use -no-remote for remote debugging"
);
assert!(
args.contains(&"--start-debugger-server".to_string()),
"Firefox should include debugger server arg"
);
assert!(
args.contains(&"9222".to_string()),
"Firefox should include debugging port"
);
// Test Mullvad Browser (should always use -no-remote)
let browser = FirefoxBrowser::new(BrowserType::MullvadBrowser);
let args = browser
.create_launch_args("/path/to/profile", None, None)
.create_launch_args("/path/to/profile", None, None, None, false)
.expect("Failed to create launch args for Mullvad Browser");
assert_eq!(args, vec!["-profile", "/path/to/profile", "-no-remote"]);
// Test Tor Browser (should use -no-remote)
// Test Tor Browser (should always use -no-remote)
let browser = FirefoxBrowser::new(BrowserType::TorBrowser);
let args = browser
.create_launch_args("/path/to/profile", None, None)
.create_launch_args("/path/to/profile", None, None, None, false)
.expect("Failed to create launch args for Tor Browser");
assert_eq!(args, vec!["-profile", "/path/to/profile", "-no-remote"]);
// Test Zen Browser (should not use -no-remote)
// Test Zen Browser (should not use -no-remote for normal launch)
let browser = FirefoxBrowser::new(BrowserType::Zen);
let args = browser
.create_launch_args("/path/to/profile", None, None)
.create_launch_args("/path/to/profile", None, None, None, false)
.expect("Failed to create launch args for Zen Browser");
assert_eq!(args, vec!["-profile", "/path/to/profile"]);
assert!(
!args.contains(&"-no-remote".to_string()),
"Zen Browser should not use -no-remote"
"Zen Browser should not use -no-remote for normal launch"
);
// Test headless mode
let args = browser
.create_launch_args("/path/to/profile", None, None, None, true)
.expect("Failed to create launch args for Zen Browser headless");
assert!(
args.contains(&"--headless".to_string()),
"Browser should include headless flag when requested"
);
}
@@ -1015,7 +1086,7 @@ mod tests {
fn test_chromium_launch_args() {
let browser = ChromiumBrowser::new(BrowserType::Chromium);
let args = browser
.create_launch_args("/path/to/profile", None, None)
.create_launch_args("/path/to/profile", None, None, None, false)
.expect("Failed to create launch args for Chromium");
// Test that basic required arguments are present
@@ -1043,6 +1114,8 @@ mod tests {
"/path/to/profile",
None,
Some("https://example.com".to_string()),
None,
false,
)
.expect("Failed to create launch args for Chromium with URL");
assert!(
@@ -1055,6 +1128,28 @@ mod tests {
args_with_url.last().expect("Args should not be empty"),
"https://example.com"
);
// Test remote debugging
let args_with_debug = browser
.create_launch_args("/path/to/profile", None, None, Some(9222), false)
.expect("Failed to create launch args for Chromium with remote debugging");
assert!(
args_with_debug.contains(&"--remote-debugging-port=9222".to_string()),
"Chromium args should contain remote debugging port"
);
assert!(
args_with_debug.contains(&"--remote-debugging-address=0.0.0.0".to_string()),
"Chromium args should contain remote debugging address"
);
// Test headless mode
let args_headless = browser
.create_launch_args("/path/to/profile", None, None, None, true)
.expect("Failed to create launch args for Chromium headless");
assert!(
args_headless.contains(&"--headless".to_string()),
"Chromium args should contain headless flag when requested"
);
}
#[test]
+121 -9
View File
@@ -161,6 +161,20 @@ impl BrowserRunner {
profile: &BrowserProfile,
url: Option<String>,
local_proxy_settings: Option<&ProxySettings>,
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
self
.launch_browser_internal(app_handle, profile, url, local_proxy_settings, None, false)
.await
}
async fn launch_browser_internal(
&self,
app_handle: tauri::AppHandle,
profile: &BrowserProfile,
url: Option<String>,
local_proxy_settings: Option<&ProxySettings>,
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
// Check if browser is disabled due to ongoing update
let auto_updater = crate::auto_updater::AutoUpdater::instance();
@@ -336,6 +350,8 @@ impl BrowserRunner {
&profile_data_path.to_string_lossy(),
proxy_for_launch_args,
url,
remote_debugging_port,
headless,
)
.expect("Failed to create launch arguments");
@@ -774,6 +790,86 @@ impl BrowserRunner {
}
}
pub async fn launch_browser_with_debugging(
&self,
app_handle: tauri::AppHandle,
profile: &BrowserProfile,
url: Option<String>,
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
// Always start a local proxy for API launches
let mut internal_proxy_settings: Option<ProxySettings> = None;
// Determine upstream proxy if configured; otherwise use DIRECT
let upstream_proxy = profile
.proxy_id
.as_ref()
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id));
// Use a temporary PID (1) to start the proxy, we'll update it after browser launch
let temp_pid = 1u32;
match PROXY_MANAGER
.start_proxy(
app_handle.clone(),
upstream_proxy.as_ref(),
temp_pid,
Some(&profile.name),
)
.await
{
Ok(internal_proxy) => {
internal_proxy_settings = Some(internal_proxy.clone());
// For Firefox-based browsers, apply PAC/user.js to point to the local proxy
if matches!(
profile.browser.as_str(),
"firefox" | "firefox-developer" | "zen" | "tor-browser" | "mullvad-browser"
) {
let profiles_dir = self.get_profiles_dir();
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
// Provide a dummy upstream (ignored when internal proxy is provided)
let dummy_upstream = ProxySettings {
proxy_type: "http".to_string(),
host: "127.0.0.1".to_string(),
port: internal_proxy.port,
username: None,
password: None,
};
self
.apply_proxy_settings_to_profile(&profile_path, &dummy_upstream, Some(&internal_proxy))
.map_err(|e| format!("Failed to update profile proxy: {e}"))?;
}
}
Err(e) => {
eprintln!("Failed to start local proxy (will launch without it): {e}");
}
}
let result = self
.launch_browser_internal(
app_handle.clone(),
profile,
url,
internal_proxy_settings.as_ref(),
remote_debugging_port,
headless,
)
.await;
// Update proxy with correct PID if launch succeeded
if let Ok(ref updated_profile) = result {
if let Some(actual_pid) = updated_profile.process_id {
let _ = PROXY_MANAGER.update_proxy_pid(temp_pid, actual_pid);
}
}
result
}
pub async fn launch_or_open_url(
&self,
app_handle: tauri::AppHandle,
@@ -863,7 +959,7 @@ impl BrowserRunner {
_ => {
println!("Falling back to new instance for browser: {}", final_profile.browser);
// Fallback to launching a new instance for other browsers
self.launch_browser(app_handle.clone(), &final_profile, url, internal_proxy_settings).await
self.launch_browser_internal(app_handle.clone(), &final_profile, url, internal_proxy_settings, None, false).await
}
}
}
@@ -888,11 +984,13 @@ impl BrowserRunner {
println!("Launching new browser instance - no URL provided");
}
self
.launch_browser(
.launch_browser_internal(
app_handle.clone(),
&final_profile,
url,
internal_proxy_settings,
None,
false,
)
.await
}
@@ -1966,12 +2064,12 @@ pub async fn launch_browser_profile(
#[tauri::command]
pub async fn update_profile_proxy(
app_handle: tauri::AppHandle,
profile_name: String,
profile_id: String,
proxy_id: Option<String>,
) -> Result<BrowserProfile, String> {
let profile_manager = ProfileManager::instance();
profile_manager
.update_profile_proxy(app_handle, &profile_name, proxy_id)
.update_profile_proxy(app_handle, &profile_id, proxy_id)
.await
.map_err(|e| format!("Failed to update profile: {e}"))
}
@@ -1979,12 +2077,12 @@ pub async fn update_profile_proxy(
#[tauri::command]
pub fn update_profile_tags(
app_handle: tauri::AppHandle,
profile_name: String,
profile_id: String,
tags: Vec<String>,
) -> Result<BrowserProfile, String> {
let profile_manager = ProfileManager::instance();
profile_manager
.update_profile_tags(&app_handle, &profile_name, tags)
.update_profile_tags(&app_handle, &profile_id, tags)
.map_err(|e| format!("Failed to update profile tags: {e}"))
}
@@ -2003,12 +2101,12 @@ pub async fn check_browser_status(
#[tauri::command]
pub fn rename_profile(
app_handle: tauri::AppHandle,
old_name: &str,
new_name: &str,
profile_id: String,
new_name: String,
) -> Result<BrowserProfile, String> {
let profile_manager = ProfileManager::instance();
profile_manager
.rename_profile(&app_handle, old_name, new_name)
.rename_profile(&app_handle, &profile_id, &new_name)
.map_err(|e| format!("Failed to rename profile: {e}"))
}
@@ -2272,6 +2370,20 @@ pub async fn ensure_all_binaries_exist(
.map_err(|e| format!("Failed to ensure all binaries exist: {e}"))
}
pub async fn launch_browser_profile_with_debugging(
app_handle: tauri::AppHandle,
profile: BrowserProfile,
url: Option<String>,
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<BrowserProfile, String> {
let browser_runner = BrowserRunner::instance();
browser_runner
.launch_browser_with_debugging(app_handle, &profile, url, remote_debugging_port, headless)
.await
.map_err(|e| format!("Failed to launch browser with debugging: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
+8 -8
View File
@@ -42,7 +42,7 @@ impl DefaultBrowser {
pub async fn open_url_with_profile(
&self,
app_handle: tauri::AppHandle,
profile_name: String,
profile_id: String,
url: String,
) -> Result<(), String> {
let runner = crate::browser_runner::BrowserRunner::instance();
@@ -53,21 +53,21 @@ impl DefaultBrowser {
.map_err(|e| format!("Failed to list profiles: {e}"))?;
let profile = profiles
.into_iter()
.find(|p| p.name == profile_name)
.ok_or_else(|| format!("Profile '{profile_name}' not found"))?;
.find(|p| p.id.to_string() == profile_id)
.ok_or_else(|| format!("Profile '{profile_id}' not found"))?;
println!("Opening URL '{url}' with profile '{profile_name}'");
println!("Opening URL '{url}' with profile '{profile_id}'");
// Use launch_or_open_url which handles both launching new instances and opening in existing ones
runner
.launch_or_open_url(app_handle, &profile, Some(url.clone()), None)
.await
.map_err(|e| {
println!("Failed to open URL with profile '{profile_name}': {e}");
println!("Failed to open URL with profile '{profile_id}': {e}");
format!("Failed to open URL with profile: {e}")
})?;
println!("Successfully opened URL '{url}' with profile '{profile_name}'");
println!("Successfully opened URL '{url}' with profile '{profile_id}'");
Ok(())
}
}
@@ -574,11 +574,11 @@ pub async fn set_as_default_browser() -> Result<(), String> {
#[tauri::command]
pub async fn open_url_with_profile(
app_handle: tauri::AppHandle,
profile_name: String,
profile_id: String,
url: String,
) -> Result<(), String> {
let default_browser = DefaultBrowser::instance();
default_browser
.open_url_with_profile(app_handle, profile_name, url)
.open_url_with_profile(app_handle, profile_id, url)
.await
}
+4 -4
View File
@@ -293,22 +293,22 @@ pub async fn delete_profile_group(
#[tauri::command]
pub async fn assign_profiles_to_group(
app_handle: tauri::AppHandle,
profile_names: Vec<String>,
profile_ids: Vec<String>,
group_id: Option<String>,
) -> Result<(), String> {
let profile_manager = crate::profile::ProfileManager::instance();
profile_manager
.assign_profiles_to_group(&app_handle, profile_names, group_id)
.assign_profiles_to_group(&app_handle, profile_ids, group_id)
.map_err(|e| format!("Failed to assign profiles to group: {e}"))
}
#[tauri::command]
pub async fn delete_selected_profiles(
app_handle: tauri::AppHandle,
profile_names: Vec<String>,
profile_ids: Vec<String>,
) -> Result<(), String> {
let profile_manager = crate::profile::ProfileManager::instance();
profile_manager
.delete_multiple_profiles(&app_handle, profile_names)
.delete_multiple_profiles(&app_handle, profile_ids)
.map_err(|e| format!("Failed to delete profiles: {e}"))
}
+1 -1
View File
@@ -573,7 +573,7 @@ pub fn run() {
// 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 {
match crate::settings_manager::get_app_settings(app_handle_api.clone()).await {
Ok(settings) => {
if settings.api_enabled {
println!("API is enabled in settings, starting API server...");
+67 -38
View File
@@ -268,7 +268,7 @@ impl ProfileManager {
pub fn rename_profile(
&self,
app_handle: &tauri::AppHandle,
old_name: &str,
profile_id: &str,
new_name: &str,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
// Check if new name already exists (case insensitive)
@@ -280,11 +280,13 @@ impl ProfileManager {
return Err(format!("Profile with name '{new_name}' already exists").into());
}
// Find the profile by old name
// Find the profile by ID
let profile_uuid =
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
let mut profile = existing_profiles
.into_iter()
.find(|p| p.name == old_name)
.ok_or_else(|| format!("Profile '{old_name}' not found"))?;
.find(|p| p.id == profile_uuid)
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
// Update profile name (no need to move directories since we use UUID)
profile.name = new_name.to_string();
@@ -308,16 +310,18 @@ impl ProfileManager {
pub fn delete_profile(
&self,
app_handle: &tauri::AppHandle,
profile_name: &str,
profile_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
println!("Attempting to delete profile: {profile_name}");
println!("Attempting to delete profile with ID: {profile_id}");
// Find the profile by name
// Find the profile by ID
let profile_uuid =
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
let profiles = self.list_profiles()?;
let profile = profiles
.into_iter()
.find(|p| p.name == profile_name)
.ok_or_else(|| format!("Profile '{profile_name}' not found"))?;
.find(|p| p.id == profile_uuid)
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
// Check if browser is running
if profile.process_id.is_some() {
@@ -338,10 +342,13 @@ impl ProfileManager {
// Verify deletion was successful
if profile_uuid_dir.exists() {
return Err(format!("Failed to completely delete profile '{profile_name}'").into());
return Err(format!("Failed to completely delete profile '{}'", profile.name).into());
}
println!("Profile '{profile_name}' deleted successfully");
println!(
"Profile '{}' (ID: {}) deleted successfully",
profile.name, profile_id
);
// Rebuild tag suggestions after deletion
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
@@ -359,15 +366,17 @@ impl ProfileManager {
pub fn update_profile_version(
&self,
app_handle: &tauri::AppHandle,
profile_name: &str,
profile_id: &str,
version: &str,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
// Find the profile by name
// Find the profile by ID
let profile_uuid =
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
let profiles = self.list_profiles()?;
let mut profile = profiles
.into_iter()
.find(|p| p.name == profile_name)
.ok_or_else(|| format!("Profile {profile_name} not found"))?;
.find(|p| p.id == profile_uuid)
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
// Check if the browser is currently running
if profile.process_id.is_some() {
@@ -411,22 +420,24 @@ impl ProfileManager {
pub fn assign_profiles_to_group(
&self,
app_handle: &tauri::AppHandle,
profile_names: Vec<String>,
profile_ids: Vec<String>,
group_id: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let profiles = self.list_profiles()?;
for profile_name in profile_names {
for profile_id in profile_ids {
let profile_uuid = uuid::Uuid::parse_str(&profile_id)
.map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
let mut profile = profiles
.iter()
.find(|p| p.name == profile_name)
.ok_or_else(|| format!("Profile '{profile_name}' not found"))?
.find(|p| p.id == profile_uuid)
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?
.clone();
// Check if browser is running
if profile.process_id.is_some() {
return Err(format!(
"Cannot modify group for profile '{profile_name}' while browser is running. Please stop the browser first."
"Cannot modify group for profile '{}' while browser is running. Please stop the browser first.", profile.name
).into());
}
@@ -450,15 +461,17 @@ impl ProfileManager {
pub fn update_profile_tags(
&self,
app_handle: &tauri::AppHandle,
profile_name: &str,
profile_id: &str,
tags: Vec<String>,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
// Find the profile by name
// Find the profile by ID
let profile_uuid =
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
let profiles = self.list_profiles()?;
let mut profile = profiles
.into_iter()
.find(|p| p.name == profile_name)
.ok_or_else(|| format!("Profile {profile_name} not found"))?;
.find(|p| p.id == profile_uuid)
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
let mut seen = std::collections::HashSet::new();
let mut deduped: Vec<String> = Vec::with_capacity(tags.len());
@@ -488,21 +501,24 @@ impl ProfileManager {
pub fn delete_multiple_profiles(
&self,
app_handle: &tauri::AppHandle,
profile_names: Vec<String>,
profile_ids: Vec<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let profiles = self.list_profiles()?;
for profile_name in profile_names {
for profile_id in profile_ids {
let profile_uuid = uuid::Uuid::parse_str(&profile_id)
.map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
let profile = profiles
.iter()
.find(|p| p.name == profile_name)
.ok_or_else(|| format!("Profile '{profile_name}' not found"))?;
.find(|p| p.id == profile_uuid)
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
// Check if browser is running
if profile.process_id.is_some() {
return Err(
format!(
"Cannot delete profile '{profile_name}' while browser is running. Please stop the browser first."
"Cannot delete profile '{}' while browser is running. Please stop the browser first.",
profile.name
)
.into(),
);
@@ -528,10 +544,15 @@ impl ProfileManager {
pub async fn update_camoufox_config(
&self,
app_handle: tauri::AppHandle,
profile_name: &str,
profile_id: &str,
config: CamoufoxConfig,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Find the profile by name
// Find the profile by ID
let profile_uuid = uuid::Uuid::parse_str(profile_id).map_err(
|_| -> Box<dyn std::error::Error + Send + Sync> {
format!("Invalid profile ID: {profile_id}").into()
},
)?;
let profiles =
self
.list_profiles()
@@ -540,9 +561,9 @@ impl ProfileManager {
})?;
let mut profile = profiles
.into_iter()
.find(|p| p.name == profile_name)
.find(|p| p.id == profile_uuid)
.ok_or_else(|| -> Box<dyn std::error::Error + Send + Sync> {
format!("Profile {profile_name} not found").into()
format!("Profile with ID '{profile_id}' not found").into()
})?;
// Check if the browser is currently running using the comprehensive status check
@@ -566,7 +587,10 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into()
})?;
println!("Camoufox configuration updated for profile '{profile_name}'.");
println!(
"Camoufox configuration updated for profile '{}' (ID: {}).",
profile.name, profile_id
);
// Emit profile config update event
if let Err(e) = app_handle.emit("profiles-changed", ()) {
@@ -579,10 +603,15 @@ impl ProfileManager {
pub async fn update_profile_proxy(
&self,
app_handle: tauri::AppHandle,
profile_name: &str,
profile_id: &str,
proxy_id: Option<String>,
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
// Find the profile by name
// Find the profile by ID
let profile_uuid = uuid::Uuid::parse_str(profile_id).map_err(
|_| -> Box<dyn std::error::Error + Send + Sync> {
format!("Invalid profile ID: {profile_id}").into()
},
)?;
let profiles =
self
.list_profiles()
@@ -592,9 +621,9 @@ impl ProfileManager {
let mut profile = profiles
.into_iter()
.find(|p| p.name == profile_name)
.find(|p| p.id == profile_uuid)
.ok_or_else(|| -> Box<dyn std::error::Error + Send + Sync> {
format!("Profile {profile_name} not found").into()
format!("Profile with ID '{profile_id}' not found").into()
})?;
// Update proxy settings
+4 -1
View File
@@ -181,7 +181,10 @@ impl ProxyManager {
// Get all stored proxies
pub fn get_stored_proxies(&self) -> Vec<StoredProxy> {
let stored_proxies = self.stored_proxies.lock().unwrap();
stored_proxies.values().cloned().collect()
let mut list: Vec<StoredProxy> = stored_proxies.values().cloned().collect();
// Sort case-insensitively by name for consistent ordering across UI/API consumers
list.sort_by_key(|p| p.name.to_lowercase());
list
}
// Get a stored proxy by ID
+243 -6
View File
@@ -6,6 +6,12 @@ use std::path::PathBuf;
use crate::api_client::ApiClient;
use crate::version_updater;
use aes_gcm::{
aead::{Aead, AeadCore, KeyInit, OsRng},
Aes256Gcm, Key, Nonce,
};
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TableSortingSettings {
pub column: String, // Column to sort by: "name", "browser", "status"
@@ -33,6 +39,8 @@ pub struct AppSettings {
pub api_enabled: bool,
#[serde(default = "default_api_port")]
pub api_port: u16,
#[serde(default)]
pub api_token: Option<String>, // Displayed token for user to copy
}
fn default_theme() -> String {
@@ -51,6 +59,7 @@ impl Default for AppSettings {
custom_theme: None,
api_enabled: false,
api_port: 10108,
api_token: None,
}
}
}
@@ -164,22 +173,249 @@ impl SettingsManager {
// Always return false - we don't show settings on startup anymore
Ok(false)
}
fn get_vault_password() -> String {
env!("DONUT_BROWSER_VAULT_PASSWORD").to_string()
}
pub async fn generate_api_token(
&self,
app_handle: &tauri::AppHandle,
) -> Result<String, Box<dyn std::error::Error>> {
// Generate a secure random token (base64 encoded for URL safety)
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);
// Store token securely
self.store_api_token(app_handle, &token).await?;
Ok(token)
}
pub async fn store_api_token(
&self,
_app_handle: &tauri::AppHandle,
token: &str,
) -> Result<(), Box<dyn std::error::Error>> {
// Store token in an encrypted file using Argon2 + AES-GCM
let token_file = self.get_settings_dir().join("api_token.dat");
// Create directory if it doesn't exist
if let Some(parent) = token_file.parent() {
std::fs::create_dir_all(parent)?;
}
let vault_password = Self::get_vault_password();
// Generate a random salt for Argon2
let salt = SaltString::generate(&mut OsRng);
// Use Argon2 to derive a 32-byte key from the 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();
// Take first 32 bytes for AES-256 key
let key = Key::<Aes256Gcm>::from_slice(&hash_bytes[..32]);
let cipher = Aes256Gcm::new(key);
// Generate a random nonce
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
// Encrypt the token
let ciphertext = cipher
.encrypt(&nonce, token.as_bytes())
.map_err(|e| format!("Encryption failed: {e}"))?;
// Create file data with header, salt, nonce, and encrypted data
let mut file_data = Vec::new();
file_data.extend_from_slice(b"DBAPI"); // 5-byte header
file_data.push(2u8); // Version 2 (Argon2 + AES-GCM)
// Store salt length and salt
let salt_str = salt.as_str();
file_data.push(salt_str.len() as u8);
file_data.extend_from_slice(salt_str.as_bytes());
// Store nonce (12 bytes for AES-GCM)
file_data.extend_from_slice(&nonce);
// Store ciphertext length and ciphertext
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_api_token(
&self,
_app_handle: &tauri::AppHandle,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
let token_file = self.get_settings_dir().join("api_token.dat");
if !token_file.exists() {
return Ok(None);
}
let file_data = std::fs::read(token_file)?;
// Validate header
if file_data.len() < 6 || &file_data[0..5] != b"DBAPI" {
return Ok(None);
}
let version = file_data[5];
// Only support Argon2 + AES-GCM (version 2)
if version != 2 {
return Ok(None);
}
// Argon2 + AES-GCM decryption
let mut offset = 6;
// Read salt
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;
// Read nonce (12 bytes)
if offset + 12 > file_data.len() {
return Ok(None);
}
let nonce_bytes = &file_data[offset..offset + 12];
let nonce = Nonce::from_slice(nonce_bytes);
offset += 12;
// Read ciphertext
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];
// Derive key using Argon2
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 = Key::<Aes256Gcm>::from_slice(&hash_bytes[..32]);
let cipher = Aes256Gcm::new(key);
// Decrypt the token
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_api_token(
&self,
_app_handle: &tauri::AppHandle,
) -> Result<(), Box<dyn std::error::Error>> {
let token_file = self.get_settings_dir().join("api_token.dat");
if token_file.exists() {
std::fs::remove_file(token_file)?;
}
Ok(())
}
}
#[tauri::command]
pub async fn get_app_settings() -> Result<AppSettings, String> {
pub async fn get_app_settings(app_handle: tauri::AppHandle) -> Result<AppSettings, String> {
let manager = SettingsManager::instance();
manager
let mut settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))
.map_err(|e| format!("Failed to load settings: {e}"))?;
// Always load token for display purposes if it exists
settings.api_token = manager
.get_api_token(&app_handle)
.await
.map_err(|e| format!("Failed to load API token: {e}"))?;
Ok(settings)
}
#[tauri::command]
pub async fn save_app_settings(settings: AppSettings) -> Result<(), String> {
pub async fn save_app_settings(
app_handle: tauri::AppHandle,
mut settings: AppSettings,
) -> Result<AppSettings, String> {
let manager = SettingsManager::instance();
if settings.api_enabled {
if let Some(ref token) = settings.api_token {
manager
.store_api_token(&app_handle, token)
.await
.map_err(|e| format!("Failed to store API token: {e}"))?;
} else {
let token = manager
.generate_api_token(&app_handle)
.await
.map_err(|e| format!("Failed to generate API token: {e}"))?;
settings.api_token = Some(token);
}
}
// If API is being disabled, remove the token
if !settings.api_enabled {
manager
.remove_api_token(&app_handle)
.await
.map_err(|e| format!("Failed to remove API token: {e}"))?;
settings.api_token = None;
}
let mut persist_settings = settings.clone();
persist_settings.api_token = None;
manager
.save_settings(&settings)
.map_err(|e| format!("Failed to save settings: {e}"))
.save_settings(&persist_settings)
.map_err(|e| format!("Failed to save settings: {e}"))?;
Ok(settings)
}
#[tauri::command]
@@ -337,6 +573,7 @@ mod tests {
custom_theme: None,
api_enabled: false,
api_port: 10108,
api_token: None,
};
// Save settings
+1
View File
@@ -25,6 +25,7 @@ impl TagManager {
}
}
// Helper for tests to override data directory without global env var
#[allow(dead_code)]
pub fn with_data_dir_override(dir: &Path) -> Self {
Self {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut Browser",
"version": "0.10.1",
"version": "0.11.0",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm dev",
+12 -10
View File
@@ -378,7 +378,7 @@ export default function Home() {
async (profile: BrowserProfile, config: CamoufoxConfig) => {
try {
await invoke("update_camoufox_config", {
profileName: profile.name,
profileId: profile.id,
config,
});
// No need to manually reload - useProfileEvents will handle the update
@@ -464,7 +464,7 @@ export default function Home() {
}
// Attempt to delete the profile
await invoke("delete_profile", { profileName: profile.name });
await invoke("delete_profile", { profileId: profile.id });
console.log("Profile deletion command completed successfully");
// No need to manually reload - useProfileEvents will handle the update
@@ -477,9 +477,9 @@ export default function Home() {
}, []);
const handleRenameProfile = useCallback(
async (oldName: string, newName: string) => {
async (profileId: string, newName: string) => {
try {
await invoke("rename_profile", { oldName, newName });
await invoke("rename_profile", { profileId, newName });
// No need to manually reload - useProfileEvents will handle the update
} catch (err: unknown) {
console.error("Failed to rename profile:", err);
@@ -507,9 +507,9 @@ export default function Home() {
}, []);
const handleDeleteSelectedProfiles = useCallback(
async (profileNames: string[]) => {
async (profileIds: string[]) => {
try {
await invoke("delete_selected_profiles", { profileNames });
await invoke("delete_selected_profiles", { profileIds });
// No need to manually reload - useProfileEvents will handle the update
} catch (err: unknown) {
console.error("Failed to delete selected profiles:", err);
@@ -521,8 +521,8 @@ export default function Home() {
[],
);
const handleAssignProfilesToGroup = useCallback((profileNames: string[]) => {
setSelectedProfilesForGroup(profileNames);
const handleAssignProfilesToGroup = useCallback((profileIds: string[]) => {
setSelectedProfilesForGroup(profileIds);
setGroupAssignmentDialogOpen(true);
}, []);
@@ -537,7 +537,7 @@ export default function Home() {
setIsBulkDeleting(true);
try {
await invoke("delete_selected_profiles", {
profileNames: selectedProfiles,
profileIds: selectedProfiles,
});
// No need to manually reload - useProfileEvents will handle the update
setSelectedProfiles([]);
@@ -797,6 +797,7 @@ export default function Home() {
}}
selectedProfiles={selectedProfilesForGroup}
onAssignmentComplete={handleGroupAssignmentComplete}
profiles={profiles}
/>
<DeleteConfirmationDialog
@@ -807,7 +808,8 @@ export default function Home() {
description={`This action cannot be undone. This will permanently delete ${selectedProfiles.length} profile${selectedProfiles.length !== 1 ? "s" : ""} and all associated data.`}
confirmButtonText={`Delete ${selectedProfiles.length} Profile${selectedProfiles.length !== 1 ? "s" : ""}`}
isLoading={isBulkDeleting}
profileNames={selectedProfiles}
profileIds={selectedProfiles}
profiles={profiles.map((p) => ({ id: p.id, name: p.name }))}
/>
</div>
);
+1 -1
View File
@@ -107,7 +107,7 @@ export function CamoufoxConfigDialog({
</DialogTitle>
</DialogHeader>
<ScrollArea className="flex-1 h-[400px]">
<ScrollArea className="flex-1 h-[320px]">
<div className="py-4">
<SharedCamoufoxConfigForm
config={config}
+14 -8
View File
@@ -19,7 +19,8 @@ interface DeleteConfirmationDialogProps {
description: string;
confirmButtonText?: string;
isLoading?: boolean;
profileNames?: string[];
profileIds?: string[];
profiles?: { id: string; name: string }[];
}
export function DeleteConfirmationDialog({
@@ -30,7 +31,8 @@ export function DeleteConfirmationDialog({
description,
confirmButtonText = "Delete",
isLoading = false,
profileNames,
profileIds,
profiles = [],
}: DeleteConfirmationDialogProps) {
const handleConfirm = async () => {
await onConfirm();
@@ -42,18 +44,22 @@ export function DeleteConfirmationDialog({
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
{profileNames && profileNames.length > 0 && (
{profileIds && profileIds.length > 0 && (
<div className="mt-4">
<p className="text-sm font-medium mb-2">
Profiles to be deleted:
</p>
<div className="bg-muted rounded-md p-3 max-h-32 overflow-y-auto">
<ul className="space-y-1">
{profileNames.map((name) => (
<li key={name} className="text-sm text-muted-foreground">
{name}
</li>
))}
{profileIds.map((id) => {
const profile = profiles.find((p) => p.id === id);
const displayName = profile ? profile.name : id;
return (
<li key={id} className="text-sm text-muted-foreground">
{displayName}
</li>
);
})}
</ul>
</div>
</div>
+4 -4
View File
@@ -74,13 +74,13 @@ export function DeleteGroupDialog({
try {
if (deleteAction === "delete" && associatedProfiles.length > 0) {
// Delete all associated profiles first
const profileNames = associatedProfiles.map((p) => p.name);
await invoke("delete_selected_profiles", { profileNames });
const profileIds = associatedProfiles.map((p) => p.id);
await invoke("delete_selected_profiles", { profileIds });
} else if (deleteAction === "move" && associatedProfiles.length > 0) {
// Move profiles to default group (null group_id)
const profileNames = associatedProfiles.map((p) => p.name);
const profileIds = associatedProfiles.map((p) => p.id);
await invoke("assign_profiles_to_group", {
profileNames,
profileIds,
groupId: null,
});
}
+16 -7
View File
@@ -22,7 +22,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { ProfileGroup } from "@/types";
import type { BrowserProfile, ProfileGroup } from "@/types";
import { RippleButton } from "./ui/ripple";
interface GroupAssignmentDialogProps {
@@ -30,6 +30,7 @@ interface GroupAssignmentDialogProps {
onClose: () => void;
selectedProfiles: string[];
onAssignmentComplete: () => void;
profiles?: BrowserProfile[];
}
export function GroupAssignmentDialog({
@@ -37,6 +38,7 @@ export function GroupAssignmentDialog({
onClose,
selectedProfiles,
onAssignmentComplete,
profiles = [],
}: GroupAssignmentDialogProps) {
const [groups, setGroups] = useState<ProfileGroup[]>([]);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
@@ -64,7 +66,7 @@ export function GroupAssignmentDialog({
setError(null);
try {
await invoke("assign_profiles_to_group", {
profileNames: selectedProfiles,
profileIds: selectedProfiles,
groupId: selectedGroupId,
});
@@ -119,11 +121,18 @@ export function GroupAssignmentDialog({
<Label>Selected Profiles:</Label>
<div className="p-3 bg-muted rounded-md max-h-32 overflow-y-auto">
<ul className="text-sm space-y-1">
{selectedProfiles.map((profileName) => (
<li key={profileName} className="truncate">
{profileName}
</li>
))}
{selectedProfiles.map((profileId) => {
// Find the profile name for display
const profile = profiles.find(
(p: BrowserProfile) => p.id === profileId,
);
const displayName = profile ? profile.name : profileId;
return (
<li key={profileId} className="truncate">
{displayName}
</li>
);
})}
</ul>
</div>
</div>
+60 -53
View File
@@ -96,15 +96,15 @@ type TableMeta = {
proxyOverrides: Record<string, string | null>;
storedProxies: StoredProxy[];
handleProxySelection: (
profileName: string,
profileId: string,
proxyId: string | null,
) => void | Promise<void>;
// Selection helpers
isProfileSelected: (name: string) => boolean;
isProfileSelected: (id: string) => boolean;
handleToggleAll: (checked: boolean) => void;
handleCheckboxChange: (name: string, checked: boolean) => void;
handleIconClick: (name: string) => void;
handleCheckboxChange: (id: string, checked: boolean) => void;
handleIconClick: (id: string) => void;
// Rename helpers
handleRename: () => void | Promise<void>;
@@ -125,7 +125,7 @@ type TableMeta = {
onLaunchProfile: (profile: BrowserProfile) => void | Promise<void>;
// Overflow actions
onAssignProfilesToGroup?: (profileNames: string[]) => void;
onAssignProfilesToGroup?: (profileIds: string[]) => void;
onConfigureCamoufox?: (profile: BrowserProfile) => void;
};
@@ -151,8 +151,8 @@ const TagsCell = React.memo<{
setOpenTagsEditorFor,
setTagsOverrides,
}) => {
const effectiveTags: string[] = Object.hasOwn(tagsOverrides, profile.name)
? tagsOverrides[profile.name]
const effectiveTags: string[] = Object.hasOwn(tagsOverrides, profile.id)
? tagsOverrides[profile.id]
: (profile.tags ?? []);
const valueOptions: Option[] = React.useMemo(
@@ -164,10 +164,9 @@ const TagsCell = React.memo<{
[allTags],
);
const handleChange = React.useCallback(
async (opts: Option[]) => {
const newTagsRaw = opts.map((o) => o.value);
// Dedupe while preserving order
const onTagsChange = React.useCallback(
async (newTagsRaw: string[]) => {
// Dedupe tags
const seen = new Set<string>();
const newTags: string[] = [];
for (const t of newTagsRaw) {
@@ -176,10 +175,10 @@ const TagsCell = React.memo<{
newTags.push(t);
}
}
setTagsOverrides((prev) => ({ ...prev, [profile.name]: newTags }));
setTagsOverrides((prev) => ({ ...prev, [profile.id]: newTags }));
try {
await invoke<BrowserProfile>("update_profile_tags", {
profileName: profile.name,
profileId: profile.id,
tags: newTags,
});
setAllTags((prev) => {
@@ -191,7 +190,15 @@ const TagsCell = React.memo<{
console.error("Failed to update tags:", error);
}
},
[profile.name, setAllTags, setTagsOverrides],
[profile.id, setTagsOverrides, setAllTags],
);
const handleChange = React.useCallback(
async (opts: Option[]) => {
const newTagsRaw = opts.map((o) => o.value);
await onTagsChange(newTagsRaw);
},
[onTagsChange],
);
const containerRef = React.useRef<HTMLDivElement | null>(null);
@@ -202,7 +209,7 @@ const TagsCell = React.memo<{
React.useLayoutEffect(() => {
// Only measure when not editing this profile's tags
if (openTagsEditorFor === profile.name) return;
if (openTagsEditorFor === profile.id) return;
const container = containerRef.current;
if (!container) return;
@@ -253,10 +260,10 @@ const TagsCell = React.memo<{
ro.disconnect();
if (timeoutId) clearTimeout(timeoutId);
};
}, [effectiveTags, openTagsEditorFor, profile.name]);
}, [effectiveTags, openTagsEditorFor, profile.id]);
React.useEffect(() => {
if (openTagsEditorFor !== profile.name) return;
if (openTagsEditorFor !== profile.id) return;
const handleClick = (e: MouseEvent) => {
const target = e.target as Node | null;
if (
@@ -269,19 +276,19 @@ const TagsCell = React.memo<{
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [openTagsEditorFor, profile.name, setOpenTagsEditorFor]);
}, [openTagsEditorFor, profile.id, setOpenTagsEditorFor]);
React.useEffect(() => {
if (openTagsEditorFor === profile.name && editorRef.current) {
if (openTagsEditorFor === profile.id && editorRef.current) {
// Focus the inner input of MultipleSelector on open
const inputEl = editorRef.current.querySelector("input");
if (inputEl) {
(inputEl as HTMLInputElement).focus();
}
}
}, [openTagsEditorFor, profile.name]);
}, [openTagsEditorFor, profile.id]);
if (openTagsEditorFor !== profile.name) {
if (openTagsEditorFor !== profile.id) {
const hiddenCount = Math.max(0, effectiveTags.length - visibleCount);
const ButtonContent = (
<button
@@ -292,7 +299,7 @@ const TagsCell = React.memo<{
isDisabled ? "opacity-60" : "cursor-pointer hover:bg-accent/50",
)}
onClick={() => {
if (!isDisabled) setOpenTagsEditorFor(profile.name);
if (!isDisabled) setOpenTagsEditorFor(profile.id);
}}
>
{effectiveTags.slice(0, visibleCount).map((t) => (
@@ -372,12 +379,12 @@ interface ProfilesDataTableProps {
onLaunchProfile: (profile: BrowserProfile) => void | Promise<void>;
onKillProfile: (profile: BrowserProfile) => void | Promise<void>;
onDeleteProfile: (profile: BrowserProfile) => void | Promise<void>;
onRenameProfile: (oldName: string, newName: string) => Promise<void>;
onRenameProfile: (profileId: string, newName: string) => Promise<void>;
onConfigureCamoufox: (profile: BrowserProfile) => void;
runningProfiles: Set<string>;
isUpdating: (browser: string) => boolean;
onDeleteSelectedProfiles: (profileNames: string[]) => Promise<void>;
onAssignProfilesToGroup: (profileNames: string[]) => void;
onDeleteSelectedProfiles: (profileIds: string[]) => Promise<void>;
onAssignProfilesToGroup: (profileIds: string[]) => void;
selectedGroupId: string | null;
selectedProfiles: string[];
onSelectedProfilesChange: Dispatch<SetStateAction<string[]>>;
@@ -441,13 +448,13 @@ export function ProfilesDataTable({
}, []);
const handleProxySelection = React.useCallback(
async (profileName: string, proxyId: string | null) => {
async (profileId: string, proxyId: string | null) => {
try {
await invoke("update_profile_proxy", {
profileName,
profileId,
proxyId,
});
setProxyOverrides((prev) => ({ ...prev, [profileName]: proxyId }));
setProxyOverrides((prev) => ({ ...prev, [profileId]: proxyId }));
// Notify other parts of the app so usage counts and lists refresh
await emit("profile-updated");
} catch (error) {
@@ -527,8 +534,8 @@ export function ProfilesDataTable({
const newSet = new Set(selectedProfiles);
let hasChanges = false;
for (const profileName of selectedProfiles) {
const profile = profiles.find((p) => p.name === profileName);
for (const profileId of selectedProfiles) {
const profile = profiles.find((p) => p.id === profileId);
if (profile) {
const isRunning =
browserState.isClient && runningProfiles.has(profile.id);
@@ -537,7 +544,7 @@ export function ProfilesDataTable({
const isBrowserUpdating = isUpdating(profile.browser);
if (isRunning || isLaunching || isStopping || isBrowserUpdating) {
newSet.delete(profileName);
newSet.delete(profileId);
hasChanges = true;
}
}
@@ -581,7 +588,7 @@ export function ProfilesDataTable({
try {
setIsRenamingSaving(true);
await onRenameProfile(profileToRename.name, newProfileName.trim());
await onRenameProfile(profileToRename.id, newProfileName.trim());
setProfileToRename(null);
setNewProfileName("");
setRenameError(null);
@@ -631,8 +638,8 @@ export function ProfilesDataTable({
// Handle icon/checkbox click
const handleIconClick = React.useCallback(
(profileName: string) => {
const profile = profiles.find((p) => p.name === profileName);
(profileId: string) => {
const profile = profiles.find((p) => p.id === profileId);
if (!profile) return;
// Prevent selection of profiles whose browsers are updating
@@ -642,10 +649,10 @@ export function ProfilesDataTable({
setShowCheckboxes(true);
const newSet = new Set(selectedProfiles);
if (newSet.has(profileName)) {
newSet.delete(profileName);
if (newSet.has(profileId)) {
newSet.delete(profileId);
} else {
newSet.add(profileName);
newSet.add(profileId);
}
// Hide checkboxes if no profiles are selected
@@ -671,12 +678,12 @@ export function ProfilesDataTable({
// Handle checkbox change
const handleCheckboxChange = React.useCallback(
(profileName: string, checked: boolean) => {
(profileId: string, checked: boolean) => {
const newSet = new Set(selectedProfiles);
if (checked) {
newSet.add(profileName);
newSet.add(profileId);
} else {
newSet.delete(profileName);
newSet.delete(profileId);
}
// Hide checkboxes if no profiles are selected
@@ -708,7 +715,7 @@ export function ProfilesDataTable({
!isBrowserUpdating
);
})
.map((profile) => profile.name),
.map((profile) => profile.id),
)
: new Set<string>();
@@ -774,7 +781,7 @@ export function ProfilesDataTable({
handleProxySelection,
// Selection helpers
isProfileSelected: (name: string) => selectedProfiles.includes(name),
isProfileSelected: (id: string) => selectedProfiles.includes(id),
handleToggleAll,
handleCheckboxChange,
handleIconClick,
@@ -857,7 +864,7 @@ export function ProfilesDataTable({
const browser = profile.browser;
const IconComponent = getBrowserIcon(browser);
const isSelected = meta.isProfileSelected(profile.name);
const isSelected = meta.isProfileSelected(profile.id);
const isRunning =
meta.isClient && meta.runningProfiles.has(profile.id);
const isLaunching = meta.launchingProfiles.has(profile.id);
@@ -897,7 +904,7 @@ export function ProfilesDataTable({
<Checkbox
checked={isSelected}
onCheckedChange={(value) =>
meta.handleCheckboxChange(profile.name, !!value)
meta.handleCheckboxChange(profile.id, !!value)
}
aria-label="Select row"
className="w-4 h-4"
@@ -911,7 +918,7 @@ export function ProfilesDataTable({
<button
type="button"
className="flex justify-center items-center p-0 border-none cursor-pointer"
onClick={() => meta.handleIconClick(profile.name)}
onClick={() => meta.handleIconClick(profile.id)}
aria-label="Select profile"
>
<span className="w-4 h-4 group">
@@ -1039,7 +1046,7 @@ export function ProfilesDataTable({
const profile = row.original as BrowserProfile;
const rawName: string = row.getValue("name");
const name = getBrowserDisplayName(rawName);
const isEditing = meta.profileToRename?.name === profile.name;
const isEditing = meta.profileToRename?.id === profile.id;
if (isEditing) {
const isSaveDisabled =
@@ -1227,9 +1234,9 @@ export function ProfilesDataTable({
const isDisabled =
isRunning || isLaunching || isStopping || isBrowserUpdating;
const hasOverride = Object.hasOwn(meta.proxyOverrides, profile.name);
const hasOverride = Object.hasOwn(meta.proxyOverrides, profile.id);
const effectiveProxyId = hasOverride
? meta.proxyOverrides[profile.name]
? meta.proxyOverrides[profile.id]
: (profile.proxy_id ?? null);
const effectiveProxy = effectiveProxyId
? (meta.storedProxies.find((p) => p.id === effectiveProxyId) ??
@@ -1248,7 +1255,7 @@ export function ProfilesDataTable({
: profileHasProxy && effectiveProxy
? effectiveProxy.name
: null;
const isSelectorOpen = meta.openProxySelectorFor === profile.name;
const isSelectorOpen = meta.openProxySelectorFor === profile.id;
if (profile.browser === "tor-browser") {
return (
@@ -1271,7 +1278,7 @@ export function ProfilesDataTable({
<Popover
open={isSelectorOpen}
onOpenChange={(open) =>
meta.setOpenProxySelectorFor(open ? profile.name : null)
meta.setOpenProxySelectorFor(open ? profile.id : null)
}
>
<Tooltip>
@@ -1311,7 +1318,7 @@ export function ProfilesDataTable({
<CommandItem
value="__none__"
onSelect={() =>
void meta.handleProxySelection(profile.name, null)
void meta.handleProxySelection(profile.id, null)
}
>
<LuCheck
@@ -1330,7 +1337,7 @@ export function ProfilesDataTable({
value={proxy.name}
onSelect={() =>
void meta.handleProxySelection(
profile.name,
profile.id,
proxy.id,
)
}
@@ -1385,7 +1392,7 @@ export function ProfilesDataTable({
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
meta.onAssignProfilesToGroup?.([profile.name]);
meta.onAssignProfilesToGroup?.([profile.id]);
}}
disabled={isDisabled}
>
+4 -4
View File
@@ -94,12 +94,12 @@ export function ProfileSelectorDialog({
setIsLaunching(true);
const selected = profiles.find((p) => p.name === selectedProfile);
if (selected) {
setLaunchingProfiles((prev) => new Set(prev).add(selected.id));
}
if (!selected) return;
setLaunchingProfiles((prev) => new Set(prev).add(selected.id));
try {
await invoke("open_url_with_profile", {
profileName: selectedProfile,
profileId: selected.id,
url,
});
onClose();
+71 -68
View File
@@ -16,6 +16,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Tooltip,
TooltipContent,
@@ -130,82 +131,84 @@ export function ProxyManagementDialog({
</RippleButton>
</div>
) : (
<div className="overflow-y-auto pr-2 space-y-2 h-full">
{storedProxies.map((proxy) => (
<div
key={proxy.id}
className="flex justify-between items-center p-1 rounded border bg-card"
>
<div className="flex-1 ml-2 min-w-0">
{proxy.name.length > 30 ? (
<ScrollArea className="h-[240px] pr-2">
<div className="space-y-2">
{storedProxies.map((proxy) => (
<div
key={proxy.id}
className="flex justify-between items-center p-1 rounded border bg-card"
>
<div className="flex-1 ml-2 min-w-0">
{proxy.name.length > 30 ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="block font-medium truncate text-card-foreground">
{trimName(proxy.name)}
</span>
</TooltipTrigger>
<TooltipContent>
<span className="text-sm font-medium text-card-foreground">
{proxy.name}
</span>
</TooltipContent>
</Tooltip>
) : (
<span className="text-sm font-medium text-card-foreground">
{proxy.name}
</span>
)}
</div>
<div className="mr-2">
<Badge variant="secondary">
{proxyUsage[proxy.id] ?? 0}
</Badge>
</div>
<div className="flex flex-shrink-0 gap-1 items-center">
<Tooltip>
<TooltipTrigger asChild>
<span className="block font-medium truncate text-card-foreground">
{trimName(proxy.name)}
</span>
</TooltipTrigger>
<TooltipContent>
<span className="text-sm font-medium text-card-foreground">
{proxy.name}
</span>
</TooltipContent>
</Tooltip>
) : (
<span className="text-sm font-medium text-card-foreground">
{proxy.name}
</span>
)}
</div>
<div className="mr-2">
<Badge variant="secondary">
{proxyUsage[proxy.id] ?? 0}
</Badge>
</div>
<div className="flex flex-shrink-0 gap-1 items-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditProxy(proxy)}
>
<FiEdit2 className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit proxy</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteProxy(proxy)}
className="text-destructive hover:text-destructive"
disabled={(proxyUsage[proxy.id] ?? 0) > 0}
onClick={() => handleEditProxy(proxy)}
>
<FiTrash2 className="w-4 h-4" />
<FiEdit2 className="w-4 h-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{(proxyUsage[proxy.id] ?? 0) > 0 ? (
<p>
Cannot delete: in use by {proxyUsage[proxy.id]}{" "}
profile
{proxyUsage[proxy.id] > 1 ? "s" : ""}
</p>
) : (
<p>Delete proxy</p>
)}
</TooltipContent>
</Tooltip>
</TooltipTrigger>
<TooltipContent>
<p>Edit proxy</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteProxy(proxy)}
className="text-destructive hover:text-destructive"
disabled={(proxyUsage[proxy.id] ?? 0) > 0}
>
<FiTrash2 className="w-4 h-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{(proxyUsage[proxy.id] ?? 0) > 0 ? (
<p>
Cannot delete: in use by{" "}
{proxyUsage[proxy.id]} profile
{proxyUsage[proxy.id] > 1 ? "s" : ""}
</p>
) : (
<p>Delete proxy</p>
)}
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
))}
</div>
))}
</div>
</ScrollArea>
)}
</div>
</div>
+247 -4
View File
@@ -54,6 +54,7 @@ interface AppSettings {
custom_theme?: Record<string, string>;
api_enabled: boolean;
api_port: number;
api_token?: string;
}
interface CustomThemeState {
@@ -81,6 +82,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
custom_theme: undefined,
api_enabled: false,
api_port: 10108,
api_token: undefined,
});
const [originalSettings, setOriginalSettings] = useState<AppSettings>({
set_as_default_browser: false,
@@ -88,6 +90,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
custom_theme: undefined,
api_enabled: false,
api_port: 10108,
api_token: undefined,
});
const [customThemeState, setCustomThemeState] = useState<CustomThemeState>({
selectedThemeId: null,
@@ -298,7 +301,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
setIsSaving(true);
try {
// Update settings with current custom theme state
const settingsToSave = {
let settingsToSave: AppSettings = {
...settings,
custom_theme:
settings.theme === "custom"
@@ -306,7 +309,12 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
: settings.custom_theme,
};
await invoke("save_app_settings", { settings: settingsToSave });
const savedSettings = await invoke<AppSettings>("save_app_settings", {
settings: settingsToSave,
});
// Update settings with any generated tokens
setSettings(savedSettings);
settingsToSave = savedSettings;
setTheme(settings.theme === "custom" ? "dark" : settings.theme);
// Apply or clear custom variables only on Save
@@ -355,7 +363,12 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
});
// Revert the API enabled setting if start failed
settingsToSave.api_enabled = false;
await invoke("save_app_settings", { settings: settingsToSave });
const revertedSettings = await invoke<AppSettings>(
"save_app_settings",
{ settings: settingsToSave },
);
setSettings(revertedSettings);
settingsToSave = revertedSettings;
}
} else if (!isApiEnabled && wasApiEnabled) {
// Stop API server
@@ -764,8 +777,34 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
<Checkbox
id="api-enabled"
checked={settings.api_enabled}
onCheckedChange={(checked: boolean) => {
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">
@@ -787,6 +826,210 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
</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"
/>
<RippleButton
variant="outline"
size="sm"
onClick={() => {
navigator.clipboard.writeText(settings.api_token || "");
showSuccessToast("API token copied to clipboard");
}}
>
Copy
</RippleButton>
</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?headless=true|false
</code>{" "}
launch with remote debugging
</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>
)}
</div>
{/* Advanced Section */}
+99 -99
View File
@@ -538,105 +538,6 @@ export function SharedCamoufoxConfigForm({
</div>
</div>
{/* WebGL Properties */}
<div className="space-y-3">
<Label>WebGL Properties</Label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="webgl-vendor">WebGL Vendor</Label>
<Input
id="webgl-vendor"
value={fingerprintConfig["webGl:vendor"] || ""}
onChange={(e) =>
updateFingerprintConfig(
"webGl:vendor",
e.target.value || undefined,
)
}
placeholder="e.g., Mesa"
/>
</div>
<div className="space-y-2">
<Label htmlFor="webgl-renderer">WebGL Renderer</Label>
<Input
id="webgl-renderer"
value={fingerprintConfig["webGl:renderer"] || ""}
onChange={(e) =>
updateFingerprintConfig(
"webGl:renderer",
e.target.value || undefined,
)
}
placeholder="e.g., llvmpipe, or similar"
/>
</div>
</div>
</div>
{/* WebGL Parameters */}
<div className="space-y-3">
<ObjectEditor
value={
(fingerprintConfig["webGl:parameters"] as Record<
string,
unknown
>) || {}
}
onChange={(value) =>
updateFingerprintConfig("webGl:parameters", value)
}
title="WebGL Parameters"
/>
</div>
{/* WebGL2 Parameters */}
<div className="space-y-3">
<ObjectEditor
value={
(fingerprintConfig["webGl2:parameters"] as Record<
string,
unknown
>) || {}
}
onChange={(value) =>
updateFingerprintConfig("webGl2:parameters", value)
}
title="WebGL2 Parameters"
/>
</div>
{/* WebGL Shader Precision Formats */}
<div className="space-y-3">
<ObjectEditor
value={
(fingerprintConfig["webGl:shaderPrecisionFormats"] as Record<
string,
unknown
>) || {}
}
onChange={(value) =>
updateFingerprintConfig("webGl:shaderPrecisionFormats", value)
}
title="WebGL Shader Precision Formats"
/>
</div>
{/* WebGL2 Shader Precision Formats */}
<div className="space-y-3">
<ObjectEditor
value={
(fingerprintConfig["webGl2:shaderPrecisionFormats"] as Record<
string,
unknown
>) || {}
}
onChange={(value) =>
updateFingerprintConfig("webGl2:shaderPrecisionFormats", value)
}
title="WebGL2 Shader Precision Formats"
/>
</div>
{/* Geolocation */}
<div className="space-y-3">
<Label>Geolocation</Label>
@@ -737,6 +638,105 @@ export function SharedCamoufoxConfigForm({
</div>
</div>
{/* WebGL Properties */}
<div className="space-y-3">
<Label>WebGL Properties</Label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="webgl-vendor">WebGL Vendor</Label>
<Input
id="webgl-vendor"
value={fingerprintConfig["webGl:vendor"] || ""}
onChange={(e) =>
updateFingerprintConfig(
"webGl:vendor",
e.target.value || undefined,
)
}
placeholder="e.g., Mesa"
/>
</div>
<div className="space-y-2">
<Label htmlFor="webgl-renderer">WebGL Renderer</Label>
<Input
id="webgl-renderer"
value={fingerprintConfig["webGl:renderer"] || ""}
onChange={(e) =>
updateFingerprintConfig(
"webGl:renderer",
e.target.value || undefined,
)
}
placeholder="e.g., llvmpipe, or similar"
/>
</div>
</div>
</div>
{/* WebGL Parameters */}
<div className="space-y-3">
<ObjectEditor
value={
(fingerprintConfig["webGl:parameters"] as Record<
string,
unknown
>) || {}
}
onChange={(value) =>
updateFingerprintConfig("webGl:parameters", value)
}
title="WebGL Parameters"
/>
</div>
{/* WebGL2 Parameters */}
<div className="space-y-3">
<ObjectEditor
value={
(fingerprintConfig["webGl2:parameters"] as Record<
string,
unknown
>) || {}
}
onChange={(value) =>
updateFingerprintConfig("webGl2:parameters", value)
}
title="WebGL2 Parameters"
/>
</div>
{/* WebGL Shader Precision Formats */}
<div className="space-y-3">
<ObjectEditor
value={
(fingerprintConfig["webGl:shaderPrecisionFormats"] as Record<
string,
unknown
>) || {}
}
onChange={(value) =>
updateFingerprintConfig("webGl:shaderPrecisionFormats", value)
}
title="WebGL Shader Precision Formats"
/>
</div>
{/* WebGL2 Shader Precision Formats */}
<div className="space-y-3">
<ObjectEditor
value={
(fingerprintConfig["webGl2:shaderPrecisionFormats"] as Record<
string,
unknown
>) || {}
}
onChange={(value) =>
updateFingerprintConfig("webGl2:shaderPrecisionFormats", value)
}
title="WebGL2 Shader Precision Formats"
/>
</div>
{/* Fonts */}
<div className="space-y-3">
<Label>Fonts</Label>