mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-27 00:49:57 +02:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c30df278fb | |||
| 95592b4aa1 | |||
| 58b0067b37 | |||
| 6260d78901 | |||
| efab286dad | |||
| e51e31911b | |||
| 348a727da7 | |||
| 10f8061acf | |||
| 69348a101e | |||
| f7e116f345 | |||
| 2e6bb2498b | |||
| 178f07bec7 | |||
| c6caf0633e | |||
| 29fe20af09 | |||
| 1cb8e7236d | |||
| ab256cd695 | |||
| 96c42ae55e | |||
| c98e12900f | |||
| 7a0d14642a | |||
| a1f153f4fa | |||
| ff9ad0a5ad | |||
| 3b78fea62a | |||
| 74e1642aa2 | |||
| c9d37519f7 | |||
| da9e1d1b58 | |||
| 77f93cc122 | |||
| f7ccca0075 | |||
| d7c2f08133 | |||
| 8dffd86ab9 | |||
| f3b3207489 | |||
| d7a787586d | |||
| 4a98eedba0 | |||
| 95ee807f3b | |||
| fac99f4a51 | |||
| 88cb154fca | |||
| a6af568d9e | |||
| 7c2ed1e0fc | |||
| 334f894e68 | |||
| a77b733a31 | |||
| c10c3b0f95 | |||
| 4b16341401 | |||
| 016d423d2c | |||
| 0596cc4009 | |||
| 269db678b7 | |||
| f809b975f3 | |||
| e369214715 | |||
| 5f93841bb7 |
Vendored
+3
@@ -83,6 +83,7 @@
|
||||
"localtime",
|
||||
"lxml",
|
||||
"lzma",
|
||||
"Matchalk",
|
||||
"mmdb",
|
||||
"mountpoint",
|
||||
"msiexec",
|
||||
@@ -111,6 +112,7 @@
|
||||
"peerconnection",
|
||||
"pids",
|
||||
"pixbuf",
|
||||
"pkill",
|
||||
"plasmohq",
|
||||
"platformdirs",
|
||||
"prefs",
|
||||
@@ -130,6 +132,7 @@
|
||||
"SARIF",
|
||||
"scipy",
|
||||
"screeninfo",
|
||||
"selectables",
|
||||
"serde",
|
||||
"setuptools",
|
||||
"shadcn",
|
||||
|
||||
@@ -7,6 +7,9 @@ const nextConfig: NextConfig = {
|
||||
unoptimized: true,
|
||||
},
|
||||
distDir: "dist",
|
||||
compiler: {
|
||||
removeConsole: process.env.NODE_ENV === "production",
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -21,11 +21,11 @@
|
||||
"author": "",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@types/node": "^24.2.1",
|
||||
"@types/node": "^24.3.0",
|
||||
"commander": "^14.0.0",
|
||||
"donutbrowser-camoufox-js": "^0.6.6",
|
||||
"dotenv": "^17.2.1",
|
||||
"fingerprint-generator": "^2.1.69",
|
||||
"fingerprint-generator": "^2.1.70",
|
||||
"get-port": "^7.1.0",
|
||||
"nodemon": "^3.1.10",
|
||||
"playwright-core": "^1.54.2",
|
||||
|
||||
+20
-16
@@ -2,7 +2,7 @@
|
||||
"name": "donutbrowser",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
@@ -21,21 +21,21 @@
|
||||
"format": "pnpm format:js && pnpm format:rust",
|
||||
"cargo": "cd src-tauri && cargo",
|
||||
"unused-exports:js": "ts-unused-exports tsconfig.json",
|
||||
"check-unused-commands": "cd src-tauri && cargo run --bin check_unused_commands"
|
||||
"check-unused-commands": "cd src-tauri && cargo test test_no_unused_tauri_commands"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tauri-apps/api": "^2.7.0",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.1",
|
||||
@@ -46,9 +46,12 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"color": "^5.0.0",
|
||||
"lucide-react": "^0.539.0",
|
||||
"motion": "^12.23.12",
|
||||
"next": "^15.4.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-icons": "^5.5.0",
|
||||
@@ -58,17 +61,18 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.1.4",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tailwindcss/postcss": "^4.1.12",
|
||||
"@tauri-apps/cli": "^2.7.1",
|
||||
"@types/node": "^24.2.1",
|
||||
"@types/react": "^19.1.9",
|
||||
"@types/color": "^4.2.0",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.1.5",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"tailwindcss": "^4.1.12",
|
||||
"ts-unused-exports": "^11.0.1",
|
||||
"tw-animate-css": "^1.3.6",
|
||||
"tw-animate-css": "^1.3.7",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748",
|
||||
|
||||
Generated
+1430
-699
File diff suppressed because it is too large
Load Diff
Generated
+278
-168
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.9.2"
|
||||
version = "0.10.0"
|
||||
description = "Simple Yet Powerful Anti-Detect Browser"
|
||||
authors = ["zhom@github"]
|
||||
edition = "2021"
|
||||
@@ -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"] }
|
||||
|
||||
@@ -334,9 +334,14 @@ pub struct ApiClient {
|
||||
}
|
||||
|
||||
impl ApiClient {
|
||||
fn new() -> Self {
|
||||
pub fn new() -> Self {
|
||||
let client = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new());
|
||||
|
||||
Self {
|
||||
client: Client::new(),
|
||||
client,
|
||||
firefox_api_base: "https://product-details.mozilla.org/1.0".to_string(),
|
||||
firefox_dev_api_base: "https://product-details.mozilla.org/1.0".to_string(),
|
||||
github_api_base: "https://api.github.com".to_string(),
|
||||
@@ -647,19 +652,19 @@ 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?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(
|
||||
format!(
|
||||
"Failed to fetch Firefox Developer Edition versions: {}",
|
||||
response.status()
|
||||
)
|
||||
.into(),
|
||||
let error_msg = format!(
|
||||
"Failed to fetch Firefox Developer Edition versions: {} - URL: {}",
|
||||
response.status(),
|
||||
url
|
||||
);
|
||||
eprintln!("{error_msg}");
|
||||
return Err(error_msg.into());
|
||||
}
|
||||
|
||||
let firefox_response: FirefoxApiResponse = response.json().await?;
|
||||
|
||||
@@ -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<String>,
|
||||
pub process_id: Option<u32>,
|
||||
pub last_launch: Option<u64>,
|
||||
pub release_type: String,
|
||||
pub camoufox_config: Option<serde_json::Value>,
|
||||
pub group_id: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub is_running: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ApiProfilesResponse {
|
||||
pub profiles: Vec<ApiProfile>,
|
||||
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<String>,
|
||||
pub proxy_id: Option<String>,
|
||||
pub release_type: Option<String>,
|
||||
pub camoufox_config: Option<serde_json::Value>,
|
||||
pub group_id: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UpdateProfileRequest {
|
||||
pub name: Option<String>,
|
||||
pub browser: Option<String>,
|
||||
pub version: Option<String>,
|
||||
pub proxy_id: Option<String>,
|
||||
pub release_type: Option<String>,
|
||||
pub camoufox_config: Option<serde_json::Value>,
|
||||
pub group_id: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
proxy_settings: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToastPayload {
|
||||
pub message: String,
|
||||
pub variant: String,
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ApiServer {
|
||||
port: Option<u16>,
|
||||
shutdown_tx: Option<mpsc::Sender<()>>,
|
||||
task_handle: Option<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl ApiServer {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
port: None,
|
||||
shutdown_tx: None,
|
||||
task_handle: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_port(&self) -> Option<u16> {
|
||||
self.port
|
||||
}
|
||||
|
||||
async fn start(
|
||||
&mut self,
|
||||
app_handle: tauri::AppHandle,
|
||||
preferred_port: u16,
|
||||
) -> Result<u16, String> {
|
||||
// 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::<u16>().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<Mutex<ApiServer>> = Arc::new(Mutex::new(ApiServer::new()));
|
||||
}
|
||||
|
||||
// Tauri commands
|
||||
#[tauri::command]
|
||||
pub async fn start_api_server_internal(
|
||||
port: u16,
|
||||
app_handle: &tauri::AppHandle,
|
||||
) -> Result<u16, String> {
|
||||
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<u16>,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<u16, String> {
|
||||
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<Option<u16>, String> {
|
||||
let server_guard = API_SERVER.lock().await;
|
||||
Ok(server_guard.get_port())
|
||||
}
|
||||
|
||||
// API Handlers - Profiles
|
||||
async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
match profile_manager.list_profiles() {
|
||||
Ok(profiles) => {
|
||||
let api_profiles: Vec<ApiProfile> = 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<String>,
|
||||
State(_state): State<ApiServerState>,
|
||||
) -> Result<Json<ApiProfileResponse>, 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<ApiServerState>,
|
||||
Json(request): Json<CreateProfileRequest>,
|
||||
) -> Result<Json<ApiProfileResponse>, 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(name): Path<String>,
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<UpdateProfileRequest>,
|
||||
) -> Result<Json<ApiProfileResponse>, StatusCode> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
|
||||
// Update profile fields
|
||||
if let Some(new_name) = request.name {
|
||||
if profile_manager.rename_profile(&name, &new_name).is_err() {
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(version) = request.version {
|
||||
if profile_manager
|
||||
.update_profile_version(&name, &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(), &name, Some(proxy_id))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(camoufox_config) = request.camoufox_config {
|
||||
let config: Result<crate::camoufox::CamoufoxConfig, _> =
|
||||
serde_json::from_value(camoufox_config);
|
||||
match config {
|
||||
Ok(config) => {
|
||||
if profile_manager
|
||||
.update_camoufox_config(state.app_handle.clone(), &name, 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![name.clone()], Some(group_id))
|
||||
.is_err()
|
||||
{
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tags) = request.tags {
|
||||
if profile_manager.update_profile_tags(&name, 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(name), State(state)).await
|
||||
}
|
||||
|
||||
async fn delete_profile(
|
||||
Path(id): Path<String>,
|
||||
State(_state): State<ApiServerState>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
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<ApiServerState>,
|
||||
) -> Result<Json<Vec<ApiGroupResponse>>, 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<String>,
|
||||
State(_state): State<ApiServerState>,
|
||||
) -> Result<Json<ApiGroupResponse>, 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<ApiServerState>,
|
||||
Json(request): Json<CreateGroupRequest>,
|
||||
) -> Result<Json<ApiGroupResponse>, 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<String>,
|
||||
State(_state): State<ApiServerState>,
|
||||
Json(request): Json<UpdateGroupRequest>,
|
||||
) -> Result<Json<ApiGroupResponse>, 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<String>,
|
||||
State(_state): State<ApiServerState>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
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<ApiServerState>) -> Result<Json<Vec<String>>, 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<ApiServerState>,
|
||||
) -> Result<Json<Vec<ApiProxyResponse>>, 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<String>,
|
||||
State(_state): State<ApiServerState>,
|
||||
) -> Result<Json<ApiProxyResponse>, 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<ApiServerState>,
|
||||
Json(request): Json<CreateProxyRequest>,
|
||||
) -> Result<Json<ApiProxyResponse>, 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<String>,
|
||||
State(_state): State<ApiServerState>,
|
||||
Json(request): Json<UpdateProxyRequest>,
|
||||
) -> Result<Json<ApiProxyResponse>, 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<String>,
|
||||
State(_state): State<ApiServerState>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
match PROXY_MANAGER.delete_stored_proxy(&id) {
|
||||
Ok(_) => Ok(StatusCode::NO_CONTENT),
|
||||
Err(_) => Err(StatusCode::BAD_REQUEST),
|
||||
}
|
||||
}
|
||||
@@ -470,14 +470,6 @@ pub async fn check_for_browser_updates() -> Result<Vec<UpdateNotification>, Stri
|
||||
Ok(grouped)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn is_browser_disabled_for_update(browser: String) -> Result<bool, String> {
|
||||
let updater = AutoUpdater::instance();
|
||||
updater
|
||||
.is_browser_disabled(&browser)
|
||||
.map_err(|e| format!("Failed to check browser status: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn dismiss_update_notification(notification_id: String) -> Result<(), String> {
|
||||
let updater = AutoUpdater::instance();
|
||||
@@ -520,6 +512,7 @@ mod tests {
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
group_id: None,
|
||||
tags: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+567
-144
@@ -2,6 +2,7 @@ use crate::platform_browser;
|
||||
use crate::profile::{BrowserProfile, ProfileManager};
|
||||
use crate::proxy_manager::PROXY_MANAGER;
|
||||
use directories::BaseDirs;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashSet;
|
||||
use std::fs::create_dir_all;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -140,11 +141,17 @@ impl BrowserRunner {
|
||||
|
||||
pub fn save_profile(&self, profile: &BrowserProfile) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
profile_manager.save_profile(profile)
|
||||
let result = profile_manager.save_profile(profile);
|
||||
// Update tag suggestions after any save
|
||||
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
|
||||
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
||||
});
|
||||
result
|
||||
}
|
||||
|
||||
pub fn list_profiles(&self) -> Result<Vec<BrowserProfile>, Box<dyn std::error::Error>> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
|
||||
profile_manager.list_profiles()
|
||||
}
|
||||
|
||||
@@ -251,16 +258,44 @@ impl BrowserRunner {
|
||||
|
||||
// Save the updated profile
|
||||
self.save_process_info(&updated_profile)?;
|
||||
// Ensure tag suggestions include any tags from this profile
|
||||
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
|
||||
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
||||
});
|
||||
println!(
|
||||
"Updated profile with process info: {}",
|
||||
updated_profile.name
|
||||
);
|
||||
|
||||
println!(
|
||||
"Emitting profile events for successful Camoufox launch: {}",
|
||||
updated_profile.name
|
||||
);
|
||||
|
||||
// Emit profile update event to frontend
|
||||
if let Err(e) = app_handle.emit("profile-updated", &updated_profile) {
|
||||
println!("Warning: Failed to emit profile update event: {e}");
|
||||
}
|
||||
|
||||
// Emit minimal running changed event to frontend with a small delay
|
||||
#[derive(Serialize)]
|
||||
struct RunningChangedPayload {
|
||||
id: String,
|
||||
is_running: bool,
|
||||
}
|
||||
|
||||
let payload = RunningChangedPayload {
|
||||
id: updated_profile.id.to_string(),
|
||||
is_running: updated_profile.process_id.is_some(),
|
||||
};
|
||||
|
||||
if let Err(e) = app_handle.emit("profile-running-changed", &payload) {
|
||||
println!("Warning: Failed to emit profile running changed event: {e}");
|
||||
} else {
|
||||
println!("Emitted profile update event for: {}", updated_profile.name);
|
||||
println!(
|
||||
"Successfully emitted profile-running-changed event for Camoufox {}: running={}",
|
||||
updated_profile.name, payload.is_running
|
||||
);
|
||||
}
|
||||
|
||||
return Ok(updated_profile);
|
||||
@@ -330,8 +365,8 @@ impl BrowserRunner {
|
||||
let launcher_pid = child.id();
|
||||
|
||||
println!(
|
||||
"Launched browser with launcher PID: {} for profile: {}",
|
||||
launcher_pid, profile.name
|
||||
"Launched browser with launcher PID: {} for profile: {} (ID: {})",
|
||||
launcher_pid, profile.name, profile.id
|
||||
);
|
||||
|
||||
// For TOR and Mullvad browsers, we need to find the actual browser process
|
||||
@@ -397,16 +432,25 @@ impl BrowserRunner {
|
||||
&& !exe_name_lower.contains("camoufox")
|
||||
}
|
||||
"firefox-developer" => {
|
||||
exe_name_lower.contains("firefox") && exe_name_lower.contains("developer")
|
||||
// More flexible detection for Firefox Developer Edition
|
||||
(exe_name_lower.contains("firefox") && exe_name_lower.contains("developer"))
|
||||
|| (exe_name_lower.contains("firefox")
|
||||
&& cmd.iter().any(|arg| {
|
||||
let arg_str = arg.to_str().unwrap_or("");
|
||||
arg_str.contains("Developer")
|
||||
|| arg_str.contains("developer")
|
||||
|| arg_str.contains("FirefoxDeveloperEdition")
|
||||
|| arg_str.contains("firefox-developer")
|
||||
}))
|
||||
|| exe_name_lower == "firefox" // Firefox Developer might just show as "firefox"
|
||||
}
|
||||
"mullvad-browser" => {
|
||||
self.is_tor_or_mullvad_browser(&exe_name_lower, cmd, "mullvad-browser")
|
||||
}
|
||||
"tor-browser" => self.is_tor_or_mullvad_browser(&exe_name_lower, cmd, "tor-browser"),
|
||||
"zen" => exe_name_lower.contains("zen"),
|
||||
"chromium" => exe_name_lower.contains("chromium"),
|
||||
"brave" => exe_name_lower.contains("brave"),
|
||||
// Camoufox uses nodecar, not PID-based here
|
||||
"chromium" => exe_name_lower.contains("chromium") || exe_name_lower.contains("chrome"),
|
||||
"brave" => exe_name_lower.contains("brave") || exe_name_lower.contains("Brave"),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
@@ -414,30 +458,52 @@ impl BrowserRunner {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for profile path match in command line args
|
||||
let profile_path_match = cmd.iter().any(|s| {
|
||||
let arg = s.to_str().unwrap_or("");
|
||||
if profile.browser == "tor-browser"
|
||||
|| profile.browser == "firefox"
|
||||
|| profile.browser == "firefox-developer"
|
||||
|| profile.browser == "mullvad-browser"
|
||||
|| profile.browser == "zen"
|
||||
{
|
||||
arg == profile_data_path_str || arg == format!("-profile={profile_data_path_str}")
|
||||
} else {
|
||||
// For Chromium-based browsers, look for user-data-dir flag or raw path
|
||||
arg.contains(&format!("--user-data-dir={profile_data_path_str}"))
|
||||
|| arg == profile_data_path_str
|
||||
// Check for profile path match
|
||||
let profile_path_match = if matches!(
|
||||
profile.browser.as_str(),
|
||||
"firefox" | "firefox-developer" | "tor-browser" | "mullvad-browser" | "zen"
|
||||
) {
|
||||
// Firefox-based browsers: look for -profile argument followed by path
|
||||
let mut found_profile_arg = false;
|
||||
for (i, arg) in cmd.iter().enumerate() {
|
||||
if let Some(arg_str) = arg.to_str() {
|
||||
if arg_str == "-profile" && i + 1 < cmd.len() {
|
||||
if let Some(next_arg) = cmd.get(i + 1).and_then(|a| a.to_str()) {
|
||||
if next_arg == profile_data_path_str {
|
||||
found_profile_arg = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also check for combined -profile=path format
|
||||
if arg_str == format!("-profile={profile_data_path_str}") {
|
||||
found_profile_arg = true;
|
||||
break;
|
||||
}
|
||||
// Check if the argument is the profile path directly
|
||||
if arg_str == profile_data_path_str {
|
||||
found_profile_arg = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
found_profile_arg
|
||||
} else {
|
||||
// Chromium-based browsers: look for --user-data-dir argument
|
||||
cmd.iter().any(|s| {
|
||||
if let Some(arg) = s.to_str() {
|
||||
arg == format!("--user-data-dir={profile_data_path_str}")
|
||||
|| arg == profile_data_path_str
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
if profile_path_match {
|
||||
let pid_u32 = pid.as_u32();
|
||||
if pid_u32 != launcher_pid {
|
||||
actual_pid = pid_u32;
|
||||
println!(
|
||||
"Resolved actual macOS browser PID: {actual_pid} (launcher PID: {launcher_pid})"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -450,6 +516,9 @@ impl BrowserRunner {
|
||||
updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs());
|
||||
|
||||
self.save_process_info(&updated_profile)?;
|
||||
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
|
||||
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
||||
});
|
||||
|
||||
// Apply proxy settings if needed (for Firefox-based browsers)
|
||||
if profile.proxy_id.is_some()
|
||||
@@ -466,11 +535,36 @@ impl BrowserRunner {
|
||||
// which is already handled in the profile creation process
|
||||
}
|
||||
|
||||
println!(
|
||||
"Emitting profile events for successful launch: {} (ID: {})",
|
||||
updated_profile.name, updated_profile.id
|
||||
);
|
||||
|
||||
// Emit profile update event to frontend
|
||||
if let Err(e) = app_handle.emit("profile-updated", &updated_profile) {
|
||||
println!("Warning: Failed to emit profile update event: {e}");
|
||||
}
|
||||
|
||||
// Emit minimal running changed event to frontend with a small delay to ensure UI consistency
|
||||
#[derive(Serialize)]
|
||||
struct RunningChangedPayload {
|
||||
id: String,
|
||||
is_running: bool,
|
||||
}
|
||||
let payload = RunningChangedPayload {
|
||||
id: updated_profile.id.to_string(),
|
||||
is_running: updated_profile.process_id.is_some(),
|
||||
};
|
||||
|
||||
if let Err(e) = app_handle.emit("profile-running-changed", &payload) {
|
||||
println!("Warning: Failed to emit profile running changed event: {e}");
|
||||
} else {
|
||||
println!(
|
||||
"Successfully emitted profile-running-changed event for {}: running={}",
|
||||
updated_profile.name, payload.is_running
|
||||
);
|
||||
}
|
||||
|
||||
Ok(updated_profile)
|
||||
}
|
||||
|
||||
@@ -497,8 +591,8 @@ impl BrowserRunner {
|
||||
{
|
||||
Ok(Some(_camoufox_process)) => {
|
||||
println!(
|
||||
"Opening URL in existing Camoufox process for profile: {}",
|
||||
profile.name
|
||||
"Opening URL in existing Camoufox process for profile: {} (ID: {})",
|
||||
profile.name, profile.id
|
||||
);
|
||||
|
||||
// For Camoufox, we need to launch a new instance with the URL since it doesn't support remote commands
|
||||
@@ -527,7 +621,7 @@ impl BrowserRunner {
|
||||
let profiles = self.list_profiles().expect("Failed to list profiles");
|
||||
let updated_profile = profiles
|
||||
.into_iter()
|
||||
.find(|p| p.name == profile.name)
|
||||
.find(|p| p.id == profile.id)
|
||||
.unwrap_or_else(|| profile.clone());
|
||||
|
||||
// Ensure we have a valid process ID
|
||||
@@ -687,28 +781,43 @@ impl BrowserRunner {
|
||||
url: Option<String>,
|
||||
internal_proxy_settings: Option<&ProxySettings>,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
|
||||
println!(
|
||||
"launch_or_open_url called for profile: {} (ID: {})",
|
||||
profile.name, profile.id
|
||||
);
|
||||
|
||||
// Get the most up-to-date profile data
|
||||
let profiles = self.list_profiles().expect("Failed to list profiles");
|
||||
let profiles = self
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles in launch_or_open_url: {e}"))?;
|
||||
let updated_profile = profiles
|
||||
.into_iter()
|
||||
.find(|p| p.name == profile.name)
|
||||
.find(|p| p.id == profile.id)
|
||||
.unwrap_or_else(|| profile.clone());
|
||||
|
||||
println!(
|
||||
"Checking browser status for profile: {} (ID: {})",
|
||||
updated_profile.name, updated_profile.id
|
||||
);
|
||||
|
||||
// Check if browser is already running
|
||||
let is_running = self
|
||||
.check_browser_status(app_handle.clone(), &updated_profile)
|
||||
.await?;
|
||||
.await
|
||||
.map_err(|e| format!("Failed to check browser status: {e}"))?;
|
||||
|
||||
// Get the updated profile again after status check (PID might have been updated)
|
||||
let profiles = self.list_profiles().expect("Failed to list profiles");
|
||||
let profiles = self
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles after status check: {e}"))?;
|
||||
let final_profile = profiles
|
||||
.into_iter()
|
||||
.find(|p| p.name == profile.name)
|
||||
.find(|p| p.id == profile.id)
|
||||
.unwrap_or_else(|| updated_profile.clone());
|
||||
|
||||
println!(
|
||||
"Browser status check - Profile: {}, Running: {}, URL: {:?}, PID: {:?}",
|
||||
final_profile.name, is_running, url, final_profile.process_id
|
||||
"Browser status check - Profile: {} (ID: {}), Running: {}, URL: {:?}, PID: {:?}",
|
||||
final_profile.name, final_profile.id, is_running, url, final_profile.process_id
|
||||
);
|
||||
|
||||
if is_running && url.is_some() {
|
||||
@@ -749,16 +858,10 @@ impl BrowserRunner {
|
||||
// and can't have multiple instances with the same profile
|
||||
match final_profile.browser.as_str() {
|
||||
"mullvad-browser" | "tor-browser" => {
|
||||
Err(format!(
|
||||
"Failed to open URL in existing {} browser. Cannot launch new instance due to profile conflict: {}",
|
||||
final_profile.browser, e
|
||||
).into())
|
||||
Err(format!("Failed to open URL in existing {} browser. Cannot launch new instance due to profile conflict: {}", final_profile.browser, e).into())
|
||||
}
|
||||
_ => {
|
||||
println!(
|
||||
"Falling back to new instance for browser: {}",
|
||||
final_profile.browser
|
||||
);
|
||||
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
|
||||
}
|
||||
@@ -806,15 +909,20 @@ impl BrowserRunner {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_profile(&self, profile_name: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
pub fn delete_profile(&self, profile_id: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
profile_manager.delete_profile(profile_name)?;
|
||||
profile_manager.delete_profile(profile_id)?;
|
||||
|
||||
// Always perform cleanup after profile deletion to remove unused binaries
|
||||
if let Err(e) = self.cleanup_unused_binaries_internal() {
|
||||
println!("Warning: Failed to cleanup unused binaries: {e}");
|
||||
}
|
||||
|
||||
// Rebuild tags after deletion
|
||||
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
|
||||
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -844,8 +952,8 @@ impl BrowserRunner {
|
||||
let profile_path_str = profile_data_path.to_string_lossy();
|
||||
|
||||
println!(
|
||||
"Attempting to kill Camoufox process for profile: {}",
|
||||
profile.name
|
||||
"Attempting to kill Camoufox process for profile: {} (ID: {})",
|
||||
profile.name, profile.id
|
||||
);
|
||||
|
||||
match camoufox_launcher
|
||||
@@ -885,8 +993,8 @@ impl BrowserRunner {
|
||||
}
|
||||
Ok(None) => {
|
||||
println!(
|
||||
"No running Camoufox process found for profile: {}",
|
||||
profile.name
|
||||
"No running Camoufox process found for profile: {} (ID: {})",
|
||||
profile.name, profile.id
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -904,86 +1012,155 @@ impl BrowserRunner {
|
||||
.save_process_info(&updated_profile)
|
||||
.map_err(|e| format!("Failed to update profile: {e}"))?;
|
||||
|
||||
println!(
|
||||
"Emitting profile events for successful Camoufox kill: {}",
|
||||
updated_profile.name
|
||||
);
|
||||
|
||||
// Emit profile update event to frontend
|
||||
if let Err(e) = app_handle.emit("profile-updated", &updated_profile) {
|
||||
println!("Warning: Failed to emit profile update event: {e}");
|
||||
}
|
||||
|
||||
// Emit minimal running changed event to frontend immediately
|
||||
#[derive(Serialize)]
|
||||
struct RunningChangedPayload {
|
||||
id: String,
|
||||
is_running: bool,
|
||||
}
|
||||
let payload = RunningChangedPayload {
|
||||
id: updated_profile.id.to_string(),
|
||||
is_running: false, // Explicitly set to false since we just killed it
|
||||
};
|
||||
|
||||
if let Err(e) = app_handle.emit("profile-running-changed", &payload) {
|
||||
println!("Warning: Failed to emit profile running changed event: {e}");
|
||||
} else {
|
||||
println!(
|
||||
"Successfully emitted profile-running-changed event for Camoufox {}: running={}",
|
||||
updated_profile.name, payload.is_running
|
||||
);
|
||||
}
|
||||
|
||||
println!(
|
||||
"Camoufox process cleanup completed for profile: {}",
|
||||
profile.name
|
||||
"Camoufox process cleanup completed for profile: {} (ID: {})",
|
||||
profile.name, profile.id
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// For non-camoufox browsers, use the existing logic
|
||||
let pid = if let Some(pid) = profile.process_id {
|
||||
pid
|
||||
} else {
|
||||
// Try to find the process by searching all processes
|
||||
// First verify the stored PID is still valid and belongs to our profile
|
||||
let system = System::new_all();
|
||||
let mut found_pid: Option<u32> = None;
|
||||
|
||||
for (pid, process) in system.processes() {
|
||||
if let Some(process) = system.process(sysinfo::Pid::from(pid as usize)) {
|
||||
let cmd = process.cmd();
|
||||
if cmd.len() >= 2 {
|
||||
// Check if this is the right browser executable first
|
||||
let exe_name = process.name().to_string_lossy().to_lowercase();
|
||||
let is_correct_browser = match profile.browser.as_str() {
|
||||
"firefox" => {
|
||||
exe_name.contains("firefox")
|
||||
&& !exe_name.contains("developer")
|
||||
&& !exe_name.contains("tor")
|
||||
&& !exe_name.contains("mullvad")
|
||||
&& !exe_name.contains("camoufox")
|
||||
}
|
||||
"firefox-developer" => exe_name.contains("firefox") && exe_name.contains("developer"),
|
||||
"mullvad-browser" => self.is_tor_or_mullvad_browser(&exe_name, cmd, "mullvad-browser"),
|
||||
"tor-browser" => self.is_tor_or_mullvad_browser(&exe_name, cmd, "tor-browser"),
|
||||
"zen" => exe_name.contains("zen"),
|
||||
"chromium" => exe_name.contains("chromium"),
|
||||
"brave" => exe_name.contains("brave"),
|
||||
// Camoufox is handled via nodecar, not PID-based checking
|
||||
_ => false,
|
||||
};
|
||||
let exe_name = process.name().to_string_lossy().to_lowercase();
|
||||
|
||||
if !is_correct_browser {
|
||||
continue;
|
||||
// Verify this process is actually our browser
|
||||
let is_correct_browser = match profile.browser.as_str() {
|
||||
"firefox" => {
|
||||
exe_name.contains("firefox")
|
||||
&& !exe_name.contains("developer")
|
||||
&& !exe_name.contains("tor")
|
||||
&& !exe_name.contains("mullvad")
|
||||
&& !exe_name.contains("camoufox")
|
||||
}
|
||||
"firefox-developer" => {
|
||||
// More flexible detection for Firefox Developer Edition
|
||||
(exe_name.contains("firefox") && exe_name.contains("developer"))
|
||||
|| (exe_name.contains("firefox")
|
||||
&& cmd.iter().any(|arg| {
|
||||
let arg_str = arg.to_str().unwrap_or("");
|
||||
arg_str.contains("Developer")
|
||||
|| arg_str.contains("developer")
|
||||
|| arg_str.contains("FirefoxDeveloperEdition")
|
||||
|| arg_str.contains("firefox-developer")
|
||||
}))
|
||||
|| exe_name == "firefox" // Firefox Developer might just show as "firefox"
|
||||
}
|
||||
"mullvad-browser" => self.is_tor_or_mullvad_browser(&exe_name, cmd, "mullvad-browser"),
|
||||
"tor-browser" => self.is_tor_or_mullvad_browser(&exe_name, cmd, "tor-browser"),
|
||||
"zen" => exe_name.contains("zen"),
|
||||
"chromium" => exe_name.contains("chromium") || exe_name.contains("chrome"),
|
||||
"brave" => exe_name.contains("brave") || exe_name.contains("Brave"),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
// Check for profile path match
|
||||
if is_correct_browser {
|
||||
// Verify profile path match
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_data_path = profile.get_profile_data_path(&profiles_dir);
|
||||
let profile_data_path_str = profile_data_path.to_string_lossy();
|
||||
let profile_path_match = cmd.iter().any(|s| {
|
||||
let arg = s.to_str().unwrap_or("");
|
||||
// For Firefox-based browsers, check for exact profile path match
|
||||
if profile.browser == "camoufox" {
|
||||
// Camoufox uses user_data_dir like Chromium browsers
|
||||
arg.contains(&format!("--user-data-dir={profile_data_path_str}"))
|
||||
|| arg == profile_data_path_str
|
||||
} else if profile.browser == "tor-browser"
|
||||
|| profile.browser == "firefox"
|
||||
|| profile.browser == "firefox-developer"
|
||||
|| profile.browser == "mullvad-browser"
|
||||
|| profile.browser == "zen"
|
||||
{
|
||||
arg == profile_data_path_str || arg == format!("-profile={profile_data_path_str}")
|
||||
} else {
|
||||
// For Chromium-based browsers, check for user-data-dir
|
||||
arg.contains(&format!("--user-data-dir={profile_data_path_str}"))
|
||||
|| arg == profile_data_path_str
|
||||
|
||||
let profile_path_match = if matches!(
|
||||
profile.browser.as_str(),
|
||||
"firefox" | "firefox-developer" | "tor-browser" | "mullvad-browser" | "zen"
|
||||
) {
|
||||
// Firefox-based browsers: look for -profile argument followed by path
|
||||
let mut found_profile_arg = false;
|
||||
for (i, arg) in cmd.iter().enumerate() {
|
||||
if let Some(arg_str) = arg.to_str() {
|
||||
if arg_str == "-profile" && i + 1 < cmd.len() {
|
||||
if let Some(next_arg) = cmd.get(i + 1).and_then(|a| a.to_str()) {
|
||||
if next_arg == profile_data_path_str {
|
||||
found_profile_arg = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also check for combined -profile=path format
|
||||
if arg_str == format!("-profile={profile_data_path_str}") {
|
||||
found_profile_arg = true;
|
||||
break;
|
||||
}
|
||||
// Check if the argument is the profile path directly
|
||||
if arg_str == profile_data_path_str {
|
||||
found_profile_arg = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
found_profile_arg
|
||||
} else {
|
||||
// Chromium-based browsers: look for --user-data-dir argument
|
||||
cmd.iter().any(|s| {
|
||||
if let Some(arg) = s.to_str() {
|
||||
arg == format!("--user-data-dir={profile_data_path_str}")
|
||||
|| arg == profile_data_path_str
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
if profile_path_match {
|
||||
found_pid = Some(pid.as_u32());
|
||||
break;
|
||||
println!(
|
||||
"Verified stored PID {} is valid for profile {} (ID: {})",
|
||||
pid, profile.name, profile.id
|
||||
);
|
||||
pid
|
||||
} else {
|
||||
println!("Stored PID {} doesn't match profile path for {} (ID: {}), searching for correct process", pid, profile.name, profile.id);
|
||||
// Fall through to search for correct process
|
||||
self.find_browser_process_by_profile(profile)?
|
||||
}
|
||||
} else {
|
||||
println!("Stored PID {} doesn't match browser type for {} (ID: {}), searching for correct process", pid, profile.name, profile.id);
|
||||
// Fall through to search for correct process
|
||||
self.find_browser_process_by_profile(profile)?
|
||||
}
|
||||
} else {
|
||||
println!(
|
||||
"Stored PID {} is no longer valid for profile {} (ID: {}), searching for correct process",
|
||||
pid, profile.name, profile.id
|
||||
);
|
||||
// Fall through to search for correct process
|
||||
self.find_browser_process_by_profile(profile)?
|
||||
}
|
||||
|
||||
found_pid.ok_or("Browser process not found")?
|
||||
} else {
|
||||
// No stored PID, search for the process
|
||||
self.find_browser_process_by_profile(profile)?
|
||||
};
|
||||
|
||||
println!("Attempting to kill browser process with PID: {pid}");
|
||||
@@ -1013,9 +1190,156 @@ impl BrowserRunner {
|
||||
.save_process_info(&updated_profile)
|
||||
.map_err(|e| format!("Failed to update profile: {e}"))?;
|
||||
|
||||
println!(
|
||||
"Emitting profile events for successful kill: {}",
|
||||
updated_profile.name
|
||||
);
|
||||
|
||||
// Emit profile update event to frontend
|
||||
if let Err(e) = app_handle.emit("profile-updated", &updated_profile) {
|
||||
println!("Warning: Failed to emit profile update event: {e}");
|
||||
}
|
||||
|
||||
// Emit minimal running changed event to frontend immediately
|
||||
#[derive(Serialize)]
|
||||
struct RunningChangedPayload {
|
||||
id: String,
|
||||
is_running: bool,
|
||||
}
|
||||
let payload = RunningChangedPayload {
|
||||
id: updated_profile.id.to_string(),
|
||||
is_running: false, // Explicitly set to false since we just killed it
|
||||
};
|
||||
|
||||
if let Err(e) = app_handle.emit("profile-running-changed", &payload) {
|
||||
println!("Warning: Failed to emit profile running changed event: {e}");
|
||||
} else {
|
||||
println!(
|
||||
"Successfully emitted profile-running-changed event for {}: running={}",
|
||||
updated_profile.name, payload.is_running
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Helper method to find browser process by profile path
|
||||
fn find_browser_process_by_profile(
|
||||
&self,
|
||||
profile: &BrowserProfile,
|
||||
) -> Result<u32, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let system = System::new_all();
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_data_path = profile.get_profile_data_path(&profiles_dir);
|
||||
let profile_data_path_str = profile_data_path.to_string_lossy();
|
||||
|
||||
println!(
|
||||
"Searching for {} browser process with profile path: {}",
|
||||
profile.browser, profile_data_path_str
|
||||
);
|
||||
|
||||
for (pid, process) in system.processes() {
|
||||
let cmd = process.cmd();
|
||||
if cmd.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this is the right browser executable first
|
||||
let exe_name = process.name().to_string_lossy().to_lowercase();
|
||||
let is_correct_browser = match profile.browser.as_str() {
|
||||
"firefox" => {
|
||||
exe_name.contains("firefox")
|
||||
&& !exe_name.contains("developer")
|
||||
&& !exe_name.contains("tor")
|
||||
&& !exe_name.contains("mullvad")
|
||||
&& !exe_name.contains("camoufox")
|
||||
}
|
||||
"firefox-developer" => {
|
||||
// More flexible detection for Firefox Developer Edition
|
||||
(exe_name.contains("firefox") && exe_name.contains("developer"))
|
||||
|| (exe_name.contains("firefox")
|
||||
&& cmd.iter().any(|arg| {
|
||||
let arg_str = arg.to_str().unwrap_or("");
|
||||
arg_str.contains("Developer")
|
||||
|| arg_str.contains("developer")
|
||||
|| arg_str.contains("FirefoxDeveloperEdition")
|
||||
|| arg_str.contains("firefox-developer")
|
||||
}))
|
||||
|| exe_name == "firefox" // Firefox Developer might just show as "firefox"
|
||||
}
|
||||
"mullvad-browser" => self.is_tor_or_mullvad_browser(&exe_name, cmd, "mullvad-browser"),
|
||||
"tor-browser" => self.is_tor_or_mullvad_browser(&exe_name, cmd, "tor-browser"),
|
||||
"zen" => exe_name.contains("zen"),
|
||||
"chromium" => exe_name.contains("chromium") || exe_name.contains("chrome"),
|
||||
"brave" => exe_name.contains("brave") || exe_name.contains("Brave"),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if !is_correct_browser {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for profile path match with improved logic
|
||||
let profile_path_match = if matches!(
|
||||
profile.browser.as_str(),
|
||||
"firefox" | "firefox-developer" | "tor-browser" | "mullvad-browser" | "zen"
|
||||
) {
|
||||
// Firefox-based browsers: look for -profile argument followed by path
|
||||
let mut found_profile_arg = false;
|
||||
for (i, arg) in cmd.iter().enumerate() {
|
||||
if let Some(arg_str) = arg.to_str() {
|
||||
if arg_str == "-profile" && i + 1 < cmd.len() {
|
||||
if let Some(next_arg) = cmd.get(i + 1).and_then(|a| a.to_str()) {
|
||||
if next_arg == profile_data_path_str {
|
||||
found_profile_arg = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also check for combined -profile=path format
|
||||
if arg_str == format!("-profile={profile_data_path_str}") {
|
||||
found_profile_arg = true;
|
||||
break;
|
||||
}
|
||||
// Check if the argument is the profile path directly
|
||||
if arg_str == profile_data_path_str {
|
||||
found_profile_arg = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
found_profile_arg
|
||||
} else {
|
||||
// Chromium-based browsers: look for --user-data-dir argument
|
||||
cmd.iter().any(|s| {
|
||||
if let Some(arg) = s.to_str() {
|
||||
arg == format!("--user-data-dir={profile_data_path_str}")
|
||||
|| arg == profile_data_path_str
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
if profile_path_match {
|
||||
let pid_u32 = pid.as_u32();
|
||||
println!(
|
||||
"Found matching {} browser process with PID: {} for profile: {} (ID: {})",
|
||||
profile.browser, pid_u32, profile.name, profile.id
|
||||
);
|
||||
return Ok(pid_u32);
|
||||
}
|
||||
}
|
||||
|
||||
Err(
|
||||
format!(
|
||||
"No running {} browser process found for profile: {} (ID: {})",
|
||||
profile.browser, profile.name, profile.id
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if browser binaries exist for all profiles and return missing binaries
|
||||
pub async fn check_missing_binaries(
|
||||
&self,
|
||||
@@ -1118,6 +1442,7 @@ impl BrowserRunner {
|
||||
println!("GeoIP database is missing for Camoufox profiles, downloading...");
|
||||
|
||||
use crate::geoip_downloader::GeoIPDownloader;
|
||||
|
||||
let geoip_downloader = GeoIPDownloader::instance();
|
||||
|
||||
match geoip_downloader.download_geoip_database(app_handle).await {
|
||||
@@ -1146,9 +1471,7 @@ impl BrowserRunner {
|
||||
{
|
||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||
if downloading.contains(&download_key) {
|
||||
return Err(format!(
|
||||
"Browser '{browser_str}' version '{version}' is already being downloaded. Please wait for the current download to complete."
|
||||
).into());
|
||||
return Err(format!("Browser '{browser_str}' version '{version}' is already being downloaded. Please wait for the current download to complete.").into());
|
||||
}
|
||||
// Mark this browser-version pair as being downloaded
|
||||
downloading.insert(download_key.clone());
|
||||
@@ -1203,7 +1526,7 @@ impl BrowserRunner {
|
||||
|
||||
// Create browser directory
|
||||
let mut browser_dir = self.get_binaries_dir();
|
||||
browser_dir.push(browser_type.as_str());
|
||||
browser_dir.push(&browser_str);
|
||||
browser_dir.push(&version);
|
||||
|
||||
create_dir_all(&browser_dir).map_err(|e| format!("Failed to create browser directory: {e}"))?;
|
||||
@@ -1357,9 +1680,10 @@ impl BrowserRunner {
|
||||
return Err(error_details.into());
|
||||
}
|
||||
|
||||
registry
|
||||
.mark_download_completed(&browser_str, &version)
|
||||
.map_err(|e| format!("Failed to mark download as completed: {e}"))?;
|
||||
// Mark completion in registry. If it fails (e.g., rare race during cleanup), log but continue.
|
||||
if let Err(e) = registry.mark_download_completed(&browser_str, &version) {
|
||||
eprintln!("Warning: Could not mark {browser_str} {version} as completed in registry: {e}");
|
||||
}
|
||||
registry
|
||||
.save()
|
||||
.map_err(|e| format!("Failed to save registry: {e}"))?;
|
||||
@@ -1383,11 +1707,15 @@ impl BrowserRunner {
|
||||
println!("Downloading GeoIP database for Camoufox...");
|
||||
|
||||
let geoip_downloader = GeoIPDownloader::instance();
|
||||
if let Err(e) = geoip_downloader.download_geoip_database(&app_handle).await {
|
||||
eprintln!("Warning: Failed to download GeoIP database: {e}");
|
||||
// Don't fail the browser download if GeoIP download fails
|
||||
} else {
|
||||
println!("GeoIP database downloaded successfully");
|
||||
|
||||
match geoip_downloader.download_geoip_database(&app_handle).await {
|
||||
Ok(_) => {
|
||||
println!("GeoIP database downloaded successfully");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to download GeoIP database: {e}");
|
||||
// Don't fail the browser download if GeoIP download fails
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("GeoIP database already available");
|
||||
@@ -1442,6 +1770,11 @@ impl BrowserRunner {
|
||||
|
||||
files_exist
|
||||
}
|
||||
|
||||
pub fn get_all_tags(&self) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||
let tag_manager = crate::tag_manager::TAG_MANAGER.lock().unwrap();
|
||||
tag_manager.get_all_tags()
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -1486,18 +1819,34 @@ pub async fn launch_browser_profile(
|
||||
profile: BrowserProfile,
|
||||
url: Option<String>,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
println!(
|
||||
"Launch request received for profile: {} (ID: {})",
|
||||
profile.name, profile.id
|
||||
);
|
||||
|
||||
let browser_runner = BrowserRunner::instance();
|
||||
|
||||
// Store the internal proxy settings for passing to launch_browser
|
||||
let mut internal_proxy_settings: Option<ProxySettings> = None;
|
||||
|
||||
// Resolve the most up-to-date profile from disk by name to avoid using stale proxy_id/browser state
|
||||
let profile_for_launch = browser_runner
|
||||
// Resolve the most up-to-date profile from disk by ID to avoid using stale proxy_id/browser state
|
||||
let profile_for_launch = match browser_runner
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?
|
||||
.into_iter()
|
||||
.find(|p| p.name == profile.name)
|
||||
.unwrap_or_else(|| profile.clone());
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))
|
||||
{
|
||||
Ok(profiles) => profiles
|
||||
.into_iter()
|
||||
.find(|p| p.id == profile.id)
|
||||
.unwrap_or_else(|| profile.clone()),
|
||||
Err(e) => {
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
println!(
|
||||
"Resolved profile for launch: {} (ID: {})",
|
||||
profile_for_launch.name, profile_for_launch.id
|
||||
);
|
||||
|
||||
// Always start a local proxy before launching (non-Camoufox handled here; Camoufox has its own flow)
|
||||
if profile.browser != "camoufox" {
|
||||
@@ -1556,9 +1905,6 @@ pub async fn launch_browser_profile(
|
||||
.map(|p| format!("{}:{}", p.host, p.port))
|
||||
.unwrap_or_else(|| "DIRECT".to_string())
|
||||
);
|
||||
|
||||
// Give the proxy a moment to fully start up
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to start local proxy (will launch without it): {e}");
|
||||
@@ -1566,20 +1912,43 @@ pub async fn launch_browser_profile(
|
||||
}
|
||||
}
|
||||
|
||||
println!(
|
||||
"Starting browser launch for profile: {} (ID: {})",
|
||||
profile_for_launch.name, profile_for_launch.id
|
||||
);
|
||||
|
||||
// Launch browser or open URL in existing instance
|
||||
let updated_profile = browser_runner
|
||||
.launch_or_open_url(app_handle.clone(), &profile_for_launch, url, internal_proxy_settings.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
// Check if this is an architecture compatibility issue
|
||||
if let Some(io_error) = e.downcast_ref::<std::io::Error>() {
|
||||
if io_error.kind() == std::io::ErrorKind::Other
|
||||
&& io_error.to_string().contains("Exec format error") {
|
||||
return format!("Failed to launch browser: Executable format error. This browser version is not compatible with your system architecture ({}). Please try a different browser or version that supports your platform.", std::env::consts::ARCH);
|
||||
}
|
||||
let updated_profile = browser_runner.launch_or_open_url(app_handle.clone(), &profile_for_launch, url, internal_proxy_settings.as_ref()).await.map_err(|e| {
|
||||
println!("Browser launch failed for profile: {}, error: {}", profile_for_launch.name, e);
|
||||
|
||||
// Emit a failure event to clear loading states in the frontend
|
||||
#[derive(serde::Serialize)]
|
||||
struct RunningChangedPayload {
|
||||
id: String,
|
||||
is_running: bool,
|
||||
}
|
||||
let payload = RunningChangedPayload {
|
||||
id: profile_for_launch.id.to_string(),
|
||||
is_running: false,
|
||||
};
|
||||
|
||||
if let Err(e) = app_handle.emit("profile-running-changed", &payload) {
|
||||
println!("Warning: Failed to emit profile running changed event: {e}");
|
||||
}
|
||||
|
||||
// Check if this is an architecture compatibility issue
|
||||
if let Some(io_error) = e.downcast_ref::<std::io::Error>() {
|
||||
if io_error.kind() == std::io::ErrorKind::Other && io_error.to_string().contains("Exec format error") {
|
||||
return format!("Failed to launch browser: Executable format error. This browser version is not compatible with your system architecture ({}). Please try a different browser or version that supports your platform.", std::env::consts::ARCH);
|
||||
}
|
||||
format!("Failed to launch browser or open URL: {e}")
|
||||
})?;
|
||||
}
|
||||
format!("Failed to launch browser or open URL: {e}")
|
||||
})?;
|
||||
|
||||
println!(
|
||||
"Browser launch completed for profile: {} (ID: {})",
|
||||
updated_profile.name, updated_profile.id
|
||||
);
|
||||
|
||||
// Now update the proxy with the correct PID if we have one
|
||||
if let Some(actual_pid) = updated_profile.process_id {
|
||||
@@ -1603,6 +1972,17 @@ pub async fn update_profile_proxy(
|
||||
.map_err(|e| format!("Failed to update profile: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_profile_tags(
|
||||
profile_name: String,
|
||||
tags: Vec<String>,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
profile_manager
|
||||
.update_profile_tags(&profile_name, tags)
|
||||
.map_err(|e| format!("Failed to update profile tags: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_browser_status(
|
||||
app_handle: tauri::AppHandle,
|
||||
@@ -1628,10 +2008,10 @@ pub fn rename_profile(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_profile(_app_handle: tauri::AppHandle, profile_name: String) -> Result<(), String> {
|
||||
pub fn delete_profile(_app_handle: tauri::AppHandle, profile_id: String) -> Result<(), String> {
|
||||
let browser_runner = BrowserRunner::instance();
|
||||
browser_runner
|
||||
.delete_profile(profile_name.as_str())
|
||||
.delete_profile(profile_id.as_str())
|
||||
.map_err(|e| format!("Failed to delete profile: {e}"))
|
||||
}
|
||||
|
||||
@@ -1738,6 +2118,14 @@ pub fn is_browser_downloaded(browser_str: String, version: String) -> bool {
|
||||
browser_runner.is_browser_downloaded(&browser_str, &version)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_all_tags() -> Result<Vec<String>, String> {
|
||||
let browser_runner = BrowserRunner::instance();
|
||||
browser_runner
|
||||
.get_all_tags()
|
||||
.map_err(|e| format!("Failed to get tags: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn check_browser_exists(browser_str: String, version: String) -> bool {
|
||||
// This is an alias for is_browser_downloaded to provide clearer semantics for auto-updates
|
||||
@@ -1749,11 +2137,46 @@ pub async fn kill_browser_profile(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile: BrowserProfile,
|
||||
) -> Result<(), String> {
|
||||
println!(
|
||||
"Kill request received for profile: {} (ID: {})",
|
||||
profile.name, profile.id
|
||||
);
|
||||
|
||||
let browser_runner = BrowserRunner::instance();
|
||||
browser_runner
|
||||
.kill_browser_process(app_handle, &profile)
|
||||
|
||||
match browser_runner
|
||||
.kill_browser_process(app_handle.clone(), &profile)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to kill browser: {e}"))
|
||||
{
|
||||
Ok(()) => {
|
||||
println!(
|
||||
"Successfully killed browser profile: {} (ID: {})",
|
||||
profile.name, profile.id
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Failed to kill browser profile {}: {}", profile.name, e);
|
||||
|
||||
// Emit a failure event to clear loading states in the frontend
|
||||
#[derive(serde::Serialize)]
|
||||
struct RunningChangedPayload {
|
||||
id: String,
|
||||
is_running: bool,
|
||||
}
|
||||
// On kill failure, we assume the process is still running
|
||||
let payload = RunningChangedPayload {
|
||||
id: profile.id.to_string(),
|
||||
is_running: true,
|
||||
};
|
||||
|
||||
if let Err(e) = app_handle.emit("profile-running-changed", &payload) {
|
||||
println!("Warning: Failed to emit profile running changed event: {e}");
|
||||
}
|
||||
|
||||
Err(format!("Failed to kill browser: {e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -1786,12 +2209,12 @@ pub async fn create_browser_profile_new(
|
||||
#[tauri::command]
|
||||
pub async fn update_camoufox_config(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_name: String,
|
||||
profile_id: String,
|
||||
config: CamoufoxConfig,
|
||||
) -> Result<(), String> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
profile_manager
|
||||
.update_camoufox_config(app_handle, &profile_name, config)
|
||||
.update_camoufox_config(app_handle, &profile_id, config)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to update Camoufox config: {e}"))
|
||||
}
|
||||
|
||||
+35
-14
@@ -402,23 +402,44 @@ impl Downloader {
|
||||
existing_size = meta.len();
|
||||
}
|
||||
|
||||
// Build request, add Range only if we have bytes
|
||||
let mut request = self
|
||||
.client
|
||||
.get(&download_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",
|
||||
);
|
||||
// Build request, add Range only if we have bytes. If the server responds with 416 (Range Not
|
||||
// Satisfiable), delete the partial file and retry once without the Range header.
|
||||
let response = {
|
||||
let mut request = self
|
||||
.client
|
||||
.get(&download_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",
|
||||
);
|
||||
|
||||
if existing_size > 0 {
|
||||
request = request.header("Range", format!("bytes={existing_size}-"));
|
||||
}
|
||||
if existing_size > 0 {
|
||||
request = request.header("Range", format!("bytes={existing_size}-"));
|
||||
}
|
||||
|
||||
// Start download (or resume)
|
||||
let response = request.send().await?;
|
||||
let first = request.send().await?;
|
||||
|
||||
// Check if the response is successful
|
||||
if first.status().as_u16() == 416 && existing_size > 0 {
|
||||
// Partial file on disk is not acceptable to the server — remove it and retry from scratch
|
||||
let _ = std::fs::remove_file(&file_path);
|
||||
existing_size = 0;
|
||||
|
||||
let retry = self
|
||||
.client
|
||||
.get(&download_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?;
|
||||
retry
|
||||
} else {
|
||||
first
|
||||
}
|
||||
};
|
||||
|
||||
// Check if the response is successful (200 OK or 206 Partial Content)
|
||||
if !(response.status().is_success() || response.status().as_u16() == 206) {
|
||||
return Err(format!("Download failed with status: {}", response.status()).into());
|
||||
}
|
||||
|
||||
@@ -400,6 +400,7 @@ mod tests {
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
group_id: Some(group1.id.clone()),
|
||||
tags: Vec::new(),
|
||||
},
|
||||
crate::profile::BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
@@ -412,6 +413,7 @@ mod tests {
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
group_id: Some(group1.id.clone()),
|
||||
tags: Vec::new(),
|
||||
},
|
||||
crate::profile::BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
@@ -424,6 +426,7 @@ mod tests {
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
group_id: None, // Default group
|
||||
tags: Vec::new(),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
+138
-8
@@ -8,6 +8,7 @@ use tauri_plugin_deep_link::DeepLinkExt;
|
||||
static PENDING_URLS: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
|
||||
mod api_client;
|
||||
mod api_server;
|
||||
mod app_auto_updater;
|
||||
mod auto_updater;
|
||||
mod browser;
|
||||
@@ -26,18 +27,17 @@ mod profile_importer;
|
||||
mod proxy_manager;
|
||||
mod settings_manager;
|
||||
// mod theme_detector; // removed: theme detection handled in webview via CSS prefers-color-scheme
|
||||
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,
|
||||
fetch_browser_versions_cached_first, fetch_browser_versions_with_count,
|
||||
fetch_browser_versions_with_count_cached_first, get_downloaded_browser_versions,
|
||||
fetch_browser_versions_with_count_cached_first, get_all_tags, get_downloaded_browser_versions,
|
||||
get_supported_browsers, is_browser_supported_on_platform, kill_browser_profile,
|
||||
launch_browser_profile, list_browser_profiles, rename_profile, update_camoufox_config,
|
||||
update_profile_proxy,
|
||||
update_profile_proxy, update_profile_tags,
|
||||
};
|
||||
|
||||
use settings_manager::{
|
||||
@@ -53,7 +53,6 @@ use version_updater::{
|
||||
|
||||
use auto_updater::{
|
||||
check_for_browser_updates, complete_browser_update_with_auto_update, dismiss_update_notification,
|
||||
is_browser_disabled_for_update,
|
||||
};
|
||||
|
||||
use app_auto_updater::{
|
||||
@@ -62,8 +61,6 @@ use app_auto_updater::{
|
||||
|
||||
use profile_importer::{detect_existing_profiles, import_browser_profile};
|
||||
|
||||
// use theme_detector::get_system_theme;
|
||||
|
||||
use group_manager::{
|
||||
assign_profiles_to_group, create_profile_group, delete_profile_group, delete_selected_profiles,
|
||||
get_groups_with_profile_counts, get_profile_groups, update_profile_group,
|
||||
@@ -73,6 +70,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")]
|
||||
@@ -489,8 +488,135 @@ pub fn run() {
|
||||
}
|
||||
});
|
||||
|
||||
// Periodically broadcast browser running status to the frontend
|
||||
let app_handle_status = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_millis(500));
|
||||
let mut last_running_states: std::collections::HashMap<String, bool> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
let runner = crate::browser_runner::BrowserRunner::instance();
|
||||
// If listing profiles fails, skip this tick
|
||||
let profiles = match runner.list_profiles() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
println!("Warning: Failed to list profiles in status checker: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for profile in profiles {
|
||||
// Check browser status and track changes
|
||||
match runner
|
||||
.check_browser_status(app_handle_status.clone(), &profile)
|
||||
.await
|
||||
{
|
||||
Ok(is_running) => {
|
||||
let profile_id = profile.id.to_string();
|
||||
let last_state = last_running_states
|
||||
.get(&profile_id)
|
||||
.copied()
|
||||
.unwrap_or(false);
|
||||
|
||||
// Only emit event if state actually changed
|
||||
if last_state != is_running {
|
||||
println!(
|
||||
"Status checker detected change for profile {}: {} -> {}",
|
||||
profile.name, last_state, is_running
|
||||
);
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct RunningChangedPayload {
|
||||
id: String,
|
||||
is_running: bool,
|
||||
}
|
||||
|
||||
let payload = RunningChangedPayload {
|
||||
id: profile_id.clone(),
|
||||
is_running,
|
||||
};
|
||||
|
||||
if let Err(e) = app_handle_status.emit("profile-running-changed", &payload) {
|
||||
println!("Warning: Failed to emit profile running changed event: {e}");
|
||||
} else {
|
||||
println!(
|
||||
"Status checker emitted profile-running-changed event for {}: running={}",
|
||||
profile.name, is_running
|
||||
);
|
||||
}
|
||||
|
||||
last_running_states.insert(profile_id, is_running);
|
||||
} else {
|
||||
// Update the state even if unchanged to ensure we have it tracked
|
||||
last_running_states.insert(profile_id, is_running);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!(
|
||||
"Warning: Status check failed for profile {}: {}",
|
||||
profile.name, e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 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![
|
||||
@@ -506,8 +632,10 @@ pub fn run() {
|
||||
fetch_browser_versions_cached_first,
|
||||
fetch_browser_versions_with_count_cached_first,
|
||||
get_downloaded_browser_versions,
|
||||
get_all_tags,
|
||||
get_browser_release_types,
|
||||
update_profile_proxy,
|
||||
update_profile_tags,
|
||||
check_browser_status,
|
||||
kill_browser_profile,
|
||||
rename_profile,
|
||||
@@ -523,7 +651,6 @@ pub fn run() {
|
||||
trigger_manual_version_update,
|
||||
get_version_update_status,
|
||||
check_for_browser_updates,
|
||||
is_browser_disabled_for_update,
|
||||
dismiss_update_notification,
|
||||
complete_browser_update_with_auto_update,
|
||||
check_for_app_updates,
|
||||
@@ -550,6 +677,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");
|
||||
|
||||
@@ -214,6 +214,227 @@ end try
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn kill_browser_process_impl(
|
||||
pid: u32,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
println!("Attempting to kill browser process with PID: {pid}");
|
||||
|
||||
// For Chromium-based browsers, use immediate aggressive termination
|
||||
// Chromium browsers are notoriously difficult to kill on macOS due to process spawning
|
||||
|
||||
// Step 1: Immediate SIGKILL on main process (no graceful shutdown for Chromium)
|
||||
println!("Starting immediate SIGKILL for PID: {pid}");
|
||||
let _ = Command::new("kill")
|
||||
.args(["-KILL", &pid.to_string()])
|
||||
.output();
|
||||
|
||||
// Step 2: Comprehensive process tree termination using multiple methods simultaneously
|
||||
let _ = kill_chromium_process_tree_aggressive(pid).await;
|
||||
|
||||
// Step 2.5: Nuclear option - kill all Chromium processes by name pattern
|
||||
let _ = kill_all_chromium_processes_by_name().await;
|
||||
|
||||
// Step 3: Use multiple kill strategies in parallel
|
||||
let pid_str = pid.to_string();
|
||||
|
||||
// Kill by parent PID with SIGKILL
|
||||
let _ = Command::new("pkill")
|
||||
.args(["-KILL", "-P", &pid_str])
|
||||
.output();
|
||||
|
||||
// Kill by process group with SIGKILL
|
||||
let _ = Command::new("pkill")
|
||||
.args(["-KILL", "-g", &pid_str])
|
||||
.output();
|
||||
|
||||
// Kill by session ID
|
||||
let _ = Command::new("pkill")
|
||||
.args(["-KILL", "-s", &pid_str])
|
||||
.output();
|
||||
|
||||
// Wait briefly for initial termination
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||
|
||||
// Step 4: Verify and retry with pattern-based killing for common Chromium process names
|
||||
use sysinfo::{Pid, System};
|
||||
let system = System::new_all();
|
||||
|
||||
// Check if main process still exists
|
||||
if system.process(Pid::from(pid as usize)).is_some() {
|
||||
println!("Main process {pid} still running, using pattern-based termination");
|
||||
|
||||
// Kill by common Chromium process patterns
|
||||
let chromium_patterns = [
|
||||
"Chrome",
|
||||
"Chromium",
|
||||
"Brave",
|
||||
"chrome",
|
||||
"chromium",
|
||||
"brave",
|
||||
"Google Chrome",
|
||||
"Brave Browser",
|
||||
"Chrome Helper",
|
||||
"Chromium Helper",
|
||||
];
|
||||
|
||||
for pattern in &chromium_patterns {
|
||||
let _ = Command::new("pkill")
|
||||
.args(["-KILL", "-f", pattern])
|
||||
.output();
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Final aggressive cleanup - kill any remaining processes
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
|
||||
|
||||
// One more round of comprehensive killing
|
||||
let _ = Command::new("pkill")
|
||||
.args(["-KILL", "-P", &pid_str])
|
||||
.output();
|
||||
|
||||
let _ = Command::new("pkill")
|
||||
.args(["-KILL", "-g", &pid_str])
|
||||
.output();
|
||||
|
||||
// Final verification with extended wait
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
let system = System::new_all();
|
||||
|
||||
if system.process(Pid::from(pid as usize)).is_some() {
|
||||
// Last resort: try system kill command with different signals
|
||||
println!("Process {pid} extremely persistent, trying system-level termination");
|
||||
|
||||
let _ = Command::new("/bin/kill").args(["-KILL", &pid_str]).output();
|
||||
|
||||
let _ = Command::new("/usr/bin/killall")
|
||||
.args(["-KILL", "-m", "Chrome"])
|
||||
.output();
|
||||
|
||||
let _ = Command::new("/usr/bin/killall")
|
||||
.args(["-KILL", "-m", "Chromium"])
|
||||
.output();
|
||||
|
||||
let _ = Command::new("/usr/bin/killall")
|
||||
.args(["-KILL", "-m", "Brave"])
|
||||
.output();
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
|
||||
let system = System::new_all();
|
||||
|
||||
if system.process(Pid::from(pid as usize)).is_some() {
|
||||
println!("WARNING: Process {pid} could not be terminated despite aggressive attempts");
|
||||
// Don't return error - let the UI update anyway since we tried everything
|
||||
}
|
||||
}
|
||||
|
||||
println!("Aggressive browser termination completed for PID: {pid}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Helper function to kill process tree (Chromium browsers often spawn child processes)
|
||||
async fn kill_chromium_process_tree_aggressive(
|
||||
pid: u32,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
println!("Killing comprehensive process tree for PID: {pid}");
|
||||
|
||||
// Get all descendant processes using recursive process tree discovery
|
||||
let descendant_pids = get_all_descendant_pids(pid).await;
|
||||
println!(
|
||||
"Found {} descendant processes to terminate",
|
||||
descendant_pids.len()
|
||||
);
|
||||
|
||||
// Kill all descendants first (reverse order - children before parents)
|
||||
for &desc_pid in descendant_pids.iter().rev() {
|
||||
if desc_pid != pid {
|
||||
println!("Terminating descendant process: {desc_pid}");
|
||||
let _ = Command::new("kill")
|
||||
.args(["-KILL", &desc_pid.to_string()])
|
||||
.output();
|
||||
}
|
||||
}
|
||||
|
||||
// No delay for initial termination
|
||||
|
||||
// Force kill any remaining descendants
|
||||
for &desc_pid in descendant_pids.iter().rev() {
|
||||
if desc_pid != pid {
|
||||
let _ = Command::new("kill")
|
||||
.args(["-KILL", &desc_pid.to_string()])
|
||||
.output();
|
||||
}
|
||||
}
|
||||
|
||||
// Also use pkill as a backup to catch any processes we might have missed
|
||||
let _ = Command::new("pkill")
|
||||
.args(["-KILL", "-P", &pid.to_string()])
|
||||
.output();
|
||||
|
||||
// On macOS, also try killing by process group for Chromium browsers
|
||||
let _ = Command::new("pkill")
|
||||
.args(["-KILL", "-g", &pid.to_string()])
|
||||
.output();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Helper function to kill all Chromium-related processes by name patterns
|
||||
async fn kill_all_chromium_processes_by_name(
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
println!("Killing all Chromium-related processes by name patterns");
|
||||
|
||||
let chromium_patterns = [
|
||||
"Chrome",
|
||||
"Chromium",
|
||||
"Brave",
|
||||
"chrome",
|
||||
"chromium",
|
||||
"brave",
|
||||
"Google Chrome",
|
||||
"Brave Browser",
|
||||
"Chrome Helper",
|
||||
"Chromium Helper",
|
||||
];
|
||||
|
||||
for pattern in &chromium_patterns {
|
||||
let _ = Command::new("pkill")
|
||||
.args(["-KILL", "-f", pattern])
|
||||
.output();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Recursively find all descendant processes
|
||||
async fn get_all_descendant_pids(parent_pid: u32) -> Vec<u32> {
|
||||
use sysinfo::System;
|
||||
|
||||
let system = System::new_all();
|
||||
let mut descendants = Vec::new();
|
||||
let mut to_check = vec![parent_pid];
|
||||
let mut checked = std::collections::HashSet::new();
|
||||
|
||||
while let Some(current_pid) = to_check.pop() {
|
||||
if checked.contains(¤t_pid) {
|
||||
continue;
|
||||
}
|
||||
checked.insert(current_pid);
|
||||
|
||||
// Find direct children of current_pid
|
||||
for (pid, process) in system.processes() {
|
||||
let pid_u32 = pid.as_u32();
|
||||
if let Some(parent) = process.parent() {
|
||||
if parent.as_u32() == current_pid && !checked.contains(&pid_u32) {
|
||||
descendants.push(pid_u32);
|
||||
to_check.push(pid_u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
descendants
|
||||
}
|
||||
|
||||
pub async fn open_url_in_existing_browser_tor_mullvad(
|
||||
profile: &BrowserProfile,
|
||||
url: &str,
|
||||
@@ -455,39 +676,6 @@ end try
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn kill_browser_process_impl(
|
||||
pid: u32,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
println!("Attempting to kill browser process with PID: {pid}");
|
||||
|
||||
// First try SIGTERM (graceful shutdown)
|
||||
let output = Command::new("kill")
|
||||
.args(["-TERM", &pid.to_string()])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute kill command: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
// If SIGTERM fails, try SIGKILL (force kill)
|
||||
let output = Command::new("kill")
|
||||
.args(["-KILL", &pid.to_string()])
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(
|
||||
format!(
|
||||
"Failed to kill process {}: {}",
|
||||
pid,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
println!("Successfully killed browser process with PID: {pid}");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -727,41 +915,10 @@ pub mod windows {
|
||||
cmd.current_dir(parent_dir);
|
||||
}
|
||||
|
||||
let output = cmd.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
// Try fallback without --new-window
|
||||
let mut fallback_cmd = Command::new(&executable_path);
|
||||
fallback_cmd.args([
|
||||
&format!(
|
||||
"--user-data-dir={}",
|
||||
profile
|
||||
.get_profile_data_path(profiles_dir)
|
||||
.to_string_lossy()
|
||||
),
|
||||
url,
|
||||
]);
|
||||
|
||||
if let Some(parent_dir) = browser_dir
|
||||
.parent()
|
||||
.or_else(|| browser_dir.ancestors().nth(1))
|
||||
{
|
||||
fallback_cmd.current_dir(parent_dir);
|
||||
}
|
||||
|
||||
let fallback_output = fallback_cmd.output()?;
|
||||
|
||||
if !fallback_output.status.success() {
|
||||
return Err(
|
||||
format!(
|
||||
"Failed to open URL in existing Chromium-based browser: {}",
|
||||
String::from_utf8_lossy(&fallback_output.stderr)
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Do not call output() to avoid blocking the UI thread while the browser processes the request.
|
||||
// Spawn the helper process and return immediately. This applies to Chromium-based browsers
|
||||
// including Brave to prevent UI freezes observed in production.
|
||||
let _child = cmd.spawn()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+155
-117
@@ -151,6 +151,7 @@ impl ProfileManager {
|
||||
release_type: release_type.to_string(),
|
||||
camoufox_config: None,
|
||||
group_id: group_id.clone(),
|
||||
tags: Vec::new(),
|
||||
};
|
||||
|
||||
match camoufox_launcher
|
||||
@@ -191,6 +192,7 @@ impl ProfileManager {
|
||||
release_type: release_type.to_string(),
|
||||
camoufox_config: final_camoufox_config,
|
||||
group_id: group_id.clone(),
|
||||
tags: Vec::new(),
|
||||
};
|
||||
|
||||
// Save profile info
|
||||
@@ -284,6 +286,11 @@ impl ProfileManager {
|
||||
// Save profile with new name
|
||||
self.save_profile(&profile)?;
|
||||
|
||||
// Keep tag suggestions up to date after name change (rebuild from all profiles)
|
||||
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
|
||||
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
||||
});
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
@@ -321,6 +328,11 @@ impl ProfileManager {
|
||||
|
||||
println!("Profile '{profile_name}' deleted successfully");
|
||||
|
||||
// Rebuild tag suggestions after deletion
|
||||
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
|
||||
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -395,9 +407,46 @@ impl ProfileManager {
|
||||
self.save_profile(&profile)?;
|
||||
}
|
||||
|
||||
// Rebuild tag suggestions after group changes just in case
|
||||
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
|
||||
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_profile_tags(
|
||||
&self,
|
||||
profile_name: &str,
|
||||
tags: Vec<String>,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||
// Find the profile by name
|
||||
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"))?;
|
||||
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut deduped: Vec<String> = Vec::with_capacity(tags.len());
|
||||
for t in tags.into_iter() {
|
||||
if seen.insert(t.clone()) {
|
||||
deduped.push(t);
|
||||
}
|
||||
}
|
||||
profile.tags = deduped;
|
||||
|
||||
// Save profile
|
||||
self.save_profile(&profile)?;
|
||||
|
||||
// Update global tag suggestions from all profiles
|
||||
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
|
||||
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
||||
});
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
pub fn delete_multiple_profiles(
|
||||
&self,
|
||||
profile_names: Vec<String>,
|
||||
@@ -497,19 +546,6 @@ impl ProfileManager {
|
||||
format!("Profile {profile_name} not found").into()
|
||||
})?;
|
||||
|
||||
// Check if browser is running to manage proxy accordingly
|
||||
let browser_is_running = profile.process_id.is_some()
|
||||
&& self
|
||||
.check_browser_status(app_handle.clone(), &profile)
|
||||
.await?;
|
||||
|
||||
// If browser is running, stop existing proxy
|
||||
if browser_is_running && profile.proxy_id.is_some() {
|
||||
if let Some(pid) = profile.process_id {
|
||||
let _ = PROXY_MANAGER.stop_proxy(app_handle.clone(), pid).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Update proxy settings
|
||||
profile.proxy_id = proxy_id.clone();
|
||||
|
||||
@@ -520,68 +556,16 @@ impl ProfileManager {
|
||||
format!("Failed to save profile: {e}").into()
|
||||
})?;
|
||||
|
||||
// Handle proxy startup/configuration
|
||||
// Update on-disk browser profile config immediately
|
||||
if let Some(proxy_id_ref) = &proxy_id {
|
||||
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_ref) {
|
||||
if browser_is_running {
|
||||
// Browser is running and proxy is enabled, start new proxy
|
||||
if let Some(pid) = profile.process_id {
|
||||
match PROXY_MANAGER
|
||||
.start_proxy(
|
||||
app_handle.clone(),
|
||||
Some(&proxy_settings),
|
||||
pid,
|
||||
Some(profile_name),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(internal_proxy_settings) => {
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
|
||||
|
||||
// Apply the proxy settings with the internal proxy to the profile directory
|
||||
self
|
||||
.apply_proxy_settings_to_profile(
|
||||
&profile_path,
|
||||
&proxy_settings,
|
||||
Some(&internal_proxy_settings),
|
||||
)
|
||||
.map_err(|e| format!("Failed to update profile proxy: {e}"))?;
|
||||
|
||||
println!("Successfully started proxy for profile: {}", profile.name);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to start proxy: {e}");
|
||||
// Apply proxy settings without internal proxy
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
|
||||
self
|
||||
.apply_proxy_settings_to_profile(&profile_path, &proxy_settings, None)
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
format!("Failed to apply proxy settings: {e}").into()
|
||||
})?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No PID available, apply proxy settings without internal proxy
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
|
||||
self
|
||||
.apply_proxy_settings_to_profile(&profile_path, &proxy_settings, None)
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
format!("Failed to apply proxy settings: {e}").into()
|
||||
})?;
|
||||
}
|
||||
} else {
|
||||
// Proxy disabled or browser not running, just apply settings
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
|
||||
self
|
||||
.apply_proxy_settings_to_profile(&profile_path, &proxy_settings, None)
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
format!("Failed to apply proxy settings: {e}").into()
|
||||
})?;
|
||||
}
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
|
||||
self
|
||||
.apply_proxy_settings_to_profile(&profile_path, &proxy_settings, None)
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
format!("Failed to apply proxy settings: {e}").into()
|
||||
})?;
|
||||
} else {
|
||||
// Proxy ID provided but proxy not found, disable proxy
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
@@ -624,7 +608,7 @@ impl ProfileManager {
|
||||
}
|
||||
|
||||
// For non-camoufox browsers, use the existing PID-based logic
|
||||
let mut inner_profile = profile.clone();
|
||||
let inner_profile = profile.clone();
|
||||
let system = System::new_all();
|
||||
let mut is_running = false;
|
||||
let mut found_pid: Option<u32> = None;
|
||||
@@ -748,24 +732,42 @@ impl ProfileManager {
|
||||
let metadata_exists = metadata_file.exists();
|
||||
|
||||
if metadata_exists {
|
||||
// Update the process ID if we found a different one
|
||||
// Load the latest profile from disk to avoid overwriting fields like proxy_id
|
||||
let latest_profile: BrowserProfile = match std::fs::read_to_string(&metadata_file)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
{
|
||||
Some(p) => p,
|
||||
None => inner_profile.clone(),
|
||||
};
|
||||
|
||||
let previous_pid = latest_profile.process_id;
|
||||
let mut merged = latest_profile.clone();
|
||||
|
||||
if let Some(pid) = found_pid {
|
||||
if inner_profile.process_id != Some(pid) {
|
||||
inner_profile.process_id = Some(pid);
|
||||
if let Err(e) = self.save_profile(&inner_profile) {
|
||||
if merged.process_id != Some(pid) {
|
||||
merged.process_id = Some(pid);
|
||||
if let Err(e) = self.save_profile(&merged) {
|
||||
println!("Warning: Failed to update profile with new PID: {e}");
|
||||
}
|
||||
}
|
||||
} else if inner_profile.process_id.is_some() {
|
||||
} else if merged.process_id.is_some() {
|
||||
// Clear the PID if no process found
|
||||
inner_profile.process_id = None;
|
||||
if let Err(e) = self.save_profile(&inner_profile) {
|
||||
merged.process_id = None;
|
||||
if let Err(e) = self.save_profile(&merged) {
|
||||
println!("Warning: Failed to clear profile PID: {e}");
|
||||
}
|
||||
|
||||
// Stop any associated proxy immediately when the browser stops
|
||||
if let Some(old_pid) = previous_pid {
|
||||
let _ = crate::proxy_manager::PROXY_MANAGER
|
||||
.stop_proxy(app_handle.clone(), old_pid)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
// Emit profile update event to frontend
|
||||
if let Err(e) = app_handle.emit("profile-updated", &inner_profile) {
|
||||
if let Err(e) = app_handle.emit("profile-updated", &merged) {
|
||||
println!("Warning: Failed to emit profile update event: {e}");
|
||||
}
|
||||
}
|
||||
@@ -790,49 +792,71 @@ impl ProfileManager {
|
||||
match launcher.find_camoufox_by_profile(&profile_path_str).await {
|
||||
Ok(Some(camoufox_process)) => {
|
||||
// Found a running instance, update profile with process info if changed
|
||||
let process_id_changed = profile.process_id != camoufox_process.processId;
|
||||
// Only write status changes if metadata still exists
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_uuid_dir = profiles_dir.join(profile.id.to_string());
|
||||
let metadata_file = profile_uuid_dir.join("metadata.json");
|
||||
let metadata_exists = metadata_file.exists();
|
||||
|
||||
if process_id_changed && metadata_exists {
|
||||
let mut updated_profile = profile.clone();
|
||||
updated_profile.process_id = camoufox_process.processId;
|
||||
if let Err(e) = self.save_profile(&updated_profile) {
|
||||
println!("Warning: Failed to update Camoufox profile with process info: {e}");
|
||||
}
|
||||
if metadata_exists {
|
||||
// Load latest to avoid overwriting other fields
|
||||
let mut latest: BrowserProfile = match std::fs::read_to_string(&metadata_file)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
{
|
||||
Some(p) => p,
|
||||
None => profile.clone(),
|
||||
};
|
||||
|
||||
// Emit profile update event to frontend
|
||||
if let Err(e) = app_handle.emit("profile-updated", &updated_profile) {
|
||||
println!("Warning: Failed to emit profile update event: {e}");
|
||||
}
|
||||
if latest.process_id != camoufox_process.processId {
|
||||
latest.process_id = camoufox_process.processId;
|
||||
if let Err(e) = self.save_profile(&latest) {
|
||||
println!("Warning: Failed to update Camoufox profile with process info: {e}");
|
||||
}
|
||||
|
||||
println!(
|
||||
"Camoufox process has started for profile '{}' with PID: {:?}",
|
||||
profile.name, camoufox_process.processId
|
||||
);
|
||||
// Emit profile update event to frontend
|
||||
if let Err(e) = app_handle.emit("profile-updated", &latest) {
|
||||
println!("Warning: Failed to emit profile update event: {e}");
|
||||
}
|
||||
|
||||
println!(
|
||||
"Camoufox process has started for profile '{}' with PID: {:?}",
|
||||
profile.name, camoufox_process.processId
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
Ok(None) => {
|
||||
// No running instance found, clear process ID if set
|
||||
// No running instance found, clear process ID if set and stop proxy
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_uuid_dir = profiles_dir.join(profile.id.to_string());
|
||||
let metadata_file = profile_uuid_dir.join("metadata.json");
|
||||
let metadata_exists = metadata_file.exists();
|
||||
|
||||
if profile.process_id.is_some() && metadata_exists {
|
||||
let mut updated_profile = profile.clone();
|
||||
updated_profile.process_id = None;
|
||||
if let Err(e) = self.save_profile(&updated_profile) {
|
||||
println!("Warning: Failed to clear Camoufox profile process info: {e}");
|
||||
}
|
||||
if metadata_exists {
|
||||
let mut latest: BrowserProfile = match std::fs::read_to_string(&metadata_file)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
{
|
||||
Some(p) => p,
|
||||
None => profile.clone(),
|
||||
};
|
||||
|
||||
// Emit profile update event to frontend
|
||||
if let Err(e) = app_handle.emit("profile-updated", &updated_profile) {
|
||||
println!("Warning: Failed to emit profile update event: {e}");
|
||||
if let Some(old_pid) = latest.process_id {
|
||||
latest.process_id = None;
|
||||
if let Err(e) = self.save_profile(&latest) {
|
||||
println!("Warning: Failed to clear Camoufox profile process info: {e}");
|
||||
}
|
||||
|
||||
// Stop any proxy tied to this old PID immediately
|
||||
let _ = crate::proxy_manager::PROXY_MANAGER
|
||||
.stop_proxy(app_handle.clone(), old_pid)
|
||||
.await;
|
||||
|
||||
// Emit profile update event to frontend
|
||||
if let Err(e) = app_handle.emit("profile-updated", &latest) {
|
||||
println!("Warning: Failed to emit profile update event: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
@@ -845,16 +869,30 @@ impl ProfileManager {
|
||||
let metadata_file = profile_uuid_dir.join("metadata.json");
|
||||
let metadata_exists = metadata_file.exists();
|
||||
|
||||
if profile.process_id.is_some() && metadata_exists {
|
||||
let mut updated_profile = profile.clone();
|
||||
updated_profile.process_id = None;
|
||||
if let Err(e) = self.save_profile(&updated_profile) {
|
||||
println!("Warning: Failed to clear Camoufox profile process info after error: {e}");
|
||||
}
|
||||
if metadata_exists {
|
||||
let mut latest: BrowserProfile = match std::fs::read_to_string(&metadata_file)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
{
|
||||
Some(p) => p,
|
||||
None => profile.clone(),
|
||||
};
|
||||
|
||||
// Emit profile update event to frontend
|
||||
if let Err(e) = app_handle.emit("profile-updated", &updated_profile) {
|
||||
println!("Warning: Failed to emit profile update event: {e}");
|
||||
if let Some(old_pid) = latest.process_id {
|
||||
latest.process_id = None;
|
||||
if let Err(e2) = self.save_profile(&latest) {
|
||||
println!("Warning: Failed to clear Camoufox profile process info after error: {e2}");
|
||||
}
|
||||
|
||||
// Best-effort stop of proxy tied to old PID
|
||||
let _ = crate::proxy_manager::PROXY_MANAGER
|
||||
.stop_proxy(app_handle.clone(), old_pid)
|
||||
.await;
|
||||
|
||||
// Emit profile update event to frontend
|
||||
if let Err(e3) = app_handle.emit("profile-updated", &latest) {
|
||||
println!("Warning: Failed to emit profile update event: {e3}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
|
||||
@@ -20,6 +20,8 @@ pub struct BrowserProfile {
|
||||
pub camoufox_config: Option<CamoufoxConfig>, // Camoufox configuration
|
||||
#[serde(default)]
|
||||
pub group_id: Option<String>, // Reference to profile group
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>, // Free-form tags
|
||||
}
|
||||
|
||||
pub fn default_release_type() -> String {
|
||||
|
||||
@@ -555,6 +555,7 @@ impl ProfileImporter {
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
group_id: None,
|
||||
tags: Vec::new(),
|
||||
};
|
||||
|
||||
// Save the profile metadata
|
||||
|
||||
@@ -350,29 +350,6 @@ impl ProxyManager {
|
||||
let _ = self.stop_proxy(app_handle.clone(), browser_pid).await;
|
||||
}
|
||||
|
||||
// Check if we have a preferred port for this profile
|
||||
let preferred_port = if let Some(name) = profile_name {
|
||||
let profile_proxies = self.profile_proxies.lock().unwrap();
|
||||
profile_proxies.get(name).and_then(|_settings| {
|
||||
// Find existing proxy with same settings to reuse port
|
||||
let active_proxies = self.active_proxies.lock().unwrap();
|
||||
active_proxies
|
||||
.values()
|
||||
.find(|p| {
|
||||
if let Some(proxy_settings) = proxy_settings {
|
||||
p.upstream_host == proxy_settings.host
|
||||
&& p.upstream_port == proxy_settings.port
|
||||
&& p.upstream_type == proxy_settings.proxy_type
|
||||
} else {
|
||||
p.upstream_type == "DIRECT"
|
||||
}
|
||||
})
|
||||
.map(|p| p.local_port)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Start a new proxy using the nodecar binary with the correct CLI interface
|
||||
let mut nodecar = app_handle
|
||||
.shell()
|
||||
@@ -400,11 +377,6 @@ impl ProxyManager {
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a preferred port, use it
|
||||
if let Some(port) = preferred_port {
|
||||
nodecar = nodecar.arg("--port").arg(port.to_string());
|
||||
}
|
||||
|
||||
// Execute the command and wait for it to complete
|
||||
// The nodecar binary should start the worker and then exit
|
||||
let output = nodecar
|
||||
@@ -449,6 +421,30 @@ impl ProxyManager {
|
||||
profile_name: profile_name.map(|s| s.to_string()),
|
||||
};
|
||||
|
||||
// Wait for the local proxy port to be ready to accept connections
|
||||
{
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::{sleep, Duration};
|
||||
let mut ready = false;
|
||||
for _ in 0..50 {
|
||||
match TcpStream::connect((std::net::Ipv4Addr::LOCALHOST, proxy_info.local_port)).await {
|
||||
Ok(_stream) => {
|
||||
ready = true;
|
||||
break;
|
||||
}
|
||||
Err(_) => {
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !ready {
|
||||
return Err(format!(
|
||||
"Local proxy on 127.0.0.1:{} did not become ready in time",
|
||||
proxy_info.local_port
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Store the proxy info
|
||||
{
|
||||
let mut proxies = self.active_proxies.lock().unwrap();
|
||||
|
||||
@@ -27,17 +27,30 @@ pub struct AppSettings {
|
||||
pub set_as_default_browser: bool,
|
||||
#[serde(default = "default_theme")]
|
||||
pub theme: String, // "light", "dark", or "system"
|
||||
#[serde(default)]
|
||||
pub custom_theme: Option<std::collections::HashMap<String, String>>, // 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -321,6 +334,9 @@ mod tests {
|
||||
let test_settings = AppSettings {
|
||||
set_as_default_browser: true,
|
||||
theme: "dark".to_string(),
|
||||
custom_theme: None,
|
||||
api_enabled: false,
|
||||
api_port: 10108,
|
||||
};
|
||||
|
||||
// Save settings
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
use crate::profile::BrowserProfile;
|
||||
use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
|
||||
struct TagsData {
|
||||
tags: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct TagManager {
|
||||
base_dirs: BaseDirs,
|
||||
data_dir_override: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl TagManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
data_dir_override: std::env::var("DONUTBROWSER_DATA_DIR")
|
||||
.ok()
|
||||
.map(PathBuf::from),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn with_data_dir_override(dir: &Path) -> Self {
|
||||
Self {
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
data_dir_override: Some(dir.to_path_buf()),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_tags_file_path(&self) -> PathBuf {
|
||||
if let Some(dir) = &self.data_dir_override {
|
||||
let mut override_path = dir.clone();
|
||||
let _ = fs::create_dir_all(&override_path);
|
||||
override_path.push("tags.json");
|
||||
return override_path;
|
||||
}
|
||||
|
||||
let mut path = self.base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("data");
|
||||
path.push("tags.json");
|
||||
path
|
||||
}
|
||||
|
||||
fn load_tags_data(&self) -> Result<TagsData, Box<dyn std::error::Error>> {
|
||||
let file_path = self.get_tags_file_path();
|
||||
if !file_path.exists() {
|
||||
return Ok(TagsData::default());
|
||||
}
|
||||
let content = fs::read_to_string(file_path)?;
|
||||
let data: TagsData = serde_json::from_str(&content)?;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
fn save_tags_data(&self, data: &TagsData) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let file_path = self.get_tags_file_path();
|
||||
if let Some(parent) = file_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(data)?;
|
||||
fs::write(file_path, json)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_all_tags(&self) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||
let mut all = self.load_tags_data()?.tags;
|
||||
// Ensure deterministic order
|
||||
all.sort();
|
||||
all.dedup();
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
pub fn rebuild_from_profiles(
|
||||
&self,
|
||||
profiles: &[BrowserProfile],
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||
// Build a set of all tags currently used by any profile
|
||||
let mut set: BTreeSet<String> = BTreeSet::new();
|
||||
for profile in profiles {
|
||||
for tag in &profile.tags {
|
||||
// Store exactly as provided (no normalization) to preserve characters
|
||||
set.insert(tag.clone());
|
||||
}
|
||||
}
|
||||
let combined: Vec<String> = set.into_iter().collect();
|
||||
self.save_tags_data(&TagsData {
|
||||
tags: combined.clone(),
|
||||
})?;
|
||||
Ok(combined)
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref TAG_MANAGER: std::sync::Mutex<TagManager> = std::sync::Mutex::new(TagManager::new());
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Donut Browser",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.0",
|
||||
"identifier": "com.donutbrowser",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased overflow-hidden`}
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased overflow-hidden bg-background`}
|
||||
>
|
||||
<CustomThemeProvider>
|
||||
<WindowDragArea />
|
||||
|
||||
+125
-108
@@ -3,7 +3,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrent } from "@tauri-apps/plugin-deep-link";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
|
||||
import { CreateProfileDialog } from "@/components/create-profile-dialog";
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
||||
@@ -21,6 +21,7 @@ import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications"
|
||||
import type { PermissionType } from "@/hooks/use-permissions";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { useUpdateNotifications } from "@/hooks/use-update-notifications";
|
||||
import { useVersionUpdater } from "@/hooks/use-version-updater";
|
||||
import { showErrorToast, showToast } from "@/lib/toast-utils";
|
||||
import type { BrowserProfile, CamoufoxConfig, GroupWithCount } from "@/types";
|
||||
|
||||
@@ -40,6 +41,8 @@ interface PendingUrl {
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
// Mount global version update listener/toasts
|
||||
useVersionUpdater();
|
||||
const [isInitializing, setIsInitializing] = useState(true);
|
||||
const [profiles, setProfiles] = useState<BrowserProfile[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -73,7 +76,9 @@ export default function Home() {
|
||||
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
|
||||
const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } =
|
||||
usePermissions();
|
||||
|
||||
const [runningProfiles, setRunningProfiles] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const handleSelectGroup = useCallback((groupId: string) => {
|
||||
setSelectedGroupId(groupId);
|
||||
setSelectedProfiles([]);
|
||||
@@ -154,6 +159,46 @@ export default function Home() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Function to check and sync profile running states with actual process status
|
||||
const syncProfileRunningStates = useCallback(
|
||||
async (profiles: BrowserProfile[]) => {
|
||||
try {
|
||||
const statusChecks = profiles.map(async (profile) => {
|
||||
try {
|
||||
const isRunning = await invoke<boolean>("check_browser_status", {
|
||||
profile,
|
||||
});
|
||||
return { id: profile.id, isRunning };
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to check status for profile ${profile.name}:`,
|
||||
error,
|
||||
);
|
||||
return { id: profile.id, isRunning: false };
|
||||
}
|
||||
});
|
||||
|
||||
const statuses = await Promise.all(statusChecks);
|
||||
|
||||
// Update running profiles state based on actual status
|
||||
setRunningProfiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
statuses.forEach(({ id, isRunning }) => {
|
||||
if (isRunning) {
|
||||
next.add(id);
|
||||
} else {
|
||||
next.delete(id);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to sync profile running states:", error);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Simple profiles loader without updates check (for use as callback)
|
||||
const loadProfiles = useCallback(async () => {
|
||||
try {
|
||||
@@ -162,13 +207,16 @@ export default function Home() {
|
||||
);
|
||||
setProfiles(profileList);
|
||||
|
||||
// Check and sync profile running status after loading profiles
|
||||
await syncProfileRunningStates(profileList);
|
||||
|
||||
// Check for missing binaries after loading profiles
|
||||
await checkMissingBinaries();
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to load profiles:", err);
|
||||
setError(`Failed to load profiles: ${JSON.stringify(err)}`);
|
||||
}
|
||||
}, [checkMissingBinaries]);
|
||||
}, [checkMissingBinaries, syncProfileRunningStates]);
|
||||
|
||||
const [processingUrls, setProcessingUrls] = useState<Set<string>>(new Set());
|
||||
|
||||
@@ -214,6 +262,9 @@ export default function Home() {
|
||||
);
|
||||
setProfiles(profileList);
|
||||
|
||||
// Check and sync profile running status after loading profiles
|
||||
await syncProfileRunningStates(profileList);
|
||||
|
||||
// Check for updates after loading profiles
|
||||
await checkForUpdates();
|
||||
await checkMissingBinaries();
|
||||
@@ -221,7 +272,7 @@ export default function Home() {
|
||||
console.error("Failed to load profiles:", err);
|
||||
setError(`Failed to load profiles: ${JSON.stringify(err)}`);
|
||||
}
|
||||
}, [checkForUpdates, checkMissingBinaries]);
|
||||
}, [checkForUpdates, checkMissingBinaries, syncProfileRunningStates]);
|
||||
|
||||
useAppUpdateNotifications();
|
||||
|
||||
@@ -420,20 +471,17 @@ export default function Home() {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const _profile = await invoke<BrowserProfile>(
|
||||
"create_browser_profile_new",
|
||||
{
|
||||
name: profileData.name,
|
||||
browserStr: profileData.browserStr,
|
||||
version: profileData.version,
|
||||
releaseType: profileData.releaseType,
|
||||
proxyId: profileData.proxyId,
|
||||
camoufoxConfig: profileData.camoufoxConfig,
|
||||
groupId:
|
||||
profileData.groupId ||
|
||||
(selectedGroupId !== "default" ? selectedGroupId : undefined),
|
||||
},
|
||||
);
|
||||
await invoke<BrowserProfile>("create_browser_profile_new", {
|
||||
name: profileData.name,
|
||||
browserStr: profileData.browserStr,
|
||||
version: profileData.version,
|
||||
releaseType: profileData.releaseType,
|
||||
proxyId: profileData.proxyId,
|
||||
camoufoxConfig: profileData.camoufoxConfig,
|
||||
groupId:
|
||||
profileData.groupId ||
|
||||
(selectedGroupId !== "default" ? selectedGroupId : undefined),
|
||||
});
|
||||
|
||||
await loadProfiles();
|
||||
await loadGroups();
|
||||
@@ -450,77 +498,48 @@ export default function Home() {
|
||||
[loadProfiles, loadGroups, selectedGroupId],
|
||||
);
|
||||
|
||||
const [runningProfiles, setRunningProfiles] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const runningProfilesRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const checkBrowserStatus = useCallback(async (profile: BrowserProfile) => {
|
||||
try {
|
||||
const isRunning = await invoke<boolean>("check_browser_status", {
|
||||
profile,
|
||||
});
|
||||
|
||||
const currentRunning = runningProfilesRef.current.has(profile.name);
|
||||
|
||||
if (isRunning !== currentRunning) {
|
||||
console.log(
|
||||
`Profile ${profile.name} (${profile.browser}) status changed: ${currentRunning} -> ${isRunning}`,
|
||||
);
|
||||
setRunningProfiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (isRunning) {
|
||||
next.add(profile.name);
|
||||
} else {
|
||||
next.delete(profile.name);
|
||||
}
|
||||
runningProfilesRef.current = next;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to check browser status:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const launchProfile = useCallback(
|
||||
async (profile: BrowserProfile) => {
|
||||
setError(null);
|
||||
|
||||
// Check if browser is disabled due to ongoing update
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
(async () => {
|
||||
try {
|
||||
const isDisabled = await invoke<boolean>(
|
||||
"is_browser_disabled_for_update",
|
||||
{
|
||||
browser: profile.browser,
|
||||
unlisten = await listen<{ id: string; is_running: boolean }>(
|
||||
"profile-running-changed",
|
||||
(event) => {
|
||||
const { id, is_running } = event.payload;
|
||||
setRunningProfiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (is_running) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
if (isDisabled || isUpdating(profile.browser)) {
|
||||
setError(
|
||||
`${profile.browser} is currently being updated. Please wait for the update to complete.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to check browser update status:", err);
|
||||
} catch {
|
||||
// best-effort listener
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, []);
|
||||
|
||||
try {
|
||||
const updatedProfile = await invoke<BrowserProfile>(
|
||||
"launch_browser_profile",
|
||||
{ profile },
|
||||
);
|
||||
await loadProfiles();
|
||||
await checkBrowserStatus(updatedProfile);
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to launch browser:", err);
|
||||
setError(`Failed to launch browser: ${JSON.stringify(err)}`);
|
||||
}
|
||||
},
|
||||
[loadProfiles, checkBrowserStatus, isUpdating],
|
||||
);
|
||||
const launchProfile = useCallback(async (profile: BrowserProfile) => {
|
||||
setError(null);
|
||||
console.log("Starting launch for profile:", profile.name);
|
||||
|
||||
try {
|
||||
const result = await invoke<BrowserProfile>("launch_browser_profile", {
|
||||
profile,
|
||||
});
|
||||
console.log("Successfully launched profile:", result.name);
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to launch browser:", err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
setError(`Failed to launch browser: ${errorMessage}`);
|
||||
// Re-throw the error so the table component can handle loading state cleanup
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDeleteProfile = useCallback(
|
||||
async (profile: BrowserProfile) => {
|
||||
@@ -579,12 +598,19 @@ export default function Home() {
|
||||
const handleKillProfile = useCallback(
|
||||
async (profile: BrowserProfile) => {
|
||||
setError(null);
|
||||
console.log("Starting kill for profile:", profile.name);
|
||||
|
||||
try {
|
||||
await invoke("kill_browser_profile", { profile });
|
||||
await loadProfiles();
|
||||
console.log("Successfully killed profile:", profile.name);
|
||||
// Don't reload profiles here - let the backend events handle UI updates
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to kill browser:", err);
|
||||
setError(`Failed to kill browser: ${JSON.stringify(err)}`);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
setError(`Failed to kill browser: ${errorMessage}`);
|
||||
// Re-throw the error so the table component can handle loading state cleanup
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[loadProfiles],
|
||||
@@ -729,24 +755,6 @@ export default function Home() {
|
||||
}
|
||||
}, [profiles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (profiles.length === 0) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
for (const profile of profiles) {
|
||||
void checkBrowserStatus(profile);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [profiles, checkBrowserStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
runningProfilesRef.current = runningProfiles;
|
||||
}, [runningProfiles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
showErrorToast(error);
|
||||
@@ -761,8 +769,17 @@ export default function Home() {
|
||||
}
|
||||
}, [isInitialized, checkAllPermissions]);
|
||||
|
||||
// Filter data by selected group
|
||||
const filteredProfiles = useMemo(() => {
|
||||
if (!selectedGroupId || selectedGroupId === "default") {
|
||||
return profiles.filter((profile) => !profile.group_id);
|
||||
}
|
||||
|
||||
return profiles.filter((profile) => profile.group_id === selectedGroupId);
|
||||
}, [profiles, selectedGroupId]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen gap-8 font-[family-name:var(--font-geist-sans)] bg-white dark:bg-black">
|
||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen gap-8 font-[family-name:var(--font-geist-sans)] bg-background">
|
||||
<main className="flex flex-col row-start-2 gap-6 items-center w-full max-w-3xl">
|
||||
<div className="w-full">
|
||||
<HomeHeader
|
||||
@@ -784,7 +801,7 @@ export default function Home() {
|
||||
isLoading={areGroupsLoading}
|
||||
/>
|
||||
<ProfilesDataTable
|
||||
data={profiles}
|
||||
profiles={filteredProfiles}
|
||||
onLaunchProfile={launchProfile}
|
||||
onKillProfile={handleKillProfile}
|
||||
onDeleteProfile={handleDeleteProfile}
|
||||
@@ -802,13 +819,13 @@ export default function Home() {
|
||||
</main>
|
||||
|
||||
{isInitializing && (
|
||||
<div className="fixed inset-0 z-[100000] backdrop-blur-sm bg-black/30 flex items-center justify-center">
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-xl p-6 shadow-xl border border-black/10 dark:border-white/10 w-[320px] text-center">
|
||||
<div className="fixed inset-0 z-[1000] backdrop-blur-sm bg-background/30 flex items-center justify-center">
|
||||
<div className="bg-background rounded-xl p-6 shadow-xl border border-border/10 w-[320px] text-center">
|
||||
<div className="text-lg font-medium">Initializing</div>
|
||||
<div className="mt-1 mb-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
Please don't close the app
|
||||
</div>
|
||||
<div className="mx-auto mb-4 w-8 h-8 rounded-full border-2 border-gray-300 animate-spin border-t-gray-900 dark:border-gray-700 dark:border-t-white" />
|
||||
<div className="mx-auto mb-4 w-8 h-8 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { GoPlus } from "react-icons/go";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
@@ -107,7 +108,7 @@ export function CreateProfileDialog({
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
if (value === "regular") {
|
||||
setSelectedBrowser(null);
|
||||
setSelectedBrowser("firefox");
|
||||
} else if (value === "anti-detect") {
|
||||
setSelectedBrowser("camoufox");
|
||||
}
|
||||
@@ -178,6 +179,8 @@ export function CreateProfileDialog({
|
||||
{ browserStr: browser },
|
||||
);
|
||||
|
||||
await loadDownloadedVersions(browser);
|
||||
|
||||
// Only update state if this browser is still the one we're loading
|
||||
if (loadingBrowserRef.current === browser) {
|
||||
// Filter to enforce stable-only creation, except Firefox Developer (nightly-only)
|
||||
@@ -197,12 +200,29 @@ export function CreateProfileDialog({
|
||||
filtered.stable = rawReleaseTypes.stable;
|
||||
setReleaseTypes(filtered);
|
||||
}
|
||||
|
||||
// Load downloaded versions for this browser
|
||||
await loadDownloadedVersions(browser);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load release types for ${browser}:`, error);
|
||||
|
||||
// Fallback: still load downloaded versions and derive release type from them if possible
|
||||
try {
|
||||
const downloaded = await loadDownloadedVersions(browser);
|
||||
if (loadingBrowserRef.current === browser && downloaded.length > 0) {
|
||||
const latest = downloaded[0];
|
||||
const fallback: BrowserReleaseTypes = {};
|
||||
if (browser === "firefox-developer") {
|
||||
fallback.nightly = latest;
|
||||
} else {
|
||||
fallback.stable = latest;
|
||||
}
|
||||
setReleaseTypes(fallback);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed to load downloaded versions for ${browser}:`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
// Clear loading state only if we're still loading this browser
|
||||
if (loadingBrowserRef.current === browser) {
|
||||
@@ -216,10 +236,14 @@ export function CreateProfileDialog({
|
||||
// Load data when dialog opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// Ensure we have a selected browser
|
||||
if (!selectedBrowser) {
|
||||
setSelectedBrowser("camoufox");
|
||||
}
|
||||
void loadSupportedBrowsers();
|
||||
void loadStoredProxies();
|
||||
// Load camoufox release types when dialog opens
|
||||
void loadReleaseTypes("camoufox");
|
||||
void loadReleaseTypes(selectedBrowser || "camoufox");
|
||||
// Check and download GeoIP database if needed for Camoufox
|
||||
void checkAndDownloadGeoIPDatabase();
|
||||
}
|
||||
@@ -229,6 +253,7 @@ export function CreateProfileDialog({
|
||||
loadStoredProxies,
|
||||
loadReleaseTypes,
|
||||
checkAndDownloadGeoIPDatabase,
|
||||
selectedBrowser,
|
||||
]);
|
||||
|
||||
// Load release types when browser selection changes
|
||||
@@ -341,7 +366,7 @@ export function CreateProfileDialog({
|
||||
|
||||
// Reset all states
|
||||
setProfileName("");
|
||||
setSelectedBrowser(null);
|
||||
setSelectedBrowser("camoufox"); // Set default browser instead of null
|
||||
setSelectedProxyId(undefined);
|
||||
setReleaseTypes({});
|
||||
setCamoufoxConfig({
|
||||
@@ -386,20 +411,6 @@ export function CreateProfileDialog({
|
||||
isBrowserVersionAvailable,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log(
|
||||
selectedBrowser,
|
||||
selectedBrowser && isBrowserCurrentlyDownloading(selectedBrowser),
|
||||
selectedBrowser && isBrowserVersionAvailable(selectedBrowser),
|
||||
selectedBrowser && getBestAvailableVersion(selectedBrowser),
|
||||
);
|
||||
}, [
|
||||
selectedBrowser,
|
||||
isBrowserCurrentlyDownloading,
|
||||
isBrowserVersionAvailable,
|
||||
getBestAvailableVersion,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="w-full max-h-[90vh] flex flex-col">
|
||||
@@ -634,6 +645,7 @@ export function CreateProfileDialog({
|
||||
onSave={(proxy) => {
|
||||
setStoredProxies((prev) => [...prev, proxy]);
|
||||
setSelectedProxyId(proxy.id);
|
||||
void emit("stored-proxies-changed");
|
||||
}}
|
||||
/>
|
||||
</Dialog>
|
||||
|
||||
@@ -168,7 +168,7 @@ export function UnifiedToast(props: ToastProps) {
|
||||
const progress = "progress" in props ? props.progress : undefined;
|
||||
|
||||
return (
|
||||
<div className="flex items-start p-4 w-full max-w-md rounded-lg border shadow-lg bg-card border-border text-card-foreground">
|
||||
<div className="flex items-start p-3 w-96 rounded-lg border shadow-lg bg-card border-border text-card-foreground">
|
||||
<div className="mr-3 mt-0.5">{getToastIcon(type, stage)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold leading-tight text-foreground">
|
||||
|
||||
@@ -352,6 +352,10 @@ const MultipleSelector = React.forwardRef<
|
||||
[options, selected],
|
||||
);
|
||||
|
||||
const hasAvailableOptions = React.useMemo(() => {
|
||||
return Object.values(selectables).some((group) => group.length > 0);
|
||||
}, [selectables]);
|
||||
|
||||
/** Avoid Creatable Selector freezing or lagging when paste a long string. */
|
||||
const commandFilter = React.useCallback(() => {
|
||||
if (commandProps?.filter) {
|
||||
@@ -416,7 +420,7 @@ const MultipleSelector = React.forwardRef<
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
"cursor-pointer ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
(disabled || option.fixed) && "hidden",
|
||||
)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -445,6 +449,40 @@ const MultipleSelector = React.forwardRef<
|
||||
setInputValue(value);
|
||||
inputProps?.onValueChange?.(value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Allow consumer to handle first
|
||||
inputProps?.onKeyDown?.(
|
||||
e as unknown as React.KeyboardEvent<HTMLInputElement>,
|
||||
);
|
||||
if (e.defaultPrevented) return;
|
||||
if (e.key === "Enter") {
|
||||
const value = inputValue.trim();
|
||||
if (value.length === 0) return;
|
||||
// If option already exists among available options, pick that; otherwise create
|
||||
const entries = Object.values(options).flat();
|
||||
const existing = entries.find(
|
||||
(o) => o.value === value && !o.disable,
|
||||
);
|
||||
// Prevent duplicates in the current selection
|
||||
if (
|
||||
selected.some((s) => s.value === (existing?.value ?? value))
|
||||
) {
|
||||
e.preventDefault();
|
||||
setInputValue("");
|
||||
return;
|
||||
}
|
||||
if (selected.length >= maxSelected) {
|
||||
onMaxSelected?.(selected.length);
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
setInputValue("");
|
||||
const picked = existing ?? { value, label: value };
|
||||
const newOptions = [...selected, picked];
|
||||
setSelected(newOptions);
|
||||
onChange?.(newOptions);
|
||||
}
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
setOpen(false);
|
||||
inputProps?.onBlur?.(event);
|
||||
@@ -465,7 +503,7 @@ const MultipleSelector = React.forwardRef<
|
||||
"flex-1 bg-transparent outline-none placeholder:text-muted-foreground",
|
||||
{
|
||||
"w-full": hidePlaceholderWhenSelected,
|
||||
"px-3 py-2": selected.length === 0,
|
||||
"px-3 mt-1": selected.length === 0,
|
||||
"ml-1": selected.length !== 0,
|
||||
},
|
||||
inputProps?.className,
|
||||
@@ -474,7 +512,7 @@ const MultipleSelector = React.forwardRef<
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
{open && (
|
||||
{open && hasAvailableOptions && (
|
||||
<CommandList className="absolute top-1 z-10 w-full rounded-md border shadow-md outline-none bg-popover text-popover-foreground animate-in">
|
||||
{isLoading ? (
|
||||
loadingIndicator
|
||||
@@ -489,7 +527,7 @@ const MultipleSelector = React.forwardRef<
|
||||
<CommandGroup
|
||||
key={key}
|
||||
heading={key}
|
||||
className="overflow-auto h-full"
|
||||
className="overflow-auto h-24"
|
||||
>
|
||||
{dropdowns.map((option) => {
|
||||
return (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -97,7 +97,7 @@ export function ProfileSelectorDialog({
|
||||
if (profileList.length > 0) {
|
||||
// First, try to find a running profile that can be used for opening links
|
||||
const runningAvailableProfile = profileList.find((profile) => {
|
||||
const isRunning = runningProfiles.has(profile.name);
|
||||
const isRunning = runningProfiles.has(profile.id);
|
||||
// Simple check without browserState dependency
|
||||
return (
|
||||
isRunning &&
|
||||
@@ -128,7 +128,10 @@ export function ProfileSelectorDialog({
|
||||
if (!selectedProfile || !url) return;
|
||||
|
||||
setIsLaunching(true);
|
||||
setLaunchingProfiles((prev) => new Set(prev).add(selectedProfile));
|
||||
const selected = profiles.find((p) => p.name === selectedProfile);
|
||||
if (selected) {
|
||||
setLaunchingProfiles((prev) => new Set(prev).add(selected.id));
|
||||
}
|
||||
try {
|
||||
await invoke("open_url_with_profile", {
|
||||
profileName: selectedProfile,
|
||||
@@ -139,13 +142,15 @@ export function ProfileSelectorDialog({
|
||||
console.error("Failed to open URL with profile:", error);
|
||||
} finally {
|
||||
setIsLaunching(false);
|
||||
setLaunchingProfiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(selectedProfile);
|
||||
return next;
|
||||
});
|
||||
if (selected) {
|
||||
setLaunchingProfiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(selected.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [selectedProfile, url, onClose]);
|
||||
}, [selectedProfile, url, onClose, profiles]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setSelectedProfile(null);
|
||||
@@ -238,7 +243,7 @@ export function ProfileSelectorDialog({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{profiles.map((profile) => {
|
||||
const isRunning = runningProfiles.has(profile.name);
|
||||
const isRunning = runningProfiles.has(profile.id);
|
||||
const canUseForLinks =
|
||||
browserState.canUseProfileForLinks(profile);
|
||||
const tooltipContent = getProfileTooltipContent(profile);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { emit, listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { FiEdit2, FiPlus, FiTrash2, FiWifi } from "react-icons/fi";
|
||||
import { toast } from "sonner";
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
||||
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -38,6 +39,8 @@ export function ProxyManagementDialog({
|
||||
const [showProxyForm, setShowProxyForm] = useState(false);
|
||||
const [editingProxy, setEditingProxy] = useState<StoredProxy | null>(null);
|
||||
const [proxyUsage, setProxyUsage] = useState<Record<string, number>>({});
|
||||
const [proxyToDelete, setProxyToDelete] = useState<StoredProxy | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const loadStoredProxies = useCallback(async () => {
|
||||
try {
|
||||
@@ -91,22 +94,46 @@ export function ProxyManagementDialog({
|
||||
};
|
||||
}, [isOpen, loadProxyUsage]);
|
||||
|
||||
const handleDeleteProxy = useCallback(async (proxy: StoredProxy) => {
|
||||
if (
|
||||
!confirm(`Are you sure you want to delete the proxy "${proxy.name}"?`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Keep list in sync with external changes (e.g., created from CreateProfileDialog)
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
const setup = async () => {
|
||||
try {
|
||||
unlisten = await listen("stored-proxies-changed", () => {
|
||||
void loadStoredProxies();
|
||||
void loadProxyUsage();
|
||||
});
|
||||
} catch (_err) {
|
||||
// ignore non-critical errors
|
||||
}
|
||||
};
|
||||
if (isOpen) void setup();
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, [isOpen, loadStoredProxies, loadProxyUsage]);
|
||||
|
||||
const handleDeleteProxy = useCallback((proxy: StoredProxy) => {
|
||||
// Open in-app confirmation dialog
|
||||
setProxyToDelete(proxy);
|
||||
}, []);
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (!proxyToDelete) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await invoke("delete_stored_proxy", { proxyId: proxy.id });
|
||||
setStoredProxies((prev) => prev.filter((p) => p.id !== proxy.id));
|
||||
await invoke("delete_stored_proxy", { proxyId: proxyToDelete.id });
|
||||
setStoredProxies((prev) => prev.filter((p) => p.id !== proxyToDelete.id));
|
||||
toast.success("Proxy deleted successfully");
|
||||
await emit("stored-proxies-changed");
|
||||
} catch (error) {
|
||||
console.error("Failed to delete proxy:", error);
|
||||
toast.error("Failed to delete proxy");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setProxyToDelete(null);
|
||||
}
|
||||
}, []);
|
||||
}, [proxyToDelete]);
|
||||
|
||||
const handleCreateProxy = useCallback(() => {
|
||||
setEditingProxy(null);
|
||||
@@ -133,6 +160,7 @@ export function ProxyManagementDialog({
|
||||
});
|
||||
setShowProxyForm(false);
|
||||
setEditingProxy(null);
|
||||
void emit("stored-proxies-changed");
|
||||
}, []);
|
||||
|
||||
const handleProxyFormClose = useCallback(() => {
|
||||
@@ -241,17 +269,28 @@ export function ProxyManagementDialog({
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteProxy(proxy)}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<FiTrash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<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>
|
||||
<p>Delete proxy</p>
|
||||
{(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>
|
||||
@@ -274,6 +313,15 @@ export function ProxyManagementDialog({
|
||||
onSave={handleProxySaved}
|
||||
editingProxy={editingProxy}
|
||||
/>
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={proxyToDelete !== null}
|
||||
onClose={() => setProxyToDelete(null)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="Delete Proxy"
|
||||
description={`This action cannot be undone. This will permanently delete the proxy "${proxyToDelete?.name ?? ""}".`}
|
||||
confirmButtonText="Delete"
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+388
-104
@@ -1,13 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import Color from "color";
|
||||
import { useTheme } from "next-themes";
|
||||
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,
|
||||
ColorPickerEyeDropper,
|
||||
ColorPickerFormat,
|
||||
ColorPickerHue,
|
||||
ColorPickerOutput,
|
||||
ColorPickerSelection,
|
||||
} from "@/components/ui/color-picker";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -16,6 +25,11 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -25,18 +39,26 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import type { PermissionType } from "@/hooks/use-permissions";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { getBrowserDisplayName } from "@/lib/browser-utils";
|
||||
import {
|
||||
dismissToast,
|
||||
showErrorToast,
|
||||
showSuccessToast,
|
||||
showUnifiedVersionUpdateToast,
|
||||
} from "@/lib/toast-utils";
|
||||
getThemeByColors,
|
||||
getThemeById,
|
||||
THEME_VARIABLES,
|
||||
THEMES,
|
||||
} from "@/lib/themes";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface AppSettings {
|
||||
set_as_default_browser: boolean;
|
||||
theme: string;
|
||||
custom_theme?: Record<string, string>;
|
||||
api_enabled: boolean;
|
||||
api_port: number;
|
||||
}
|
||||
|
||||
interface CustomThemeState {
|
||||
selectedThemeId: string | null;
|
||||
colors: Record<string, string>;
|
||||
}
|
||||
|
||||
interface PermissionInfo {
|
||||
@@ -45,14 +67,7 @@ interface PermissionInfo {
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface VersionUpdateProgress {
|
||||
current_browser: string;
|
||||
total_browsers: number;
|
||||
completed_browsers: number;
|
||||
new_versions_found: number;
|
||||
browser_new_versions: number;
|
||||
status: string; // "updating", "completed", "error"
|
||||
}
|
||||
// Version update progress toasts are handled globally via useVersionUpdater
|
||||
|
||||
interface SettingsDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -63,10 +78,20 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
const [settings, setSettings] = useState<AppSettings>({
|
||||
set_as_default_browser: false,
|
||||
theme: "system",
|
||||
custom_theme: undefined,
|
||||
api_enabled: false,
|
||||
api_port: 10108,
|
||||
});
|
||||
const [originalSettings, setOriginalSettings] = useState<AppSettings>({
|
||||
set_as_default_browser: false,
|
||||
theme: "system",
|
||||
custom_theme: undefined,
|
||||
api_enabled: false,
|
||||
api_port: 10108,
|
||||
});
|
||||
const [customThemeState, setCustomThemeState] = useState<CustomThemeState>({
|
||||
selectedThemeId: null,
|
||||
colors: {},
|
||||
});
|
||||
const [isDefaultBrowser, setIsDefaultBrowser] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -78,6 +103,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
const [requestingPermission, setRequestingPermission] =
|
||||
useState<PermissionType | null>(null);
|
||||
const [isMacOS, setIsMacOS] = useState(false);
|
||||
const [apiServerPort, setApiServerPort] = useState<number | null>(null);
|
||||
|
||||
const { setTheme } = useTheme();
|
||||
const {
|
||||
@@ -123,12 +149,40 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
return "Access to camera for browser applications";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const appSettings = await invoke<AppSettings>("get_app_settings");
|
||||
setSettings(appSettings);
|
||||
setOriginalSettings(appSettings);
|
||||
const tokyoNightTheme = getThemeById("tokyo-night");
|
||||
if (!tokyoNightTheme) {
|
||||
throw new Error("Tokyo Night theme not found");
|
||||
}
|
||||
const merged: AppSettings = {
|
||||
...appSettings,
|
||||
custom_theme:
|
||||
appSettings.custom_theme &&
|
||||
Object.keys(appSettings.custom_theme).length > 0
|
||||
? appSettings.custom_theme
|
||||
: tokyoNightTheme.colors,
|
||||
};
|
||||
setSettings(merged);
|
||||
setOriginalSettings(merged);
|
||||
|
||||
// Initialize custom theme state
|
||||
if (merged.theme === "custom" && merged.custom_theme) {
|
||||
const matchingTheme = getThemeByColors(merged.custom_theme);
|
||||
setCustomThemeState({
|
||||
selectedThemeId: matchingTheme?.id || null,
|
||||
colors: merged.custom_theme,
|
||||
});
|
||||
} else if (merged.theme === "custom") {
|
||||
// Initialize with Tokyo Night if no custom theme exists
|
||||
setCustomThemeState({
|
||||
selectedThemeId: "tokyo-night",
|
||||
colors: tokyoNightTheme.colors,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load settings:", error);
|
||||
} finally {
|
||||
@@ -136,6 +190,20 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const applyCustomTheme = useCallback((vars: Record<string, string>) => {
|
||||
const root = document.documentElement;
|
||||
Object.entries(vars).forEach(([k, v]) =>
|
||||
root.style.setProperty(k, v, "important"),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const clearCustomTheme = useCallback(() => {
|
||||
const root = document.documentElement;
|
||||
THEME_VARIABLES.forEach(({ key }) =>
|
||||
root.style.removeProperty(key as string),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const loadPermissions = useCallback(async () => {
|
||||
setIsLoadingPermissions(true);
|
||||
try {
|
||||
@@ -225,31 +293,152 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
},
|
||||
[getPermissionDisplayName, requestPermission],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await invoke("save_app_settings", { settings });
|
||||
setTheme(settings.theme);
|
||||
setOriginalSettings(settings);
|
||||
// Update settings with current custom theme state
|
||||
const settingsToSave = {
|
||||
...settings,
|
||||
custom_theme:
|
||||
settings.theme === "custom"
|
||||
? customThemeState.colors
|
||||
: settings.custom_theme,
|
||||
};
|
||||
|
||||
await invoke("save_app_settings", { settings: settingsToSave });
|
||||
setTheme(settings.theme === "custom" ? "dark" : settings.theme);
|
||||
|
||||
// Apply or clear custom variables only on Save
|
||||
if (settings.theme === "custom") {
|
||||
if (
|
||||
customThemeState.colors &&
|
||||
Object.keys(customThemeState.colors).length > 0
|
||||
) {
|
||||
try {
|
||||
const root = document.documentElement;
|
||||
// Clear any previous custom vars first
|
||||
THEME_VARIABLES.forEach(({ key }) =>
|
||||
root.style.removeProperty(key as string),
|
||||
);
|
||||
Object.entries(customThemeState.colors).forEach(([k, v]) =>
|
||||
root.style.setProperty(k, v, "important"),
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const root = document.documentElement;
|
||||
THEME_VARIABLES.forEach(({ key }) =>
|
||||
root.style.removeProperty(key as string),
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Handle API server start/stop based on settings
|
||||
const wasApiEnabled = originalSettings.api_enabled;
|
||||
const isApiEnabled = settingsToSave.api_enabled;
|
||||
|
||||
if (isApiEnabled && !wasApiEnabled) {
|
||||
// Start API server
|
||||
try {
|
||||
const port = await invoke<number>("start_api_server", {
|
||||
port: settingsToSave.api_port,
|
||||
});
|
||||
setApiServerPort(port);
|
||||
showSuccessToast(`Local API started on port ${port}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to start API server:", error);
|
||||
showErrorToast("Failed to start API server", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Unknown error occurred",
|
||||
});
|
||||
// Revert the API enabled setting if start failed
|
||||
settingsToSave.api_enabled = false;
|
||||
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) {
|
||||
console.error("Failed to save settings:", error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [onClose, setTheme, settings]);
|
||||
}, [onClose, setTheme, settings, customThemeState, originalSettings]);
|
||||
|
||||
const updateSetting = useCallback(
|
||||
(key: keyof AppSettings, value: boolean | string) => {
|
||||
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||
(
|
||||
key: keyof AppSettings,
|
||||
value: boolean | string | Record<string, string> | undefined,
|
||||
) => {
|
||||
setSettings((prev) => ({ ...prev, [key]: value as unknown as never }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const loadApiServerStatus = useCallback(async () => {
|
||||
try {
|
||||
const port = await invoke<number | null>("get_api_server_status");
|
||||
setApiServerPort(port);
|
||||
} catch (error) {
|
||||
console.error("Failed to load API server status:", error);
|
||||
setApiServerPort(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
// Restore original theme when closing without saving
|
||||
if (originalSettings.theme === "custom" && originalSettings.custom_theme) {
|
||||
applyCustomTheme(originalSettings.custom_theme);
|
||||
} else {
|
||||
clearCustomTheme();
|
||||
}
|
||||
|
||||
// Reset custom theme state to original
|
||||
if (originalSettings.theme === "custom" && originalSettings.custom_theme) {
|
||||
const matchingTheme = getThemeByColors(originalSettings.custom_theme);
|
||||
setCustomThemeState({
|
||||
selectedThemeId: matchingTheme?.id || null,
|
||||
colors: originalSettings.custom_theme,
|
||||
});
|
||||
}
|
||||
|
||||
onClose();
|
||||
}, [
|
||||
originalSettings.theme,
|
||||
originalSettings.custom_theme,
|
||||
applyCustomTheme,
|
||||
clearCustomTheme,
|
||||
onClose,
|
||||
]);
|
||||
|
||||
// Only clear custom theme when switching away from custom, don't apply live changes
|
||||
useEffect(() => {
|
||||
if (settings.theme !== "custom") {
|
||||
clearCustomTheme();
|
||||
}
|
||||
}, [settings.theme, clearCustomTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadSettings().catch(console.error);
|
||||
checkDefaultBrowserStatus().catch(console.error);
|
||||
loadApiServerStatus().catch(console.error);
|
||||
|
||||
// Check if we're on macOS
|
||||
const userAgent = navigator.userAgent;
|
||||
@@ -265,86 +454,18 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
checkDefaultBrowserStatus().catch(console.error);
|
||||
}, 500); // Check every 500ms
|
||||
|
||||
// Listen for version update progress events
|
||||
let unlistenFn: (() => void) | null = null;
|
||||
const setupVersionUpdateListener = async () => {
|
||||
try {
|
||||
unlistenFn = await listen<VersionUpdateProgress>(
|
||||
"version-update-progress",
|
||||
(event) => {
|
||||
const progress = event.payload;
|
||||
|
||||
if (progress.status === "updating") {
|
||||
// Show unified progress toast
|
||||
const currentBrowserName = progress.current_browser
|
||||
? getBrowserDisplayName(progress.current_browser)
|
||||
: undefined;
|
||||
|
||||
showUnifiedVersionUpdateToast(
|
||||
"Checking for browser updates...",
|
||||
{
|
||||
description: currentBrowserName
|
||||
? `Fetching ${currentBrowserName} release information...`
|
||||
: "Initializing version check...",
|
||||
progress: {
|
||||
current: progress.completed_browsers,
|
||||
total: progress.total_browsers,
|
||||
found: progress.new_versions_found,
|
||||
current_browser: currentBrowserName,
|
||||
},
|
||||
},
|
||||
);
|
||||
} else if (progress.status === "completed") {
|
||||
dismissToast("unified-version-update");
|
||||
|
||||
if (progress.new_versions_found > 0) {
|
||||
showSuccessToast("Browser versions updated successfully", {
|
||||
duration: 5000,
|
||||
description:
|
||||
"Auto-downloads will start shortly for available updates.",
|
||||
});
|
||||
} else {
|
||||
showSuccessToast("No new browser versions found", {
|
||||
duration: 3000,
|
||||
description: "All browser versions are up to date",
|
||||
});
|
||||
}
|
||||
} else if (progress.status === "error") {
|
||||
dismissToast("unified-version-update");
|
||||
|
||||
showErrorToast("Failed to update browser versions", {
|
||||
duration: 6000,
|
||||
description: "Check your internet connection and try again",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Failed to setup version update progress listener:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
setupVersionUpdateListener();
|
||||
|
||||
// Cleanup interval and listener on component unmount or dialog close
|
||||
// Cleanup interval on component unmount or dialog close
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
if (unlistenFn) {
|
||||
try {
|
||||
unlistenFn();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Failed to cleanup version update progress listener:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [isOpen, loadPermissions, checkDefaultBrowserStatus, loadSettings]);
|
||||
}, [
|
||||
isOpen,
|
||||
loadPermissions,
|
||||
checkDefaultBrowserStatus,
|
||||
loadSettings,
|
||||
loadApiServerStatus,
|
||||
]);
|
||||
|
||||
// Update permissions when the permission states change
|
||||
useEffect(() => {
|
||||
@@ -373,10 +494,18 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
]);
|
||||
|
||||
// Check if settings have changed (excluding default browser setting)
|
||||
const hasChanges = settings.theme !== originalSettings.theme;
|
||||
const hasChanges =
|
||||
settings.theme !== originalSettings.theme ||
|
||||
settings.api_enabled !== originalSettings.api_enabled ||
|
||||
(settings.theme === "custom" &&
|
||||
JSON.stringify(customThemeState.colors) !==
|
||||
JSON.stringify(originalSettings.custom_theme ?? {})) ||
|
||||
(settings.theme !== "custom" &&
|
||||
JSON.stringify(settings.custom_theme ?? {}) !==
|
||||
JSON.stringify(originalSettings.custom_theme ?? {}));
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md max-h-[80vh] my-8 flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>Settings</DialogTitle>
|
||||
@@ -395,6 +524,15 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
value={settings.theme}
|
||||
onValueChange={(value) => {
|
||||
updateSetting("theme", value);
|
||||
if (value === "custom") {
|
||||
const tokyoNightTheme = getThemeById("tokyo-night");
|
||||
if (tokyoNightTheme) {
|
||||
setCustomThemeState({
|
||||
selectedThemeId: "tokyo-night",
|
||||
colors: tokyoNightTheme.colors,
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="theme-select">
|
||||
@@ -404,13 +542,126 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
<SelectItem value="system">System</SelectItem>
|
||||
<SelectItem value="custom">Custom</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose your preferred theme or follow your system settings.
|
||||
Choose your preferred theme or follow your system settings. Custom
|
||||
theme changes are applied only when you save.
|
||||
</p>
|
||||
|
||||
{settings.theme === "custom" && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="theme-preset-select"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Theme Preset
|
||||
</Label>
|
||||
<Select
|
||||
value={customThemeState.selectedThemeId || "custom"}
|
||||
onValueChange={(value) => {
|
||||
if (value === "custom") {
|
||||
setCustomThemeState((prev) => ({
|
||||
...prev,
|
||||
selectedThemeId: null,
|
||||
}));
|
||||
} else {
|
||||
const theme = getThemeById(value);
|
||||
if (theme) {
|
||||
setCustomThemeState({
|
||||
selectedThemeId: value,
|
||||
colors: theme.colors,
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="theme-preset-select">
|
||||
<SelectValue placeholder="Select a theme preset" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{THEMES.map((theme) => (
|
||||
<SelectItem key={theme.id} value={theme.id}>
|
||||
{theme.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="custom">Your Own</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="text-sm font-medium">Custom Colors</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{THEME_VARIABLES.map(({ key, label }) => {
|
||||
const colorValue =
|
||||
customThemeState.colors[key] || "#000000";
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-col gap-1 items-center"
|
||||
>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
className="w-8 h-8 rounded-md border shadow-sm cursor-pointer"
|
||||
style={{ backgroundColor: colorValue }}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[320px] p-3"
|
||||
sideOffset={6}
|
||||
>
|
||||
<ColorPicker
|
||||
className="p-3 rounded-md border shadow-sm bg-background"
|
||||
value={colorValue}
|
||||
onColorChange={([r, g, b, a]) => {
|
||||
const next = Color({ r, g, b }).alpha(a);
|
||||
const nextStr = next.hexa();
|
||||
const newColors = {
|
||||
...customThemeState.colors,
|
||||
[key]: nextStr,
|
||||
};
|
||||
|
||||
// Check if colors match any preset theme
|
||||
const matchingTheme =
|
||||
getThemeByColors(newColors);
|
||||
|
||||
setCustomThemeState({
|
||||
selectedThemeId: matchingTheme?.id || null,
|
||||
colors: newColors,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ColorPickerSelection className="h-36 rounded" />
|
||||
<div className="flex gap-3 items-center mt-3">
|
||||
<ColorPickerEyeDropper />
|
||||
<div className="grid gap-1 w-full">
|
||||
<ColorPickerHue />
|
||||
<ColorPickerAlpha />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center mt-3">
|
||||
<ColorPickerOutput />
|
||||
<ColorPickerFormat />
|
||||
</div>
|
||||
</ColorPicker>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className="text-[10px] text-muted-foreground text-center leading-tight">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Default Browser Section */}
|
||||
@@ -505,6 +756,39 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local API Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">Local API</Label>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="api-enabled"
|
||||
checked={settings.api_enabled}
|
||||
onCheckedChange={(checked: boolean) => {
|
||||
updateSetting("api_enabled", checked);
|
||||
}}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<Label
|
||||
htmlFor="api-enabled"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
(ALPHA) Enable Local API Server
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow managing the application data externally via REST API.
|
||||
Server will start on port 10108 or a random port if
|
||||
unavailable.
|
||||
{apiServerPort && (
|
||||
<span className="ml-1 font-medium text-green-600">
|
||||
(Currently running on port {apiServerPort})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">Advanced</Label>
|
||||
@@ -529,7 +813,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-shrink-0">
|
||||
<RippleButton variant="outline" onClick={onClose}>
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
|
||||
@@ -83,7 +83,7 @@ export function SharedCamoufoxConfigForm({
|
||||
forceAdvanced = false,
|
||||
}: SharedCamoufoxConfigFormProps) {
|
||||
const [activeTab, setActiveTab] = useState(
|
||||
forceAdvanced ? "advanced" : "normal",
|
||||
forceAdvanced ? "manual" : "automatic",
|
||||
);
|
||||
const [fingerprintConfig, setFingerprintConfig] =
|
||||
useState<CamoufoxFingerprintConfig>({});
|
||||
@@ -817,14 +817,13 @@ export function SharedCamoufoxConfigForm({
|
||||
// Advanced mode only (for editing)
|
||||
renderAdvancedForm()
|
||||
) : (
|
||||
// Normal/Advanced tabs for creation
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid grid-cols-2 w-full">
|
||||
<TabsTrigger value="normal">Normal</TabsTrigger>
|
||||
<TabsTrigger value="advanced">Advanced</TabsTrigger>
|
||||
<TabsTrigger value="automatic">Automatic</TabsTrigger>
|
||||
<TabsTrigger value="manual">Manual</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="normal" className="space-y-6">
|
||||
<TabsContent value="automatic" className="space-y-6">
|
||||
{/* Automatic Location Configuration */}
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
@@ -908,7 +907,7 @@ export function SharedCamoufoxConfigForm({
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="advanced" className="space-y-6">
|
||||
<TabsContent value="manual" className="space-y-6">
|
||||
{renderAdvancedForm()}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { applyThemeColors, clearThemeColors } from "@/lib/themes";
|
||||
|
||||
interface AppSettings {
|
||||
set_as_default_browser: boolean;
|
||||
theme: string;
|
||||
custom_theme?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface CustomThemeProviderProps {
|
||||
@@ -27,12 +29,26 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
|
||||
// Lazy import to avoid pulling Tauri API on SSR
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
const settings = await invoke<AppSettings>("get_app_settings");
|
||||
const themeValue = settings?.theme ?? "system";
|
||||
|
||||
if (
|
||||
settings?.theme === "light" ||
|
||||
settings?.theme === "dark" ||
|
||||
settings?.theme === "system"
|
||||
themeValue === "light" ||
|
||||
themeValue === "dark" ||
|
||||
themeValue === "system"
|
||||
) {
|
||||
setDefaultTheme(settings.theme);
|
||||
setDefaultTheme(themeValue);
|
||||
} else if (themeValue === "custom") {
|
||||
setDefaultTheme("light");
|
||||
if (
|
||||
settings.custom_theme &&
|
||||
Object.keys(settings.custom_theme).length > 0
|
||||
) {
|
||||
try {
|
||||
applyThemeColors(settings.custom_theme);
|
||||
} catch (error) {
|
||||
console.warn("Failed to apply custom theme variables:", error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setDefaultTheme("system");
|
||||
}
|
||||
@@ -51,6 +67,29 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
|
||||
void loadTheme();
|
||||
}, []);
|
||||
|
||||
// Additional effect to ensure custom theme is applied after mount
|
||||
useEffect(() => {
|
||||
if (!isLoading && _mounted) {
|
||||
const reapplyCustomTheme = async () => {
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
const settings = await invoke<AppSettings>("get_app_settings");
|
||||
|
||||
if (settings?.theme === "custom" && settings.custom_theme) {
|
||||
applyThemeColors(settings.custom_theme);
|
||||
} else {
|
||||
clearThemeColors();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to reapply custom theme:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Apply after a short delay to ensure CSS has loaded
|
||||
setTimeout(reapplyCustomTheme, 100);
|
||||
}
|
||||
}, [isLoading, _mounted]);
|
||||
|
||||
if (isLoading) {
|
||||
// Keep UI simple during initial settings load to avoid flicker
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
"use client";
|
||||
|
||||
import Color from "color";
|
||||
import { Slider } from "radix-ui";
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type HTMLAttributes,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { LuPipette } from "react-icons/lu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ColorPickerContextValue {
|
||||
hue: number;
|
||||
saturation: number;
|
||||
lightness: number;
|
||||
alpha: number;
|
||||
mode: string;
|
||||
setHue: (hue: number) => void;
|
||||
setSaturation: (saturation: number) => void;
|
||||
setLightness: (lightness: number) => void;
|
||||
setAlpha: (alpha: number) => void;
|
||||
setMode: (mode: string) => void;
|
||||
}
|
||||
|
||||
const ColorPickerContext = createContext<ColorPickerContextValue | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export const useColorPicker = () => {
|
||||
const context = useContext(ColorPickerContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useColorPicker must be used within a ColorPickerProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ColorPickerProps = Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
"onChange"
|
||||
> & {
|
||||
value?: Parameters<typeof Color>[0];
|
||||
defaultValue?: Parameters<typeof Color>[0];
|
||||
onColorChange?: (value: [number, number, number, number]) => void;
|
||||
};
|
||||
|
||||
export const ColorPicker = ({
|
||||
value,
|
||||
defaultValue = "#000000",
|
||||
onColorChange,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ColorPickerProps) => {
|
||||
const selectedColor = Color(value ?? defaultValue);
|
||||
const defaultColor = Color(defaultValue);
|
||||
|
||||
const initialHue = Number.isFinite(selectedColor.hue())
|
||||
? selectedColor.hue()
|
||||
: Number.isFinite(defaultColor.hue())
|
||||
? defaultColor.hue()
|
||||
: 0;
|
||||
const initialSaturation = Number.isFinite(selectedColor.saturationl())
|
||||
? selectedColor.saturationl()
|
||||
: Number.isFinite(defaultColor.saturationl())
|
||||
? defaultColor.saturationl()
|
||||
: 100;
|
||||
const initialLightness = Number.isFinite(selectedColor.lightness())
|
||||
? selectedColor.lightness()
|
||||
: Number.isFinite(defaultColor.lightness())
|
||||
? defaultColor.lightness()
|
||||
: 50;
|
||||
const initialAlpha = Number.isFinite(selectedColor.alpha())
|
||||
? Math.round(selectedColor.alpha() * 100)
|
||||
: Math.round(defaultColor.alpha() * 100);
|
||||
|
||||
const [hue, setHue] = useState(initialHue);
|
||||
const [saturation, setSaturation] = useState(initialSaturation);
|
||||
const [lightness, setLightness] = useState(initialLightness);
|
||||
const [alpha, setAlpha] = useState(initialAlpha);
|
||||
const [mode, setMode] = useState("hex");
|
||||
const lastEmittedRef = useRef<string>(
|
||||
`${Math.round(initialHue)}|${Math.round(initialSaturation)}|${Math.round(initialLightness)}|${Math.round(initialAlpha)}`,
|
||||
);
|
||||
|
||||
// Update color when controlled value changes
|
||||
useEffect(() => {
|
||||
if (value !== undefined) {
|
||||
const c = Color(value).hsl();
|
||||
const nextHue = Number.isFinite(c.hue()) ? c.hue() : 0;
|
||||
const nextSat = Number.isFinite(c.saturationl()) ? c.saturationl() : 0;
|
||||
const nextLight = Number.isFinite(c.lightness()) ? c.lightness() : 0;
|
||||
const nextAlpha = Math.round(
|
||||
(Number.isFinite(c.alpha()) ? c.alpha() : 1) * 100,
|
||||
);
|
||||
|
||||
// Update internal state unconditionally when value prop changes
|
||||
setHue(nextHue);
|
||||
setSaturation(nextSat);
|
||||
setLightness(nextLight);
|
||||
setAlpha(nextAlpha);
|
||||
}
|
||||
}, [value]); // Remove state values from dependency array to prevent infinite loop
|
||||
|
||||
// Notify parent of changes
|
||||
useEffect(() => {
|
||||
if (onColorChange) {
|
||||
const key = `${Math.round(hue)}|${Math.round(saturation)}|${Math.round(lightness)}|${Math.round(alpha)}`;
|
||||
if (key === lastEmittedRef.current) {
|
||||
return;
|
||||
}
|
||||
lastEmittedRef.current = key;
|
||||
|
||||
const color = Color.hsl(hue, saturation, lightness).alpha(alpha / 100);
|
||||
const rgba = color.rgb().array();
|
||||
onColorChange([rgba[0], rgba[1], rgba[2], alpha / 100]);
|
||||
}
|
||||
}, [hue, saturation, lightness, alpha, onColorChange]);
|
||||
|
||||
return (
|
||||
<ColorPickerContext.Provider
|
||||
value={{
|
||||
hue,
|
||||
saturation,
|
||||
lightness,
|
||||
alpha,
|
||||
mode,
|
||||
setHue,
|
||||
setSaturation,
|
||||
setLightness,
|
||||
setAlpha,
|
||||
setMode,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn("flex flex-col gap-4 size-full", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</ColorPickerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type ColorPickerSelectionProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ColorPickerSelection = memo(
|
||||
({ className, ...props }: ColorPickerSelectionProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [positionX, setPositionX] = useState(0);
|
||||
const [positionY, setPositionY] = useState(0);
|
||||
const { hue, saturation, lightness, setSaturation, setLightness } =
|
||||
useColorPicker();
|
||||
|
||||
const backgroundGradient = useMemo(() => {
|
||||
return `linear-gradient(0deg, rgba(0,0,0,1), rgba(0,0,0,0)),
|
||||
linear-gradient(90deg, rgba(255,255,255,1), rgba(255,255,255,0)),
|
||||
hsl(${hue}, 100%, 50%)`;
|
||||
}, [hue]);
|
||||
|
||||
// Update position indicators when saturation/lightness change externally
|
||||
useEffect(() => {
|
||||
if (!isDragging) {
|
||||
const x = saturation / 100;
|
||||
const topLightness = x < 0.01 ? 100 : 50 + 50 * (1 - x);
|
||||
const y = topLightness > 0 ? 1 - lightness / topLightness : 0;
|
||||
setPositionX(x);
|
||||
setPositionY(Math.max(0, Math.min(1, y)));
|
||||
}
|
||||
}, [saturation, lightness, isDragging]);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(event: PointerEvent) => {
|
||||
if (!(isDragging && containerRef.current)) {
|
||||
return;
|
||||
}
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const x = Math.max(
|
||||
0,
|
||||
Math.min(1, (event.clientX - rect.left) / rect.width),
|
||||
);
|
||||
const y = Math.max(
|
||||
0,
|
||||
Math.min(1, (event.clientY - rect.top) / rect.height),
|
||||
);
|
||||
setPositionX(x);
|
||||
setPositionY(y);
|
||||
setSaturation(x * 100);
|
||||
const topLightness = x < 0.01 ? 100 : 50 + 50 * (1 - x);
|
||||
const lightness = topLightness * (1 - y);
|
||||
|
||||
setLightness(lightness);
|
||||
},
|
||||
[isDragging, setSaturation, setLightness],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerUp = () => setIsDragging(false);
|
||||
|
||||
if (isDragging) {
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
};
|
||||
}, [isDragging, handlePointerMove]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative rounded cursor-pointer size-full", className)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
handlePointerMove(e.nativeEvent);
|
||||
}}
|
||||
ref={containerRef}
|
||||
style={{
|
||||
background: backgroundGradient,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className="absolute w-4 h-4 rounded-full border-2 border-white -translate-x-1/2 -translate-y-1/2 pointer-events-none"
|
||||
style={{
|
||||
left: `${positionX * 100}%`,
|
||||
top: `${positionY * 100}%`,
|
||||
boxShadow: "0 0 0 1px rgba(0,0,0,0.5)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ColorPickerSelection.displayName = "ColorPickerSelection";
|
||||
|
||||
export type ColorPickerHueProps = ComponentProps<typeof Slider.Root>;
|
||||
|
||||
export const ColorPickerHue = ({
|
||||
className,
|
||||
...props
|
||||
}: ColorPickerHueProps) => {
|
||||
const { hue, setHue } = useColorPicker();
|
||||
|
||||
return (
|
||||
<Slider.Root
|
||||
className={cn("flex relative w-full h-4 touch-none", className)}
|
||||
max={360}
|
||||
onValueChange={([hue]) => setHue(hue)}
|
||||
step={1}
|
||||
value={[hue]}
|
||||
{...props}
|
||||
>
|
||||
<Slider.Track className="relative my-0.5 h-3 w-full grow rounded-full bg-[linear-gradient(90deg,#FF0000,#FFFF00,#00FF00,#00FFFF,#0000FF,#FF00FF,#FF0000)]">
|
||||
<Slider.Range className="absolute h-full" />
|
||||
</Slider.Track>
|
||||
<Slider.Thumb className="block w-4 h-4 rounded-full border shadow transition-colors border-primary/50 bg-background focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||
</Slider.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export type ColorPickerAlphaProps = ComponentProps<typeof Slider.Root>;
|
||||
|
||||
export const ColorPickerAlpha = ({
|
||||
className,
|
||||
...props
|
||||
}: ColorPickerAlphaProps) => {
|
||||
const { alpha, setAlpha } = useColorPicker();
|
||||
|
||||
return (
|
||||
<Slider.Root
|
||||
className={cn("flex relative w-full h-4 touch-none", className)}
|
||||
max={100}
|
||||
onValueChange={([alpha]) => setAlpha(alpha)}
|
||||
step={1}
|
||||
value={[alpha]}
|
||||
{...props}
|
||||
>
|
||||
<Slider.Track
|
||||
className="relative my-0.5 h-3 w-full grow rounded-full"
|
||||
style={{
|
||||
background:
|
||||
'url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==") left center',
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent rounded-full to-black/50" />
|
||||
<Slider.Range className="absolute h-full bg-transparent rounded-full" />
|
||||
</Slider.Track>
|
||||
<Slider.Thumb className="block w-4 h-4 rounded-full border shadow transition-colors border-primary/50 bg-background focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||
</Slider.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export type ColorPickerEyeDropperProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ColorPickerEyeDropper = ({
|
||||
className,
|
||||
...props
|
||||
}: ColorPickerEyeDropperProps) => {
|
||||
const { setHue, setSaturation, setLightness, setAlpha } = useColorPicker();
|
||||
|
||||
const handleEyeDropper = async () => {
|
||||
try {
|
||||
// @ts-expect-error - EyeDropper API is experimental
|
||||
const eyeDropper = new EyeDropper();
|
||||
const result = await eyeDropper.open();
|
||||
const color = Color(result.sRGBHex);
|
||||
const [h, s, l] = color.hsl().array();
|
||||
|
||||
setHue(h);
|
||||
setSaturation(s);
|
||||
setLightness(l);
|
||||
setAlpha(100);
|
||||
} catch (error) {
|
||||
console.error("EyeDropper failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("shrink-0 text-muted-foreground", className)}
|
||||
onClick={handleEyeDropper}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
<LuPipette size={16} />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type ColorPickerOutputProps = ComponentProps<typeof SelectTrigger>;
|
||||
|
||||
const formats = ["hex", "rgb", "css", "hsl"];
|
||||
|
||||
export const ColorPickerOutput = ({
|
||||
className,
|
||||
...props
|
||||
}: ColorPickerOutputProps) => {
|
||||
const { mode, setMode } = useColorPicker();
|
||||
|
||||
return (
|
||||
<Select onValueChange={setMode} value={mode}>
|
||||
<SelectTrigger className="w-20 h-8 text-xs shrink-0" {...props}>
|
||||
<SelectValue placeholder="Mode" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{formats.map((format) => (
|
||||
<SelectItem className="text-xs" key={format} value={format}>
|
||||
{format.toUpperCase()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
type PercentageInputProps = ComponentProps<typeof Input>;
|
||||
|
||||
const PercentageInput = ({ className, ...props }: PercentageInputProps) => {
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
readOnly
|
||||
type="text"
|
||||
{...props}
|
||||
className={cn(
|
||||
"h-8 w-[3.25rem] rounded-l-none bg-secondary px-2 text-xs shadow-none",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
<span className="absolute right-2 top-1/2 text-xs -translate-y-1/2 text-muted-foreground">
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ColorPickerFormatProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ColorPickerFormat = ({
|
||||
className,
|
||||
...props
|
||||
}: ColorPickerFormatProps) => {
|
||||
const { hue, saturation, lightness, alpha, mode } = useColorPicker();
|
||||
const color = Color.hsl(hue, saturation, lightness, alpha / 100);
|
||||
|
||||
if (mode === "hex") {
|
||||
const hex = color.hex();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex relative items-center -space-x-px w-full rounded-md shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Input
|
||||
className="px-2 h-8 text-xs rounded-r-none shadow-none bg-secondary"
|
||||
readOnly
|
||||
type="text"
|
||||
value={hex}
|
||||
/>
|
||||
<PercentageInput value={alpha} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "rgb") {
|
||||
const rgb = color
|
||||
.rgb()
|
||||
.array()
|
||||
.map((value) => Math.round(value));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center -space-x-px rounded-md shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{rgb.map((value, index) => (
|
||||
<Input
|
||||
className={cn(
|
||||
"h-8 rounded-r-none bg-secondary px-2 text-xs shadow-none",
|
||||
index && "rounded-l-none",
|
||||
className,
|
||||
)}
|
||||
key={`rgb-${value.toString()}`}
|
||||
readOnly
|
||||
type="text"
|
||||
value={value}
|
||||
/>
|
||||
))}
|
||||
<PercentageInput value={alpha} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "css") {
|
||||
const rgb = color
|
||||
.rgb()
|
||||
.array()
|
||||
.map((value) => Math.round(value));
|
||||
|
||||
return (
|
||||
<div className={cn("w-full rounded-md shadow-sm", className)} {...props}>
|
||||
<Input
|
||||
className="px-2 w-full h-8 text-xs shadow-none bg-secondary"
|
||||
readOnly
|
||||
type="text"
|
||||
value={`rgba(${rgb.join(", ")}, ${alpha}%)`}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "hsl") {
|
||||
const hsl = color
|
||||
.hsl()
|
||||
.array()
|
||||
.map((value) => Math.round(value));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center -space-x-px rounded-md shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{hsl.map((value, index) => (
|
||||
<Input
|
||||
className={cn(
|
||||
"h-8 rounded-r-none bg-secondary px-2 text-xs shadow-none",
|
||||
index && "rounded-l-none",
|
||||
className,
|
||||
)}
|
||||
key={`hsl-${value.toString()}`}
|
||||
readOnly
|
||||
type="text"
|
||||
value={value}
|
||||
/>
|
||||
))}
|
||||
<PercentageInput value={alpha} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -39,7 +39,7 @@ function DialogOverlay({
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[9999] bg-black/50",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[9999] bg-background/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -6,10 +6,7 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="overflow-x-auto relative w-full"
|
||||
>
|
||||
<div data-slot="table-container" className="overflow-visible w-full">
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full text-sm caption-bottom", className)}
|
||||
@@ -70,7 +67,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap",
|
||||
"px-2 h-10 font-medium text-left align-middle whitespace-nowrap text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -41,7 +41,7 @@ export function WindowDragArea() {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="fixed top-0 right-0 left-0 h-10 bg-transparent border-0 z-[9999] select-none"
|
||||
className="fixed top-0 right-0 left-0 h-10 bg-transparent border-0 z-[999999] select-none"
|
||||
data-window-drag-area="true"
|
||||
onPointerDown={handlePointerDown}
|
||||
onContextMenu={(e) => {
|
||||
|
||||
@@ -35,7 +35,7 @@ export function useBrowserState(
|
||||
(browserType: string): boolean => {
|
||||
if (!isClient) return false;
|
||||
return profiles.some(
|
||||
(p) => p.browser === browserType && runningProfiles.has(p.name),
|
||||
(p) => p.browser === browserType && runningProfiles.has(p.id),
|
||||
);
|
||||
},
|
||||
[profiles, runningProfiles, isClient],
|
||||
@@ -48,10 +48,10 @@ export function useBrowserState(
|
||||
(profile: BrowserProfile): boolean => {
|
||||
if (!isClient) return false;
|
||||
|
||||
const isRunning = runningProfiles.has(profile.name);
|
||||
const isLaunching = launchingProfiles?.has(profile.name) ?? false;
|
||||
const isStopping = stoppingProfiles?.has(profile.name) ?? false;
|
||||
const isBrowserUpdating = isUpdating?.(profile.browser) ?? false;
|
||||
const isRunning = runningProfiles.has(profile.id);
|
||||
const isLaunching = launchingProfiles.has(profile.id);
|
||||
const isStopping = stoppingProfiles.has(profile.id);
|
||||
const isBrowserUpdating = isUpdating(profile.browser);
|
||||
|
||||
// If the profile is launching or stopping, disable the button
|
||||
if (isLaunching || isStopping) {
|
||||
@@ -92,10 +92,10 @@ export function useBrowserState(
|
||||
(profile: BrowserProfile): boolean => {
|
||||
if (!isClient) return false;
|
||||
|
||||
const isRunning = runningProfiles.has(profile.name);
|
||||
const isLaunching = launchingProfiles?.has(profile.name) ?? false;
|
||||
const isStopping = stoppingProfiles?.has(profile.name) ?? false;
|
||||
const isBrowserUpdating = isUpdating?.(profile.browser) ?? false;
|
||||
const isRunning = runningProfiles.has(profile.id);
|
||||
const isLaunching = launchingProfiles.has(profile.id);
|
||||
const isStopping = stoppingProfiles.has(profile.id);
|
||||
const isBrowserUpdating = isUpdating(profile.browser);
|
||||
|
||||
// If this specific browser is updating, downloading, launching, or stopping, block it
|
||||
if (isBrowserUpdating || isLaunching || isStopping) {
|
||||
@@ -105,7 +105,7 @@ export function useBrowserState(
|
||||
// For single-instance browsers (Tor and Mullvad)
|
||||
if (isSingleInstanceBrowser(profile.browser)) {
|
||||
const runningInstancesOfType = profiles.filter(
|
||||
(p) => p.browser === profile.browser && runningProfiles.has(p.name),
|
||||
(p) => p.browser === profile.browser && runningProfiles.has(p.id),
|
||||
);
|
||||
|
||||
// If no instances are running, any profile of this type can be used
|
||||
@@ -142,10 +142,10 @@ export function useBrowserState(
|
||||
(profile: BrowserProfile): boolean => {
|
||||
if (!isClient) return false;
|
||||
|
||||
const isRunning = runningProfiles.has(profile.name);
|
||||
const isLaunching = launchingProfiles?.has(profile.name) ?? false;
|
||||
const isStopping = stoppingProfiles?.has(profile.name) ?? false;
|
||||
const isBrowserUpdating = isUpdating?.(profile.browser) ?? false;
|
||||
const isRunning = runningProfiles.has(profile.id);
|
||||
const isLaunching = launchingProfiles.has(profile.id);
|
||||
const isStopping = stoppingProfiles.has(profile.id);
|
||||
const isBrowserUpdating = isUpdating(profile.browser);
|
||||
|
||||
// If profile is running, launching, stopping, or browser is updating, block selection
|
||||
if (isRunning || isLaunching || isStopping || isBrowserUpdating) {
|
||||
@@ -170,10 +170,10 @@ export function useBrowserState(
|
||||
(profile: BrowserProfile): string => {
|
||||
if (!isClient) return "Loading...";
|
||||
|
||||
const isRunning = runningProfiles.has(profile.name);
|
||||
const isLaunching = launchingProfiles?.has(profile.name) ?? false;
|
||||
const isStopping = stoppingProfiles?.has(profile.name) ?? false;
|
||||
const isBrowserUpdating = isUpdating?.(profile.browser) ?? false;
|
||||
const isRunning = runningProfiles.has(profile.id);
|
||||
const isLaunching = launchingProfiles.has(profile.id);
|
||||
const isStopping = stoppingProfiles.has(profile.id);
|
||||
const isBrowserUpdating = isUpdating(profile.browser);
|
||||
|
||||
if (isLaunching) {
|
||||
return "Launching browser...";
|
||||
@@ -224,10 +224,10 @@ export function useBrowserState(
|
||||
|
||||
if (canUseForLinks) return null;
|
||||
|
||||
const isRunning = runningProfiles.has(profile.name);
|
||||
const isLaunching = launchingProfiles?.has(profile.name) ?? false;
|
||||
const isStopping = stoppingProfiles?.has(profile.name) ?? false;
|
||||
const isBrowserUpdating = isUpdating?.(profile.browser) ?? false;
|
||||
const isRunning = runningProfiles.has(profile.id);
|
||||
const isLaunching = launchingProfiles.has(profile.id);
|
||||
const isStopping = stoppingProfiles.has(profile.id);
|
||||
const isBrowserUpdating = isUpdating(profile.browser);
|
||||
|
||||
if (isLaunching) {
|
||||
return "Profile is currently launching. Please wait.";
|
||||
@@ -245,7 +245,7 @@ export function useBrowserState(
|
||||
const browserDisplayName =
|
||||
profile.browser === "tor-browser" ? "TOR" : "Mullvad";
|
||||
const runningInstancesOfType = profiles.filter(
|
||||
(p) => p.browser === profile.browser && runningProfiles.has(p.name),
|
||||
(p) => p.browser === profile.browser && runningProfiles.has(p.id),
|
||||
);
|
||||
|
||||
if (runningInstancesOfType.length > 0) {
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
export interface ThemeColors extends Record<string, string> {
|
||||
"--background": string;
|
||||
"--foreground": string;
|
||||
"--card": string;
|
||||
"--card-foreground": string;
|
||||
"--popover": string;
|
||||
"--popover-foreground": string;
|
||||
"--primary": string;
|
||||
"--primary-foreground": string;
|
||||
"--secondary": string;
|
||||
"--secondary-foreground": string;
|
||||
"--muted": string;
|
||||
"--muted-foreground": string;
|
||||
"--accent": string;
|
||||
"--accent-foreground": string;
|
||||
"--destructive": string;
|
||||
"--destructive-foreground": string;
|
||||
"--border": string;
|
||||
}
|
||||
|
||||
export interface Theme {
|
||||
id: string;
|
||||
name: string;
|
||||
colors: ThemeColors;
|
||||
}
|
||||
|
||||
export const THEMES: Theme[] = [
|
||||
{
|
||||
id: "tokyo-night",
|
||||
name: "Tokyo Night",
|
||||
colors: {
|
||||
"--background": "#1a1b26",
|
||||
"--foreground": "#c0caf5",
|
||||
"--card": "#24283b",
|
||||
"--card-foreground": "#c0caf5",
|
||||
"--popover": "#24283b",
|
||||
"--popover-foreground": "#c0caf5",
|
||||
"--primary": "#7aa2f7",
|
||||
"--primary-foreground": "#1a1b26",
|
||||
"--secondary": "#2ac3de",
|
||||
"--secondary-foreground": "#1a1b26",
|
||||
"--muted": "#3b4261",
|
||||
"--muted-foreground": "#a9b1d6",
|
||||
"--accent": "#bb9af7",
|
||||
"--accent-foreground": "#1a1b26",
|
||||
"--destructive": "#f7768e",
|
||||
"--destructive-foreground": "#1a1b26",
|
||||
"--border": "#3b4261",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "dracula",
|
||||
name: "Dracula",
|
||||
colors: {
|
||||
"--background": "#282a36",
|
||||
"--foreground": "#f8f8f2",
|
||||
"--card": "#44475a",
|
||||
"--card-foreground": "#f8f8f2",
|
||||
"--popover": "#44475a",
|
||||
"--popover-foreground": "#f8f8f2",
|
||||
"--primary": "#bd93f9",
|
||||
"--primary-foreground": "#282a36",
|
||||
"--secondary": "#8be9fd",
|
||||
"--secondary-foreground": "#282a36",
|
||||
"--muted": "#6272a4",
|
||||
"--muted-foreground": "#f8f8f2",
|
||||
"--accent": "#ff79c6",
|
||||
"--accent-foreground": "#282a36",
|
||||
"--destructive": "#ff5555",
|
||||
"--destructive-foreground": "#f8f8f2",
|
||||
"--border": "#6272a4",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "matchalk",
|
||||
name: "Matchalk",
|
||||
colors: {
|
||||
"--background": "#273136",
|
||||
"--foreground": "#D1DED3",
|
||||
"--card": "#1c2427",
|
||||
"--card-foreground": "#D1DED3",
|
||||
"--popover": "#323e45",
|
||||
"--popover-foreground": "#D1DED3",
|
||||
"--primary": "#7eb08a",
|
||||
"--primary-foreground": "#273136",
|
||||
"--secondary": "#d2b48c",
|
||||
"--secondary-foreground": "#273136",
|
||||
"--muted": "#323e45",
|
||||
"--muted-foreground": "#7ea4b0",
|
||||
"--accent": "#d2b48c",
|
||||
"--accent-foreground": "#273136",
|
||||
"--destructive": "#ff819f",
|
||||
"--destructive-foreground": "#273136",
|
||||
"--border": "#304e37",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "houston",
|
||||
name: "Houston",
|
||||
colors: {
|
||||
"--background": "#17191e",
|
||||
"--foreground": "#f7f7f8",
|
||||
"--card": "#21252e",
|
||||
"--card-foreground": "#f7f7f8",
|
||||
"--popover": "#21252e",
|
||||
"--popover-foreground": "#f7f7f8",
|
||||
"--primary": "#5755d9",
|
||||
"--primary-foreground": "#f7f7f8",
|
||||
"--secondary": "#f25f4c",
|
||||
"--secondary-foreground": "#f7f7f8",
|
||||
"--muted": "#2a2e39",
|
||||
"--muted-foreground": "#9ca3af",
|
||||
"--accent": "#0ea5e9",
|
||||
"--accent-foreground": "#f7f7f8",
|
||||
"--destructive": "#ef4444",
|
||||
"--destructive-foreground": "#f7f7f8",
|
||||
"--border": "#2a2e39",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "ayu-dark",
|
||||
name: "Ayu Dark",
|
||||
colors: {
|
||||
"--background": "#0a0e14",
|
||||
"--foreground": "#b3b1ad",
|
||||
"--card": "#11151c",
|
||||
"--card-foreground": "#b3b1ad",
|
||||
"--popover": "#11151c",
|
||||
"--popover-foreground": "#b3b1ad",
|
||||
"--primary": "#39bae6",
|
||||
"--primary-foreground": "#0a0e14",
|
||||
"--secondary": "#ffb454",
|
||||
"--secondary-foreground": "#0a0e14",
|
||||
"--muted": "#1f2430",
|
||||
"--muted-foreground": "#5c6773",
|
||||
"--accent": "#d2a6ff",
|
||||
"--accent-foreground": "#0a0e14",
|
||||
"--destructive": "#f07178",
|
||||
"--destructive-foreground": "#b3b1ad",
|
||||
"--border": "#1f2430",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "ayu-light",
|
||||
name: "Ayu Light",
|
||||
colors: {
|
||||
"--background": "#fafafa",
|
||||
"--foreground": "#5c6773",
|
||||
"--card": "#ffffff",
|
||||
"--card-foreground": "#5c6773",
|
||||
"--popover": "#ffffff",
|
||||
"--popover-foreground": "#5c6773",
|
||||
"--primary": "#399ee6",
|
||||
"--primary-foreground": "#fafafa",
|
||||
"--secondary": "#fa8d3e",
|
||||
"--secondary-foreground": "#fafafa",
|
||||
"--muted": "#f0f0f0",
|
||||
"--muted-foreground": "#828c99",
|
||||
"--accent": "#a37acc",
|
||||
"--accent-foreground": "#fafafa",
|
||||
"--destructive": "#f07178",
|
||||
"--destructive-foreground": "#fafafa",
|
||||
"--border": "#e7eaed",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const THEME_VARIABLES: Array<{ key: keyof ThemeColors; label: string }> =
|
||||
[
|
||||
{ key: "--background", label: "Background" },
|
||||
{ key: "--foreground", label: "Foreground" },
|
||||
{ key: "--card", label: "Card" },
|
||||
{ key: "--card-foreground", label: "Card FG" },
|
||||
{ key: "--popover", label: "Popover" },
|
||||
{ key: "--popover-foreground", label: "Popover FG" },
|
||||
{ key: "--primary", label: "Primary" },
|
||||
{ key: "--primary-foreground", label: "Primary FG" },
|
||||
{ key: "--secondary", label: "Secondary" },
|
||||
{ key: "--secondary-foreground", label: "Secondary FG" },
|
||||
{ key: "--muted", label: "Muted" },
|
||||
{ key: "--muted-foreground", label: "Muted FG" },
|
||||
{ key: "--accent", label: "Accent" },
|
||||
{ key: "--accent-foreground", label: "Accent FG" },
|
||||
{ key: "--destructive", label: "Destructive" },
|
||||
{ key: "--destructive-foreground", label: "Destructive FG" },
|
||||
{ key: "--border", label: "Border" },
|
||||
];
|
||||
|
||||
export function getThemeById(id: string): Theme | undefined {
|
||||
return THEMES.find((theme) => theme.id === id);
|
||||
}
|
||||
|
||||
export function getThemeByColors(
|
||||
colors: Record<string, string>,
|
||||
): Theme | undefined {
|
||||
return THEMES.find((theme) => {
|
||||
return THEME_VARIABLES.every(({ key }) => {
|
||||
return theme.colors[key] === colors[key];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function applyThemeColors(colors: Record<string, string>): void {
|
||||
const root = document.documentElement;
|
||||
Object.entries(colors).forEach(([key, value]) => {
|
||||
root.style.setProperty(key, value, "important");
|
||||
});
|
||||
}
|
||||
|
||||
export function clearThemeColors(): void {
|
||||
const root = document.documentElement;
|
||||
THEME_VARIABLES.forEach(({ key }) => {
|
||||
root.style.removeProperty(key as string);
|
||||
});
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export interface BrowserProfile {
|
||||
release_type: string; // "stable" or "nightly"
|
||||
camoufox_config?: CamoufoxConfig; // Camoufox configuration
|
||||
group_id?: string; // Reference to profile group
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface StoredProxy {
|
||||
|
||||
Reference in New Issue
Block a user