mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-11 17:27:54 +02:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 269db678b7 | |||
| f809b975f3 | |||
| e369214715 | |||
| 5f93841bb7 | |||
| 1d71729c9e | |||
| a14da3d2f0 | |||
| 59c69c44a1 | |||
| 025523d0d3 | |||
| 76d17df281 | |||
| 727fa51a64 | |||
| 80305ef903 | |||
| 4d98606f28 | |||
| c2d083a10d | |||
| 6d1d15d366 | |||
| 2b2c855679 | |||
| e80043167f | |||
| 2ee3a90e25 | |||
| 231ac3f26c | |||
| 41c02c539f | |||
| ec78787079 | |||
| 7fc6f985dd | |||
| 5814f00f3d | |||
| 621a2dd0a1 | |||
| 3564762872 | |||
| b12d3af3bd | |||
| 32e70a5943 | |||
| 8b8ba31cce | |||
| 201e0270c7 | |||
| ceb2eec80e | |||
| f2b3b2cc69 | |||
| 8ac077d81b | |||
| dab5ab5805 | |||
| 83a7c0e394 | |||
| f622c77a3e |
Vendored
+1
@@ -38,6 +38,7 @@
|
||||
"doesn",
|
||||
"domcontentloaded",
|
||||
"donutbrowser",
|
||||
"doorhanger",
|
||||
"dpkg",
|
||||
"dtolnay",
|
||||
"dyld",
|
||||
|
||||
@@ -57,10 +57,8 @@ program
|
||||
|
||||
// Build upstream URL from individual components if provided
|
||||
if (options.host && options.proxyPort && options.type) {
|
||||
const protocol =
|
||||
options.type === "socks4" || options.type === "socks5"
|
||||
? options.type
|
||||
: "http";
|
||||
// Preserve provided scheme (http, https, socks4, socks5)
|
||||
const protocol = String(options.type).toLowerCase();
|
||||
const auth =
|
||||
options.username && options.password
|
||||
? `${encodeURIComponent(options.username)}:${encodeURIComponent(
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
"name": "donutbrowser",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
@@ -71,7 +71,7 @@
|
||||
"tw-animate-css": "^1.3.6",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"packageManager": "pnpm@10.13.1",
|
||||
"packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748",
|
||||
"lint-staged": {
|
||||
"**/*.{js,jsx,ts,tsx,json,css}": [
|
||||
"biome check --fix"
|
||||
|
||||
Generated
+1
-1
@@ -1021,7 +1021,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "donutbrowser"
|
||||
version = "0.9.1"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.9.1"
|
||||
version = "0.9.3"
|
||||
description = "Simple Yet Powerful Anti-Detect Browser"
|
||||
authors = ["zhom@github"]
|
||||
edition = "2021"
|
||||
@@ -14,6 +14,7 @@ default-run = "donutbrowser"
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "donutbrowser"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
doctest = false
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
@@ -28,5 +28,11 @@
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.automation.apple-events</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.usb</key>
|
||||
<true/>
|
||||
<key>com.apple.security.inherit</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
+146
-110
@@ -153,7 +153,7 @@ impl BrowserRunner {
|
||||
app_handle: tauri::AppHandle,
|
||||
profile: &BrowserProfile,
|
||||
url: Option<String>,
|
||||
_local_proxy_settings: Option<&ProxySettings>,
|
||||
local_proxy_settings: Option<&ProxySettings>,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Check if browser is disabled due to ongoing update
|
||||
let auto_updater = crate::auto_updater::AutoUpdater::instance();
|
||||
@@ -290,8 +290,8 @@ impl BrowserRunner {
|
||||
.as_ref()
|
||||
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id));
|
||||
|
||||
// For now, don't use proxy in launch args - we'll set it up after launch
|
||||
let proxy_for_launch_args: Option<&ProxySettings> = None;
|
||||
// Use provided local proxy for Chromium-based browsers launch arguments
|
||||
let proxy_for_launch_args: Option<&ProxySettings> = local_proxy_settings;
|
||||
|
||||
// Get profile data path and launch arguments
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
@@ -368,6 +368,82 @@ impl BrowserRunner {
|
||||
}
|
||||
}
|
||||
|
||||
// On macOS, when launching via `open -a`, the child PID is the `open` helper.
|
||||
// Resolve and store the actual browser PID for all browser types.
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// Give the browser a moment to start
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(1500)).await;
|
||||
|
||||
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();
|
||||
|
||||
for (pid, process) in system.processes() {
|
||||
let cmd = process.cmd();
|
||||
if cmd.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine if this process matches the intended browser type
|
||||
let exe_name_lower = process.name().to_string_lossy().to_lowercase();
|
||||
let is_correct_browser = match profile.browser.as_str() {
|
||||
"firefox" => {
|
||||
exe_name_lower.contains("firefox")
|
||||
&& !exe_name_lower.contains("developer")
|
||||
&& !exe_name_lower.contains("tor")
|
||||
&& !exe_name_lower.contains("mullvad")
|
||||
&& !exe_name_lower.contains("camoufox")
|
||||
}
|
||||
"firefox-developer" => {
|
||||
exe_name_lower.contains("firefox") && exe_name_lower.contains("developer")
|
||||
}
|
||||
"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
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if !is_correct_browser {
|
||||
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
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update profile with process info and save
|
||||
let mut updated_profile = profile.clone();
|
||||
updated_profile.process_id = Some(actual_pid);
|
||||
@@ -390,58 +466,6 @@ impl BrowserRunner {
|
||||
// which is already handled in the profile creation process
|
||||
}
|
||||
|
||||
// Always start a local proxy for traffic monitoring and potential upstream routing
|
||||
let upstream_proxy = profile
|
||||
.proxy_id
|
||||
.as_ref()
|
||||
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id));
|
||||
|
||||
println!(
|
||||
"Starting local proxy for profile: {} (upstream: {})",
|
||||
profile.name,
|
||||
upstream_proxy
|
||||
.as_ref()
|
||||
.map(|p| format!("{}:{}", p.host, p.port))
|
||||
.unwrap_or_else(|| "DIRECT".to_string())
|
||||
);
|
||||
|
||||
match PROXY_MANAGER
|
||||
.start_proxy(
|
||||
app_handle.clone(),
|
||||
upstream_proxy.as_ref(),
|
||||
actual_pid,
|
||||
Some(&profile.name),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(local_proxy) => {
|
||||
println!(
|
||||
"Local proxy started successfully for profile: {} on port: {}",
|
||||
profile.name, local_proxy.port
|
||||
);
|
||||
|
||||
// For Firefox-based browsers, update the PAC file with the local proxy
|
||||
if matches!(
|
||||
browser_type,
|
||||
BrowserType::Firefox
|
||||
| BrowserType::FirefoxDeveloper
|
||||
| BrowserType::Zen
|
||||
| BrowserType::TorBrowser
|
||||
| BrowserType::MullvadBrowser
|
||||
) {
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_path = profiles_dir
|
||||
.join(updated_profile.id.to_string())
|
||||
.join("profile");
|
||||
|
||||
if let Err(e) = self.apply_proxy_settings_to_profile(&profile_path, &local_proxy, None) {
|
||||
println!("Warning: Failed to update Firefox proxy settings: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => println!("Warning: Failed to start local proxy: {e}"),
|
||||
}
|
||||
|
||||
// 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}");
|
||||
@@ -1467,54 +1491,77 @@ pub async fn launch_browser_profile(
|
||||
// Store the internal proxy settings for passing to launch_browser
|
||||
let mut internal_proxy_settings: Option<ProxySettings> = None;
|
||||
|
||||
// If the profile has proxy settings, we need to start the proxy first
|
||||
// and update the profile with proxy settings before launching
|
||||
let profile_for_launch = profile.clone();
|
||||
if let Some(proxy_id) = &profile.proxy_id {
|
||||
if let Some(proxy) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id) {
|
||||
// Use a temporary PID (1) to start the proxy, we'll update it after browser launch
|
||||
let temp_pid = 1u32;
|
||||
// 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
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?
|
||||
.into_iter()
|
||||
.find(|p| p.name == profile.name)
|
||||
.unwrap_or_else(|| profile.clone());
|
||||
|
||||
// Start the proxy first
|
||||
match PROXY_MANAGER
|
||||
.start_proxy(
|
||||
app_handle.clone(),
|
||||
Some(&proxy),
|
||||
temp_pid,
|
||||
Some(&profile.name),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(internal_proxy) => {
|
||||
let browser_runner = BrowserRunner::instance();
|
||||
// Always start a local proxy before launching (non-Camoufox handled here; Camoufox has its own flow)
|
||||
if profile.browser != "camoufox" {
|
||||
// Determine upstream proxy if configured; otherwise use DIRECT
|
||||
let upstream_proxy = profile_for_launch
|
||||
.proxy_id
|
||||
.as_ref()
|
||||
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id));
|
||||
|
||||
// Use a temporary PID (1) to start the proxy, we'll update it after browser launch
|
||||
let temp_pid = 1u32;
|
||||
|
||||
match PROXY_MANAGER
|
||||
.start_proxy(
|
||||
app_handle.clone(),
|
||||
upstream_proxy.as_ref(),
|
||||
temp_pid,
|
||||
Some(&profile.name),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(internal_proxy) => {
|
||||
// Use internal proxy for subsequent launch
|
||||
internal_proxy_settings = Some(internal_proxy.clone());
|
||||
|
||||
// For Firefox-based browsers, apply PAC/user.js to point to the local proxy
|
||||
if matches!(
|
||||
profile_for_launch.browser.as_str(),
|
||||
"firefox" | "firefox-developer" | "zen" | "tor-browser" | "mullvad-browser"
|
||||
) {
|
||||
let profiles_dir = browser_runner.get_profiles_dir();
|
||||
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
|
||||
let profile_path = profiles_dir
|
||||
.join(profile_for_launch.id.to_string())
|
||||
.join("profile");
|
||||
|
||||
// Store the internal proxy settings for later use
|
||||
internal_proxy_settings = Some(internal_proxy.clone());
|
||||
// Provide a dummy upstream (ignored when internal proxy is provided)
|
||||
let dummy_upstream = ProxySettings {
|
||||
proxy_type: "http".to_string(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: internal_proxy.port,
|
||||
username: None,
|
||||
password: None,
|
||||
};
|
||||
|
||||
// Apply the proxy settings with the internal proxy to the profile directory
|
||||
browser_runner
|
||||
.apply_proxy_settings_to_profile(&profile_path, &proxy, Some(&internal_proxy))
|
||||
.map_err(|e| format!("Failed to update profile proxy: {e}"))?;
|
||||
|
||||
println!("Successfully started proxy for profile: {}", profile.name);
|
||||
|
||||
// Give the proxy a moment to fully start up
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to start proxy: {e}");
|
||||
// Still continue with browser launch, but without proxy
|
||||
let browser_runner = BrowserRunner::instance();
|
||||
let profiles_dir = browser_runner.get_profiles_dir();
|
||||
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
|
||||
|
||||
// Apply proxy settings without internal proxy
|
||||
browser_runner
|
||||
.apply_proxy_settings_to_profile(&profile_path, &proxy, None)
|
||||
.apply_proxy_settings_to_profile(&profile_path, &dummy_upstream, Some(&internal_proxy))
|
||||
.map_err(|e| format!("Failed to update profile proxy: {e}"))?;
|
||||
}
|
||||
|
||||
println!(
|
||||
"Local proxy prepared for profile: {} on port: {} (upstream: {})",
|
||||
profile_for_launch.name,
|
||||
internal_proxy.port,
|
||||
upstream_proxy
|
||||
.as_ref()
|
||||
.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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1535,20 +1582,9 @@ pub async fn launch_browser_profile(
|
||||
})?;
|
||||
|
||||
// Now update the proxy with the correct PID if we have one
|
||||
if let Some(proxy_id) = &profile.proxy_id {
|
||||
if PROXY_MANAGER.get_proxy_settings_by_id(proxy_id).is_some() {
|
||||
if let Some(actual_pid) = updated_profile.process_id {
|
||||
// Update the proxy manager with the correct PID
|
||||
match PROXY_MANAGER.update_proxy_pid(1u32, actual_pid) {
|
||||
Ok(()) => {
|
||||
println!("Updated proxy PID mapping from temp (1) to actual PID: {actual_pid}");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to update proxy PID mapping: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(actual_pid) = updated_profile.process_id {
|
||||
// Update the proxy manager with the correct PID (we always started with temp pid 1 for non-Camoufox)
|
||||
let _ = PROXY_MANAGER.update_proxy_pid(1u32, actual_pid);
|
||||
}
|
||||
|
||||
Ok(updated_profile)
|
||||
|
||||
@@ -135,30 +135,57 @@ impl DownloadedBrowsersRegistry {
|
||||
version: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
if let Some(info) = self.remove_browser(browser, version) {
|
||||
// Clean up any files that might have been left behind
|
||||
// Clean up extracted binaries but preserve downloaded archives
|
||||
if info.file_path.exists() {
|
||||
if info.file_path.is_dir() {
|
||||
fs::remove_dir_all(&info.file_path)?;
|
||||
// Allowed archive extensions to preserve
|
||||
let archive_exts = [
|
||||
"zip", "dmg", "tar.xz", "tar.gz", "tar.bz2", "AppImage", "exe", "pkg", "msi",
|
||||
];
|
||||
|
||||
for entry in fs::read_dir(&info.file_path)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_dir() {
|
||||
fs::remove_dir_all(&path)?;
|
||||
continue;
|
||||
}
|
||||
|
||||
// For files, preserve if they look like downloaded archives/installers
|
||||
let keep = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|name| {
|
||||
// Match suffixes (handles multi-part extensions like .tar.xz)
|
||||
archive_exts
|
||||
.iter()
|
||||
.any(|ext| name.to_lowercase().ends_with(&ext.to_lowercase()))
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
if !keep {
|
||||
fs::remove_file(&path)?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fs::remove_file(&info.file_path)?;
|
||||
// It's a file. If it's not an archive, remove it; otherwise preserve it.
|
||||
let file_name = info
|
||||
.file_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("");
|
||||
let archive_exts = [
|
||||
"zip", "dmg", "tar.xz", "tar.gz", "tar.bz2", "AppImage", "exe", "pkg", "msi",
|
||||
];
|
||||
let is_archive = archive_exts
|
||||
.iter()
|
||||
.any(|ext| file_name.to_lowercase().ends_with(&ext.to_lowercase()));
|
||||
if !is_archive {
|
||||
fs::remove_file(&info.file_path)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also clean up the browser directory if it exists
|
||||
let base_dirs = BaseDirs::new().ok_or("Failed to get base directories")?;
|
||||
let mut browser_dir = base_dirs.data_local_dir().to_path_buf();
|
||||
browser_dir.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
browser_dir.push("binaries");
|
||||
browser_dir.push(browser);
|
||||
browser_dir.push(version);
|
||||
|
||||
if browser_dir.exists() {
|
||||
fs::remove_dir_all(&browser_dir)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -334,16 +361,20 @@ impl DownloadedBrowsersRegistry {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this browser/version is already in registry
|
||||
// Only add to registry if this looks like a valid installed browser, not just an archive
|
||||
if !self.is_browser_downloaded(browser_name, version_name) {
|
||||
// Add to registry
|
||||
let info = DownloadedBrowserInfo {
|
||||
browser: browser_name.to_string(),
|
||||
version: version_name.to_string(),
|
||||
file_path: version_path.clone(),
|
||||
};
|
||||
self.add_browser(info);
|
||||
changes.push(format!("Added {browser_name} {version_name} to registry"));
|
||||
if let Ok(browser_type) = crate::browser::BrowserType::from_str(browser_name) {
|
||||
let browser = crate::browser::create_browser(browser_type);
|
||||
if browser.is_version_downloaded(version_name, binaries_dir) {
|
||||
let info = DownloadedBrowserInfo {
|
||||
browser: browser_name.to_string(),
|
||||
version: version_name.to_string(),
|
||||
file_path: version_path.clone(),
|
||||
};
|
||||
self.add_browser(info);
|
||||
changes.push(format!("Added {browser_name} {version_name} to registry"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -25,16 +25,37 @@ struct GroupsData {
|
||||
|
||||
pub struct GroupManager {
|
||||
base_dirs: BaseDirs,
|
||||
data_dir_override: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl GroupManager {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
// Helper for tests to override data directory without global env var
|
||||
#[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_groups_file_path(&self) -> PathBuf {
|
||||
if let Some(dir) = &self.data_dir_override {
|
||||
let mut override_path = dir.clone();
|
||||
// Ensure the directory exists before returning the path
|
||||
let _ = fs::create_dir_all(&override_path);
|
||||
override_path.push("groups.json");
|
||||
return override_path;
|
||||
}
|
||||
|
||||
let mut path = self.base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
@@ -152,29 +173,26 @@ impl GroupManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Create result with counts
|
||||
// Create result including all groups (even those with 0 count)
|
||||
let mut result = Vec::new();
|
||||
for group in groups {
|
||||
let count = group_counts.get(&group.id).copied().unwrap_or(0);
|
||||
if count > 0 {
|
||||
result.push(GroupWithCount {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
count,
|
||||
});
|
||||
}
|
||||
result.push(GroupWithCount {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
count,
|
||||
});
|
||||
}
|
||||
|
||||
// Add default group count (profiles without group_id)
|
||||
// Add default group count (profiles without group_id), always include even if 0
|
||||
let default_count = profiles.iter().filter(|p| p.group_id.is_none()).count();
|
||||
if default_count > 0 {
|
||||
let default_group = GroupWithCount {
|
||||
id: "default".to_string(),
|
||||
name: "Default".to_string(),
|
||||
count: default_count,
|
||||
};
|
||||
result.insert(0, default_group);
|
||||
}
|
||||
let default_group = GroupWithCount {
|
||||
id: "default".to_string(),
|
||||
name: "Default".to_string(),
|
||||
count: default_count,
|
||||
};
|
||||
// Insert at the beginning for consistent ordering with UI expectations
|
||||
result.insert(0, default_group);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
@@ -197,7 +215,9 @@ mod tests {
|
||||
// Set up a temporary home directory for testing
|
||||
env::set_var("HOME", temp_dir.path());
|
||||
|
||||
let manager = GroupManager::new();
|
||||
// Use per-test isolated data directory without relying on global env vars
|
||||
let data_override = temp_dir.path().join("donutbrowser_test_data");
|
||||
let manager = GroupManager::with_data_dir_override(&data_override);
|
||||
(manager, temp_dir)
|
||||
}
|
||||
|
||||
@@ -411,11 +431,11 @@ mod tests {
|
||||
.get_groups_with_profile_counts(&profiles)
|
||||
.expect("Should get groups with counts");
|
||||
|
||||
// Should have default group + group1 (_group2 has no profiles so shouldn't appear)
|
||||
// Should have default group + group1 + group2 (group2 has 0 profiles but should still appear)
|
||||
assert_eq!(
|
||||
groups_with_counts.len(),
|
||||
2,
|
||||
"Should have 2 groups with profiles"
|
||||
3,
|
||||
"Should include all groups, even those with 0 profiles"
|
||||
);
|
||||
|
||||
// Check default group
|
||||
@@ -434,6 +454,13 @@ mod tests {
|
||||
.find(|g| g.id == group1.id)
|
||||
.expect("Should have group1");
|
||||
assert_eq!(group1_with_count.count, 2, "Group1 should have 2 profiles");
|
||||
|
||||
// Check that group2 exists with 0 profiles
|
||||
let group2_with_count = groups_with_counts
|
||||
.iter()
|
||||
.find(|g| g.name == "Group 2")
|
||||
.expect("Should have group2 present even with 0 profiles");
|
||||
assert_eq!(group2_with_count.count, 0, "Group2 should have 0 profiles");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+31
-30
@@ -118,6 +118,35 @@ impl<R: Runtime> WindowExt for WebviewWindow<R> {
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn warm_up_nodecar(app: tauri::AppHandle) -> Result<(), String> {
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
// Use sidecar to execute a fast, harmless command that ensures the binary is loaded
|
||||
let cmd = app
|
||||
.shell()
|
||||
.sidecar("nodecar")
|
||||
.map_err(|e| format!("Failed to create nodecar sidecar: {e}"))?
|
||||
.arg("help");
|
||||
|
||||
let exec_future = async { cmd.output().await };
|
||||
match timeout(Duration::from_secs(120), exec_future).await {
|
||||
Ok(Ok(_output)) => {
|
||||
let duration = start_time.elapsed();
|
||||
println!(
|
||||
"Nodecar warm-up (frontend-triggered) completed in {:.2}s",
|
||||
duration.as_secs_f64()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Ok(Err(e)) => Err(format!("Failed to execute nodecar for warm-up: {e}")),
|
||||
Err(_) => Err("Nodecar warm-up timed out after 120s".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), String> {
|
||||
println!("handle_url_open called with URL: {url}");
|
||||
@@ -460,36 +489,7 @@ pub fn run() {
|
||||
}
|
||||
});
|
||||
|
||||
// Warm up nodecar binary in the background
|
||||
tauri::async_runtime::spawn(async move {
|
||||
println!("Starting nodecar warm-up...");
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
// Send a ping request to nodecar to trigger unpacking/warm-up
|
||||
match tokio::process::Command::new("nodecar").output().await {
|
||||
Ok(output) => {
|
||||
let duration = start_time.elapsed();
|
||||
if output.status.success() {
|
||||
println!(
|
||||
"Nodecar warm-up completed successfully in {:.2}s",
|
||||
duration.as_secs_f64()
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"Nodecar warm-up completed with non-zero exit code in {:.2}s",
|
||||
duration.as_secs_f64()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let duration = start_time.elapsed();
|
||||
println!(
|
||||
"Nodecar warm-up failed after {:.2}s: {e}",
|
||||
duration.as_secs_f64()
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Nodecar warm-up is now triggered from the frontend to allow UI blocking overlay
|
||||
|
||||
Ok(())
|
||||
})
|
||||
@@ -549,6 +549,7 @@ pub fn run() {
|
||||
delete_selected_profiles,
|
||||
is_geoip_database_available,
|
||||
download_geoip_database,
|
||||
warm_up_nodecar,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -48,7 +48,38 @@ pub mod macos {
|
||||
args: &[String],
|
||||
) -> Result<std::process::Child, Box<dyn std::error::Error + Send + Sync>> {
|
||||
println!("Launching browser on macOS: {executable_path:?} with args: {args:?}");
|
||||
Ok(Command::new(executable_path).args(args).spawn()?)
|
||||
// If the executable is inside an app bundle, launch via Launch Services so
|
||||
// macOS recognizes the real application for privacy permissions (e.g. Screen Recording).
|
||||
// This ensures TCC prompts are attributed to the browser app, not our launcher.
|
||||
let mut current = Some(executable_path);
|
||||
let mut app_bundle: Option<std::path::PathBuf> = None;
|
||||
while let Some(path) = current {
|
||||
if let Some(file_name) = path.file_name().and_then(|s| s.to_str()) {
|
||||
if file_name.ends_with(".app") {
|
||||
app_bundle = Some(path.to_path_buf());
|
||||
break;
|
||||
}
|
||||
}
|
||||
current = path.parent();
|
||||
}
|
||||
|
||||
if let Some(app_path) = app_bundle {
|
||||
// Use `open -n -a <App>.app --args ...` to launch the app bundle.
|
||||
// Note: The returned child PID will belong to `open`, not the browser.
|
||||
// The caller should resolve the actual browser PID after launch.
|
||||
let mut cmd = Command::new("open");
|
||||
cmd.arg("-n");
|
||||
cmd.arg("-a");
|
||||
cmd.arg(app_path);
|
||||
cmd.arg("--args");
|
||||
for a in args {
|
||||
cmd.arg(a);
|
||||
}
|
||||
Ok(cmd.spawn()?)
|
||||
} else {
|
||||
// Fallback: direct spawn if this is not an app bundle
|
||||
Ok(Command::new(executable_path).args(args).spawn()?)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn open_url_in_existing_browser_firefox_like(
|
||||
|
||||
+152
-124
@@ -497,19 +497,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 +507,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 +559,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;
|
||||
@@ -741,25 +676,51 @@ impl ProfileManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Update the process ID if we found a different one
|
||||
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) {
|
||||
println!("Warning: Failed to update profile with new PID: {e}");
|
||||
// Only persist status changes if the profile metadata still exists on disk
|
||||
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 metadata_exists {
|
||||
// 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 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 merged.process_id.is_some() {
|
||||
// Clear the PID if no process found
|
||||
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;
|
||||
}
|
||||
}
|
||||
} else if inner_profile.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) {
|
||||
println!("Warning: Failed to clear profile PID: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Emit profile update event to frontend
|
||||
if let Err(e) = app_handle.emit("profile-updated", &inner_profile) {
|
||||
println!("Warning: Failed to emit profile update event: {e}");
|
||||
// Emit profile update event to frontend
|
||||
if let Err(e) = app_handle.emit("profile-updated", &merged) {
|
||||
println!("Warning: Failed to emit profile update event: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(is_running)
|
||||
@@ -782,38 +743,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;
|
||||
if process_id_changed {
|
||||
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}");
|
||||
}
|
||||
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();
|
||||
|
||||
// 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 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(),
|
||||
};
|
||||
|
||||
println!(
|
||||
"Camoufox process has started for profile '{}' with PID: {:?}",
|
||||
profile.name, camoufox_process.processId
|
||||
);
|
||||
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}");
|
||||
}
|
||||
|
||||
// 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
|
||||
if profile.process_id.is_some() {
|
||||
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}");
|
||||
}
|
||||
// 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();
|
||||
|
||||
// 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 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(),
|
||||
};
|
||||
|
||||
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)
|
||||
@@ -821,16 +815,35 @@ impl ProfileManager {
|
||||
Err(e) => {
|
||||
// Error checking status, assume not running and clear process ID
|
||||
println!("Warning: Failed to check Camoufox status via nodecar: {e}");
|
||||
if profile.process_id.is_some() {
|
||||
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}");
|
||||
}
|
||||
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();
|
||||
|
||||
// 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 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(),
|
||||
};
|
||||
|
||||
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)
|
||||
@@ -884,6 +897,15 @@ impl ProfileManager {
|
||||
"user_pref(\"browser.preferences.moreFromMozilla\", false);".to_string(),
|
||||
"user_pref(\"services.sync.prefs.sync.browser.startup.upgradeDialog.enabled\", false);"
|
||||
.to_string(),
|
||||
// Disable welcome / first-run screens
|
||||
"user_pref(\"browser.aboutwelcome.enabled\", false);".to_string(),
|
||||
"user_pref(\"browser.startup.homepage_override.mstone\", \"ignore\");".to_string(),
|
||||
"user_pref(\"startup.homepage_welcome_url\", \"\");".to_string(),
|
||||
"user_pref(\"startup.homepage_welcome_url.additional\", \"\");".to_string(),
|
||||
"user_pref(\"startup.homepage_override_url\", \"\");".to_string(),
|
||||
// Keep extension updates enabled
|
||||
"user_pref(\"extensions.update.enabled\", true);".to_string(),
|
||||
"user_pref(\"extensions.update.autoUpdateDefault\", true);".to_string(),
|
||||
"user_pref(\"app.update.enabled\", false);".to_string(),
|
||||
"user_pref(\"app.update.staging.enabled\", false);".to_string(),
|
||||
"user_pref(\"app.update.timerFirstInterval\", -1);".to_string(),
|
||||
@@ -899,6 +921,12 @@ impl ProfileManager {
|
||||
"user_pref(\"app.update.interval\", -1);".to_string(),
|
||||
"user_pref(\"app.update.background.interval\", -1);".to_string(),
|
||||
"user_pref(\"app.update.idletime\", -1);".to_string(),
|
||||
// Suppress additional update UI/prompts
|
||||
"user_pref(\"app.update.doorhanger\", false);".to_string(),
|
||||
"user_pref(\"app.update.badge\", false);".to_string(),
|
||||
"user_pref(\"app.update.background.scheduling.enabled\", false);".to_string(),
|
||||
// Suppress upgrade dialogs on startup
|
||||
"user_pref(\"browser.startup.upgradeDialog.enabled\", false);".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
+130
-40
@@ -18,6 +18,8 @@ pub struct ProxyInfo {
|
||||
pub upstream_port: u16,
|
||||
pub upstream_type: String,
|
||||
pub local_port: u16,
|
||||
// Optional profile name to which this proxy instance is logically tied
|
||||
pub profile_name: Option<String>,
|
||||
}
|
||||
|
||||
// Stored proxy configuration with name and ID for reuse
|
||||
@@ -51,7 +53,9 @@ pub struct ProxyManager {
|
||||
active_proxies: Mutex<HashMap<u32, ProxyInfo>>, // Maps browser process ID to proxy info
|
||||
// Store proxy info by profile name for persistence across browser restarts
|
||||
profile_proxies: Mutex<HashMap<String, ProxySettings>>, // Maps profile name to proxy settings
|
||||
stored_proxies: Mutex<HashMap<String, StoredProxy>>, // Maps proxy ID to stored proxy
|
||||
// Track active proxy IDs by profile name for targeted cleanup
|
||||
profile_active_proxy_ids: Mutex<HashMap<String, String>>, // Maps profile name to proxy id
|
||||
stored_proxies: Mutex<HashMap<String, StoredProxy>>, // Maps proxy ID to stored proxy
|
||||
base_dirs: BaseDirs,
|
||||
}
|
||||
|
||||
@@ -61,6 +65,7 @@ impl ProxyManager {
|
||||
let manager = Self {
|
||||
active_proxies: Mutex::new(HashMap::new()),
|
||||
profile_proxies: Mutex::new(HashMap::new()),
|
||||
profile_active_proxy_ids: Mutex::new(HashMap::new()),
|
||||
stored_proxies: Mutex::new(HashMap::new()),
|
||||
base_dirs,
|
||||
};
|
||||
@@ -257,42 +262,93 @@ impl ProxyManager {
|
||||
browser_pid: u32,
|
||||
profile_name: Option<&str>,
|
||||
) -> Result<ProxySettings, String> {
|
||||
// Check if we already have a proxy for this browser
|
||||
// First, proactively cleanup any dead proxies so we don't accidentally reuse stale ones
|
||||
let _ = self.cleanup_dead_proxies(app_handle.clone()).await;
|
||||
|
||||
// If we have a previous proxy tied to this profile, and the upstream settings are changing,
|
||||
// stop it before starting a new one so the change takes effect immediately.
|
||||
if let Some(name) = profile_name {
|
||||
// Check if we have an active proxy recorded for this profile
|
||||
let maybe_existing_id = {
|
||||
let map = self.profile_active_proxy_ids.lock().unwrap();
|
||||
map.get(name).cloned()
|
||||
};
|
||||
|
||||
if let Some(existing_id) = maybe_existing_id {
|
||||
// Find the existing proxy info
|
||||
let existing_info = {
|
||||
let proxies = self.active_proxies.lock().unwrap();
|
||||
proxies.values().find(|p| p.id == existing_id).cloned()
|
||||
};
|
||||
|
||||
if let Some(existing) = existing_info {
|
||||
let desired_type = proxy_settings
|
||||
.map(|p| p.proxy_type.as_str())
|
||||
.unwrap_or("DIRECT");
|
||||
let desired_host = proxy_settings.map(|p| p.host.as_str()).unwrap_or("DIRECT");
|
||||
let desired_port = proxy_settings.map(|p| p.port).unwrap_or(0);
|
||||
|
||||
let is_same_upstream = existing.upstream_type == desired_type
|
||||
&& existing.upstream_host == desired_host
|
||||
&& existing.upstream_port == desired_port;
|
||||
|
||||
if !is_same_upstream {
|
||||
// Stop the previous proxy tied to this profile (best effort)
|
||||
// We don't know the original PID mapping that created it; iterate to find its key
|
||||
let pid_to_stop = {
|
||||
let proxies = self.active_proxies.lock().unwrap();
|
||||
proxies.iter().find_map(|(pid, info)| {
|
||||
if info.id == existing_id {
|
||||
Some(*pid)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
};
|
||||
if let Some(pid) = pid_to_stop {
|
||||
let _ = self.stop_proxy(app_handle.clone(), pid).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check if we already have a proxy for this browser PID. If it exists but the upstream
|
||||
// settings don't match the newly requested ones, stop it and create a new proxy so that
|
||||
// changes take effect immediately.
|
||||
let mut needs_restart = false;
|
||||
{
|
||||
let proxies = self.active_proxies.lock().unwrap();
|
||||
if let Some(proxy) = proxies.get(&browser_pid) {
|
||||
return Ok(ProxySettings {
|
||||
proxy_type: "http".to_string(),
|
||||
host: "127.0.0.1".to_string(), // Use 127.0.0.1 instead of localhost for better compatibility
|
||||
port: proxy.local_port,
|
||||
username: None,
|
||||
password: None,
|
||||
});
|
||||
if let Some(existing) = proxies.get(&browser_pid) {
|
||||
let desired_type = proxy_settings
|
||||
.map(|p| p.proxy_type.as_str())
|
||||
.unwrap_or("DIRECT");
|
||||
let desired_host = proxy_settings.map(|p| p.host.as_str()).unwrap_or("DIRECT");
|
||||
let desired_port = proxy_settings.map(|p| p.port).unwrap_or(0);
|
||||
|
||||
let is_same_upstream = existing.upstream_type == desired_type
|
||||
&& existing.upstream_host == desired_host
|
||||
&& existing.upstream_port == desired_port;
|
||||
|
||||
if is_same_upstream {
|
||||
// Reuse existing local proxy
|
||||
return Ok(ProxySettings {
|
||||
proxy_type: "http".to_string(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: existing.local_port,
|
||||
username: None,
|
||||
password: None,
|
||||
});
|
||||
} else {
|
||||
// Upstream changed; we must restart the local proxy so that traffic is routed correctly
|
||||
needs_restart = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
};
|
||||
if needs_restart {
|
||||
// Best-effort stop of the old proxy for this PID before starting a new one
|
||||
let _ = self.stop_proxy(app_handle.clone(), browser_pid).await;
|
||||
}
|
||||
|
||||
// Start a new proxy using the nodecar binary with the correct CLI interface
|
||||
let mut nodecar = app_handle
|
||||
@@ -321,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
|
||||
@@ -367,8 +418,33 @@ impl ProxyManager {
|
||||
.map(|p| p.proxy_type.clone())
|
||||
.unwrap_or_else(|| "DIRECT".to_string()),
|
||||
local_port,
|
||||
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();
|
||||
@@ -381,6 +457,9 @@ impl ProxyManager {
|
||||
let mut profile_proxies = self.profile_proxies.lock().unwrap();
|
||||
profile_proxies.insert(name.to_string(), proxy_settings.clone());
|
||||
}
|
||||
// Also record the active proxy id for this profile for quick cleanup on changes
|
||||
let mut map = self.profile_active_proxy_ids.lock().unwrap();
|
||||
map.insert(name.to_string(), proxy_info.id.clone());
|
||||
}
|
||||
|
||||
// Return proxy settings for the browser
|
||||
@@ -399,10 +478,10 @@ impl ProxyManager {
|
||||
app_handle: tauri::AppHandle,
|
||||
browser_pid: u32,
|
||||
) -> Result<(), String> {
|
||||
let proxy_id = {
|
||||
let (proxy_id, profile_name): (String, Option<String>) = {
|
||||
let mut proxies = self.active_proxies.lock().unwrap();
|
||||
match proxies.remove(&browser_pid) {
|
||||
Some(proxy) => proxy.id,
|
||||
Some(proxy) => (proxy.id, proxy.profile_name.clone()),
|
||||
None => return Ok(()), // No proxy to stop
|
||||
}
|
||||
};
|
||||
@@ -415,7 +494,7 @@ impl ProxyManager {
|
||||
.arg("proxy")
|
||||
.arg("stop")
|
||||
.arg("--id")
|
||||
.arg(proxy_id);
|
||||
.arg(&proxy_id);
|
||||
|
||||
let output = nodecar.output().await.unwrap();
|
||||
|
||||
@@ -425,6 +504,16 @@ impl ProxyManager {
|
||||
// We still return Ok since we've already removed the proxy from our tracking
|
||||
}
|
||||
|
||||
// Clear profile-to-proxy mapping if it references this proxy
|
||||
if let Some(name) = profile_name {
|
||||
let mut map = self.profile_active_proxy_ids.lock().unwrap();
|
||||
if let Some(current_id) = map.get(&name) {
|
||||
if current_id == &proxy_id {
|
||||
map.remove(&name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -624,6 +713,7 @@ mod tests {
|
||||
upstream_port: 3128,
|
||||
upstream_type: "http".to_string(),
|
||||
local_port: (8000 + i) as u16,
|
||||
profile_name: None,
|
||||
};
|
||||
|
||||
// Add proxy
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Donut Browser",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.3",
|
||||
"identifier": "com.donutbrowser",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
+34
-41
@@ -16,7 +16,6 @@ import { PermissionDialog } from "@/components/permission-dialog";
|
||||
import { ProfilesDataTable } from "@/components/profile-data-table";
|
||||
import { ProfileSelectorDialog } from "@/components/profile-selector-dialog";
|
||||
import { ProxyManagementDialog } from "@/components/proxy-management-dialog";
|
||||
import { ProxySettingsDialog } from "@/components/proxy-settings-dialog";
|
||||
import { SettingsDialog } from "@/components/settings-dialog";
|
||||
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
|
||||
import type { PermissionType } from "@/hooks/use-permissions";
|
||||
@@ -41,9 +40,9 @@ interface PendingUrl {
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [isInitializing, setIsInitializing] = useState(true);
|
||||
const [profiles, setProfiles] = useState<BrowserProfile[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [proxyDialogOpen, setProxyDialogOpen] = useState(false);
|
||||
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
|
||||
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||
const [importProfileDialogOpen, setImportProfileDialogOpen] = useState(false);
|
||||
@@ -61,8 +60,6 @@ export default function Home() {
|
||||
>([]);
|
||||
const [selectedProfiles, setSelectedProfiles] = useState<string[]>([]);
|
||||
const [pendingUrls, setPendingUrls] = useState<PendingUrl[]>([]);
|
||||
const [currentProfileForProxy, setCurrentProfileForProxy] =
|
||||
useState<BrowserProfile | null>(null);
|
||||
const [currentProfileForCamoufoxConfig, setCurrentProfileForCamoufoxConfig] =
|
||||
useState<BrowserProfile | null>(null);
|
||||
const [hasCheckedStartupPrompt, setHasCheckedStartupPrompt] = useState(false);
|
||||
@@ -264,6 +261,28 @@ export default function Home() {
|
||||
}
|
||||
}, [hasCheckedStartupPrompt]);
|
||||
|
||||
// Warm up nodecar at startup and block UI until complete
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
await invoke("warm_up_nodecar");
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
`Initialization failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setIsInitializing(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const checkAllPermissions = useCallback(async () => {
|
||||
try {
|
||||
// Wait for permissions to be initialized before checking
|
||||
@@ -349,11 +368,6 @@ export default function Home() {
|
||||
}
|
||||
}, [handleUrlOpen]);
|
||||
|
||||
const openProxyDialog = useCallback((profile: BrowserProfile | null) => {
|
||||
setCurrentProfileForProxy(profile);
|
||||
setProxyDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleConfigureCamoufox = useCallback((profile: BrowserProfile) => {
|
||||
setCurrentProfileForCamoufoxConfig(profile);
|
||||
setCamoufoxConfigDialogOpen(true);
|
||||
@@ -378,28 +392,6 @@ export default function Home() {
|
||||
[loadProfiles],
|
||||
);
|
||||
|
||||
const handleSaveProxy = useCallback(
|
||||
async (proxyId: string | null) => {
|
||||
setProxyDialogOpen(false);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (currentProfileForProxy) {
|
||||
await invoke("update_profile_proxy", {
|
||||
profileName: currentProfileForProxy.name,
|
||||
proxyId: proxyId,
|
||||
});
|
||||
}
|
||||
await loadProfiles();
|
||||
// Trigger proxy data reload in the table
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to update proxy settings:", err);
|
||||
setError(`Failed to update proxy settings: ${JSON.stringify(err)}`);
|
||||
}
|
||||
},
|
||||
[currentProfileForProxy, loadProfiles],
|
||||
);
|
||||
|
||||
const loadGroups = useCallback(async () => {
|
||||
setGroupsLoading(true);
|
||||
try {
|
||||
@@ -795,7 +787,6 @@ export default function Home() {
|
||||
data={profiles}
|
||||
onLaunchProfile={launchProfile}
|
||||
onKillProfile={handleKillProfile}
|
||||
onProxySettings={openProxyDialog}
|
||||
onDeleteProfile={handleDeleteProfile}
|
||||
onRenameProfile={handleRenameProfile}
|
||||
onConfigureCamoufox={handleConfigureCamoufox}
|
||||
@@ -810,15 +801,17 @@ export default function Home() {
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<ProxySettingsDialog
|
||||
isOpen={proxyDialogOpen}
|
||||
onClose={() => {
|
||||
setProxyDialogOpen(false);
|
||||
}}
|
||||
onSave={handleSaveProxy}
|
||||
initialProxyId={currentProfileForProxy?.proxy_id}
|
||||
browserType={currentProfileForProxy?.browser}
|
||||
/>
|
||||
{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="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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CreateProfileDialog
|
||||
isOpen={createProfileDialogOpen}
|
||||
|
||||
@@ -17,26 +17,20 @@ interface AppUpdateToastProps {
|
||||
|
||||
function getStageIcon(stage?: string, isUpdating?: boolean) {
|
||||
if (!isUpdating) {
|
||||
return <FaDownload className="flex-shrink-0 w-5 h-5 text-blue-500" />;
|
||||
return <FaDownload className="flex-shrink-0 w-5 h-5" />;
|
||||
}
|
||||
|
||||
switch (stage) {
|
||||
case "downloading":
|
||||
return <FaDownload className="flex-shrink-0 w-5 h-5 text-blue-500" />;
|
||||
return <FaDownload className="flex-shrink-0 w-5 h-5" />;
|
||||
case "extracting":
|
||||
return (
|
||||
<LuRefreshCw className="flex-shrink-0 w-5 h-5 text-blue-500 animate-spin" />
|
||||
);
|
||||
return <LuRefreshCw className="flex-shrink-0 w-5 h-5 animate-spin" />;
|
||||
case "installing":
|
||||
return (
|
||||
<LuCog className="flex-shrink-0 w-5 h-5 text-blue-500 animate-spin" />
|
||||
);
|
||||
return <LuCog className="flex-shrink-0 w-5 h-5 animate-spin" />;
|
||||
case "completed":
|
||||
return <LuCheckCheck className="flex-shrink-0 w-5 h-5 text-green-500" />;
|
||||
return <LuCheckCheck className="flex-shrink-0 w-5 h-5" />;
|
||||
default:
|
||||
return (
|
||||
<LuRefreshCw className="flex-shrink-0 w-5 h-5 text-blue-500 animate-spin" />
|
||||
);
|
||||
return <LuRefreshCw className="flex-shrink-0 w-5 h-5 animate-spin" />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +73,7 @@ export function AppUpdateToast({
|
||||
updateProgress.stage === "completed");
|
||||
|
||||
return (
|
||||
<div className="flex items-start p-4 w-full max-w-md bg-card rounded-lg border border-border shadow-lg text-card-foreground">
|
||||
<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="mr-3 mt-0.5">
|
||||
{getStageIcon(updateProgress?.stage, isUpdating)}
|
||||
</div>
|
||||
|
||||
@@ -103,7 +103,7 @@ export function CamoufoxConfigDialog({
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>
|
||||
Configure Camoufox Settings - {profile.name}
|
||||
Configure Fingerprint Settings - {profile.name}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -634,6 +635,7 @@ export function CreateProfileDialog({
|
||||
onSave={(proxy) => {
|
||||
setStoredProxies((prev) => [...prev, proxy]);
|
||||
setSelectedProxyId(proxy.id);
|
||||
void emit("stored-proxies-changed");
|
||||
}}
|
||||
/>
|
||||
</Dialog>
|
||||
|
||||
@@ -53,7 +53,6 @@ import {
|
||||
LuCheckCheck,
|
||||
LuDownload,
|
||||
LuRefreshCw,
|
||||
LuRocket,
|
||||
LuTriangleAlert,
|
||||
} from "react-icons/lu";
|
||||
import type { ExternalToast } from "sonner";
|
||||
@@ -127,36 +126,38 @@ type ToastProps =
|
||||
function getToastIcon(type: ToastProps["type"], stage?: string) {
|
||||
switch (type) {
|
||||
case "success":
|
||||
return <LuCheckCheck className="flex-shrink-0 w-4 h-4 text-green-500" />;
|
||||
return <LuCheckCheck className="flex-shrink-0 w-4 h-4 text-foreground" />;
|
||||
case "error":
|
||||
return <LuTriangleAlert className="flex-shrink-0 w-4 h-4 text-red-500" />;
|
||||
return (
|
||||
<LuTriangleAlert className="flex-shrink-0 w-4 h-4 text-foreground" />
|
||||
);
|
||||
case "download":
|
||||
if (stage === "completed") {
|
||||
return (
|
||||
<LuCheckCheck className="flex-shrink-0 w-4 h-4 text-green-500" />
|
||||
<LuCheckCheck className="flex-shrink-0 w-4 h-4 text-foreground" />
|
||||
);
|
||||
}
|
||||
return <LuDownload className="flex-shrink-0 w-4 h-4 text-blue-500" />;
|
||||
return <LuDownload className="flex-shrink-0 w-4 h-4 text-foreground" />;
|
||||
|
||||
case "version-update":
|
||||
return (
|
||||
<LuRefreshCw className="flex-shrink-0 w-4 h-4 text-blue-500 animate-spin" />
|
||||
<LuRefreshCw className="flex-shrink-0 w-4 h-4 animate-spin text-foreground" />
|
||||
);
|
||||
case "fetching":
|
||||
return (
|
||||
<LuRefreshCw className="flex-shrink-0 w-4 h-4 text-blue-500 animate-spin" />
|
||||
<LuRefreshCw className="flex-shrink-0 w-4 h-4 animate-spin text-foreground" />
|
||||
);
|
||||
case "twilight-update":
|
||||
return (
|
||||
<LuRefreshCw className="flex-shrink-0 w-4 h-4 text-purple-500 animate-spin" />
|
||||
<LuRefreshCw className="flex-shrink-0 w-4 h-4 animate-spin text-foreground" />
|
||||
);
|
||||
case "loading":
|
||||
return (
|
||||
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 border-blue-500 animate-spin border-t-transparent" />
|
||||
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 border-blue-500 animate-spin border-t-transparent" />
|
||||
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -166,33 +167,11 @@ export function UnifiedToast(props: ToastProps) {
|
||||
const stage = "stage" in props ? props.stage : undefined;
|
||||
const progress = "progress" in props ? props.progress : undefined;
|
||||
|
||||
// Check if this is an auto-update toast
|
||||
const isAutoUpdate = title.includes("update started");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-start p-3 w-96 rounded-lg border shadow-lg ${
|
||||
isAutoUpdate
|
||||
? "bg-emerald-50 border-emerald-200 dark:bg-emerald-950 dark:border-emerald-800"
|
||||
: "bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-700"
|
||||
}`}
|
||||
data-toast-type={isAutoUpdate ? "auto-update" : "default"}
|
||||
>
|
||||
<div className="mr-3 mt-0.5">
|
||||
{isAutoUpdate ? (
|
||||
<LuRocket className="flex-shrink-0 w-4 h-4 text-emerald-500" />
|
||||
) : (
|
||||
getToastIcon(type, stage)
|
||||
)}
|
||||
</div>
|
||||
<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="mr-3 mt-0.5">{getToastIcon(type, stage)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={`text-sm font-medium leading-tight ${
|
||||
isAutoUpdate
|
||||
? "text-emerald-900 dark:text-emerald-100"
|
||||
: "text-gray-900 dark:text-white"
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm font-semibold leading-tight text-foreground">
|
||||
{title}
|
||||
</p>
|
||||
|
||||
@@ -203,15 +182,15 @@ export function UnifiedToast(props: ToastProps) {
|
||||
stage === "downloading" && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="flex-1 min-w-0 text-xs text-gray-600 dark:text-gray-300">
|
||||
<p className="flex-1 min-w-0 text-xs text-muted-foreground">
|
||||
{progress.percentage.toFixed(1)}%
|
||||
{progress.speed && ` • ${progress.speed} MB/s`}
|
||||
{progress.eta && ` • ${progress.eta} remaining`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
|
||||
<div className="w-full bg-muted rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
|
||||
className="bg-foreground h-1.5 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progress.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -223,21 +202,21 @@ export function UnifiedToast(props: ToastProps) {
|
||||
progress &&
|
||||
"current_browser" in progress && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<p className="text-xs text-gray-600 dark:text-gray-300">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{progress.current_browser && (
|
||||
<>Looking for updates for {progress.current_browser}</>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-1.5 min-w-0">
|
||||
<div className="flex-1 bg-muted rounded-full h-1.5 min-w-0">
|
||||
<div
|
||||
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
|
||||
className="bg-foreground h-1.5 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${(progress.current / progress.total) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-8 text-xs text-right text-gray-500 whitespace-nowrap dark:text-gray-400 shrink-0">
|
||||
<span className="w-8 text-xs text-right whitespace-nowrap text-muted-foreground shrink-0">
|
||||
{progress.current}/{progress.total}
|
||||
</span>
|
||||
</div>
|
||||
@@ -247,13 +226,13 @@ export function UnifiedToast(props: ToastProps) {
|
||||
{/* Twilight update progress */}
|
||||
{type === "twilight-update" && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs text-gray-600 dark:text-gray-300">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{"hasUpdate" in props && props.hasUpdate
|
||||
? "New twilight build available for download"
|
||||
: "Checking for twilight updates..."}
|
||||
</p>
|
||||
{props.browserName && (
|
||||
<p className="mt-1 text-xs text-purple-600 dark:text-purple-400">
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{props.browserName} • Rolling Release
|
||||
</p>
|
||||
)}
|
||||
@@ -262,13 +241,7 @@ export function UnifiedToast(props: ToastProps) {
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p
|
||||
className={`mt-1 text-xs leading-tight ${
|
||||
isAutoUpdate
|
||||
? "text-emerald-700 dark:text-emerald-300"
|
||||
: "text-gray-600 dark:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
<p className="mt-1 text-xs leading-tight text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
@@ -277,17 +250,17 @@ export function UnifiedToast(props: ToastProps) {
|
||||
{type === "download" && !description && (
|
||||
<>
|
||||
{stage === "extracting" && (
|
||||
<p className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Extracting browser files...
|
||||
</p>
|
||||
)}
|
||||
{stage === "verifying" && (
|
||||
<p className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Verifying browser files...
|
||||
</p>
|
||||
)}
|
||||
{stage === "downloading (twilight rolling release)" && (
|
||||
<p className="mt-1 text-xs text-purple-600 dark:text-purple-400">
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Downloading rolling release build...
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -27,10 +27,6 @@ export function GroupBadges({
|
||||
);
|
||||
}
|
||||
|
||||
if (groups.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{groups.map((group) => (
|
||||
|
||||
@@ -35,7 +35,7 @@ const HomeHeader = ({
|
||||
onImportProfileDialogOpen,
|
||||
onCreateProfileDialogOpen,
|
||||
}: Props) => {
|
||||
const _handleLogoClick = () => {
|
||||
const handleLogoClick = () => {
|
||||
// Trigger the same URL handling logic as if the URL came from the system
|
||||
const event = new CustomEvent("url-open-request", {
|
||||
detail: "https://donutbrowser.com",
|
||||
@@ -44,17 +44,17 @@ const HomeHeader = ({
|
||||
};
|
||||
return (
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex gap-3 items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 cursor-pointer"
|
||||
title="Open donutbrowser.com"
|
||||
onClick={_handleLogoClick}
|
||||
onClick={handleLogoClick}
|
||||
>
|
||||
<Logo className="w-10 h-10 transition-transform duration-300 ease-out will-change-transform hover:scale-110" />
|
||||
</button>
|
||||
{selectedProfiles.length > 0 ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm font-medium">
|
||||
{selectedProfiles.length} profile
|
||||
{selectedProfiles.length !== 1 ? "s" : ""} selected
|
||||
|
||||
@@ -9,13 +9,22 @@ import {
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit, listen } from "@tauri-apps/api/event";
|
||||
import * as React from "react";
|
||||
import { CiCircleCheck } from "react-icons/ci";
|
||||
import { IoEllipsisHorizontal } from "react-icons/io5";
|
||||
import { LuChevronDown, LuChevronUp } from "react-icons/lu";
|
||||
import { LuCheck, LuChevronDown, LuChevronUp } from "react-icons/lu";
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -29,6 +38,11 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Table,
|
||||
@@ -61,13 +75,11 @@ interface ProfilesDataTableProps {
|
||||
data: BrowserProfile[];
|
||||
onLaunchProfile: (profile: BrowserProfile) => void | Promise<void>;
|
||||
onKillProfile: (profile: BrowserProfile) => void | Promise<void>;
|
||||
onProxySettings: (profile: BrowserProfile) => void;
|
||||
onDeleteProfile: (profile: BrowserProfile) => void | Promise<void>;
|
||||
onRenameProfile: (oldName: string, newName: string) => Promise<void>;
|
||||
onConfigureCamoufox?: (profile: BrowserProfile) => void;
|
||||
runningProfiles: Set<string>;
|
||||
isUpdating: (browser: string) => boolean;
|
||||
onReloadProxyData?: () => void | Promise<void>;
|
||||
onDeleteSelectedProfiles?: (profileNames: string[]) => Promise<void>;
|
||||
onAssignProfilesToGroup?: (profileNames: string[]) => void;
|
||||
selectedGroupId?: string | null;
|
||||
@@ -79,13 +91,11 @@ export function ProfilesDataTable({
|
||||
data,
|
||||
onLaunchProfile,
|
||||
onKillProfile,
|
||||
onProxySettings,
|
||||
onDeleteProfile,
|
||||
onRenameProfile,
|
||||
onConfigureCamoufox,
|
||||
runningProfiles,
|
||||
isUpdating,
|
||||
onDeleteSelectedProfiles: _onDeleteSelectedProfiles,
|
||||
onAssignProfilesToGroup,
|
||||
selectedGroupId,
|
||||
selectedProfiles: externalSelectedProfiles = [],
|
||||
@@ -108,38 +118,34 @@ export function ProfilesDataTable({
|
||||
);
|
||||
|
||||
const [storedProxies, setStoredProxies] = React.useState<StoredProxy[]>([]);
|
||||
const [openProxySelectorFor, setOpenProxySelectorFor] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [proxyOverrides, setProxyOverrides] = React.useState<
|
||||
Record<string, string | null>
|
||||
>({});
|
||||
const [selectedProfiles, setSelectedProfiles] = React.useState<Set<string>>(
|
||||
new Set(externalSelectedProfiles),
|
||||
);
|
||||
const [showCheckboxes, setShowCheckboxes] = React.useState(false);
|
||||
|
||||
// Helper function to check if a profile has a proxy
|
||||
const hasProxy = React.useCallback(
|
||||
(profile: BrowserProfile): boolean => {
|
||||
if (!profile.proxy_id) return false;
|
||||
const proxy = storedProxies.find((p) => p.id === profile.proxy_id);
|
||||
return proxy !== undefined;
|
||||
const handleProxySelection = React.useCallback(
|
||||
async (profileName: string, proxyId: string | null) => {
|
||||
try {
|
||||
await invoke("update_profile_proxy", {
|
||||
profileName,
|
||||
proxyId,
|
||||
});
|
||||
setProxyOverrides((prev) => ({ ...prev, [profileName]: proxyId }));
|
||||
// Notify other parts of the app so usage counts and lists refresh
|
||||
await emit("profile-updated");
|
||||
} catch (error) {
|
||||
console.error("Failed to update proxy settings:", error);
|
||||
} finally {
|
||||
setOpenProxySelectorFor(null);
|
||||
}
|
||||
},
|
||||
[storedProxies],
|
||||
);
|
||||
|
||||
// Helper function to get proxy info for a profile
|
||||
const getProxyInfo = React.useCallback(
|
||||
(profile: BrowserProfile): StoredProxy | null => {
|
||||
if (!profile.proxy_id) return null;
|
||||
return storedProxies.find((p) => p.id === profile.proxy_id) ?? null;
|
||||
},
|
||||
[storedProxies],
|
||||
);
|
||||
|
||||
// Helper function to get proxy name for display
|
||||
const getProxyDisplayName = React.useCallback(
|
||||
(profile: BrowserProfile): string => {
|
||||
if (!profile.proxy_id) return "Disabled";
|
||||
const proxy = storedProxies.find((p) => p.id === profile.proxy_id);
|
||||
return proxy?.name ?? "Unknown Proxy";
|
||||
},
|
||||
[storedProxies],
|
||||
[],
|
||||
);
|
||||
|
||||
// Filter data by selected group
|
||||
@@ -176,6 +182,24 @@ export function ProfilesDataTable({
|
||||
}
|
||||
}, [browserState.isClient, loadStoredProxies]);
|
||||
|
||||
// Keep stored proxies up-to-date by listening for changes emitted elsewhere in the app
|
||||
React.useEffect(() => {
|
||||
if (!browserState.isClient) return;
|
||||
let unlisten: (() => void) | undefined;
|
||||
(async () => {
|
||||
try {
|
||||
unlisten = await listen("stored-proxies-changed", () => {
|
||||
void loadStoredProxies();
|
||||
});
|
||||
} catch (_err) {
|
||||
// Best-effort only
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, [browserState.isClient, loadStoredProxies]);
|
||||
|
||||
// Automatically deselect profiles that become running, updating, launching, or stopping
|
||||
React.useEffect(() => {
|
||||
setSelectedProfiles((prev) => {
|
||||
@@ -669,41 +693,135 @@ export function ProfilesDataTable({
|
||||
header: "Proxy",
|
||||
cell: ({ row }) => {
|
||||
const profile = row.original;
|
||||
const profileHasProxy = hasProxy(profile);
|
||||
const proxyDisplayName = getProxyDisplayName(profile);
|
||||
const proxyInfo = getProxyInfo(profile);
|
||||
const isRunning =
|
||||
browserState.isClient && runningProfiles.has(profile.name);
|
||||
const isLaunching = launchingProfiles.has(profile.name);
|
||||
const isStopping = stoppingProfiles.has(profile.name);
|
||||
const isBrowserUpdating = isUpdating(profile.browser);
|
||||
const isDisabled =
|
||||
isRunning || isLaunching || isStopping || isBrowserUpdating;
|
||||
|
||||
const hasOverride = Object.hasOwn(proxyOverrides, profile.name);
|
||||
const effectiveProxyId = hasOverride
|
||||
? proxyOverrides[profile.name]
|
||||
: (profile.proxy_id ?? null);
|
||||
const effectiveProxy = effectiveProxyId
|
||||
? (storedProxies.find((p) => p.id === effectiveProxyId) ?? null)
|
||||
: null;
|
||||
const displayName =
|
||||
profile.browser === "tor-browser"
|
||||
? "Not supported"
|
||||
: effectiveProxy
|
||||
? effectiveProxy.name
|
||||
: "Not Selected";
|
||||
const profileHasProxy = Boolean(effectiveProxy);
|
||||
const tooltipText =
|
||||
profile.browser === "tor-browser"
|
||||
? "Proxies are not supported for TOR browser"
|
||||
: profileHasProxy && proxyInfo
|
||||
? `${proxyDisplayName}, ${proxyInfo.proxy_settings.proxy_type.toUpperCase()} (${
|
||||
proxyInfo.proxy_settings.host
|
||||
}:${proxyInfo.proxy_settings.port})`
|
||||
: profileHasProxy && effectiveProxy
|
||||
? `${effectiveProxy.name} (${effectiveProxy.proxy_settings.proxy_type.toUpperCase()})`
|
||||
: "";
|
||||
const isSelectorOpen = openProxySelectorFor === profile.name;
|
||||
|
||||
if (profile.browser === "tor-browser") {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex gap-2 items-center">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Not supported
|
||||
</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{tooltipText && <TooltipContent>{tooltipText}</TooltipContent>}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex gap-2 items-center">
|
||||
{profileHasProxy && (
|
||||
<CiCircleCheck className="w-4 h-4 text-green-500" />
|
||||
)}
|
||||
{proxyDisplayName.length > 10 ? (
|
||||
<span className="text-sm truncate text-muted-foreground">
|
||||
{proxyDisplayName.slice(0, 10)}...
|
||||
<Popover
|
||||
open={isSelectorOpen}
|
||||
onOpenChange={(open) =>
|
||||
setOpenProxySelectorFor(open ? profile.name : null)
|
||||
}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
"flex gap-2 items-center px-1 rounded",
|
||||
isDisabled
|
||||
? "opacity-60 cursor-not-allowed pointer-events-none"
|
||||
: "cursor-pointer hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
{profileHasProxy && (
|
||||
<CiCircleCheck className="w-4 h-4 text-green-500" />
|
||||
)}
|
||||
{displayName.length > 18 ? (
|
||||
<span className="text-sm truncate text-muted-foreground max-w-[140px]">
|
||||
{displayName.slice(0, 18)}...
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{displayName}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{profile.browser === "tor-browser"
|
||||
? "Not supported"
|
||||
: proxyDisplayName}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{tooltipText && <TooltipContent>{tooltipText}</TooltipContent>}
|
||||
</Tooltip>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
{tooltipText && <TooltipContent>{tooltipText}</TooltipContent>}
|
||||
</Tooltip>
|
||||
|
||||
{!isDisabled && (
|
||||
<PopoverContent className="w-[240px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search proxies..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No proxies found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="__none__"
|
||||
onSelect={() =>
|
||||
void handleProxySelection(profile.name, null)
|
||||
}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
effectiveProxyId === null
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
No Proxy
|
||||
</CommandItem>
|
||||
{storedProxies.map((proxy) => (
|
||||
<CommandItem
|
||||
key={proxy.id}
|
||||
value={proxy.name}
|
||||
onSelect={() =>
|
||||
void handleProxySelection(profile.name, proxy.id)
|
||||
}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
effectiveProxyId === proxy.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{proxy.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -734,14 +852,6 @@ export function ProfilesDataTable({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onProxySettings(profile);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
Configure Proxy
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (onAssignProfilesToGroup) {
|
||||
@@ -759,7 +869,7 @@ export function ProfilesDataTable({
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
Configure Camoufox
|
||||
Configure Fingerprint
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
@@ -794,10 +904,6 @@ export function ProfilesDataTable({
|
||||
handleIconClick,
|
||||
runningProfiles,
|
||||
browserState,
|
||||
hasProxy,
|
||||
getProxyDisplayName,
|
||||
getProxyInfo,
|
||||
onProxySettings,
|
||||
onLaunchProfile,
|
||||
onKillProfile,
|
||||
onConfigureCamoufox,
|
||||
@@ -807,6 +913,10 @@ export function ProfilesDataTable({
|
||||
stoppingProfiles,
|
||||
filteredData,
|
||||
browserState.isClient,
|
||||
storedProxies,
|
||||
openProxySelectorFor,
|
||||
proxyOverrides,
|
||||
handleProxySelection,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,330 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { FiPlus } from "react-icons/fi";
|
||||
import { toast } from "sonner";
|
||||
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { StoredProxy } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface ProxySettingsDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (proxyId: string | null) => void;
|
||||
initialProxyId?: string | null;
|
||||
browserType?: string;
|
||||
}
|
||||
|
||||
export function ProxySettingsDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
initialProxyId,
|
||||
browserType,
|
||||
}: ProxySettingsDialogProps) {
|
||||
const [storedProxies, setStoredProxies] = useState<StoredProxy[]>([]);
|
||||
const [selectedProxyId, setSelectedProxyId] = useState<string | null>(
|
||||
initialProxyId || null,
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showProxyForm, setShowProxyForm] = useState(false);
|
||||
const [proxyUsage, setProxyUsage] = useState<Record<string, number>>({});
|
||||
|
||||
// Helper to determine if proxy should be disabled for the selected browser
|
||||
const isProxyDisabled = browserType === "tor-browser";
|
||||
|
||||
const loadStoredProxies = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const proxies = await invoke<StoredProxy[]>("get_stored_proxies");
|
||||
setStoredProxies(proxies);
|
||||
} catch (error) {
|
||||
console.error("Failed to load stored proxies:", error);
|
||||
toast.error("Failed to load proxies");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadProxyUsage = useCallback(async () => {
|
||||
try {
|
||||
const profiles = await invoke<Array<{ proxy_id?: string }>>(
|
||||
"list_browser_profiles",
|
||||
);
|
||||
const counts: Record<string, number> = {};
|
||||
for (const p of profiles) {
|
||||
if (p.proxy_id) {
|
||||
counts[p.proxy_id] = (counts[p.proxy_id] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
setProxyUsage(counts);
|
||||
} catch (error) {
|
||||
// Non-fatal
|
||||
console.error("Failed to load proxy usage:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadStoredProxies();
|
||||
void loadProxyUsage();
|
||||
if (isProxyDisabled) {
|
||||
setSelectedProxyId(null);
|
||||
} else {
|
||||
// Reset to initial proxy ID when dialog opens
|
||||
setSelectedProxyId(initialProxyId || null);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isOpen,
|
||||
isProxyDisabled,
|
||||
loadStoredProxies,
|
||||
initialProxyId,
|
||||
loadProxyUsage,
|
||||
]);
|
||||
|
||||
// Refresh usage when profiles change
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
const setup = async () => {
|
||||
try {
|
||||
unlisten = await listen("profile-updated", () => {
|
||||
void loadProxyUsage();
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
if (isOpen) void setup();
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, [isOpen, loadProxyUsage]);
|
||||
|
||||
const handleCreateProxy = useCallback(() => {
|
||||
setShowProxyForm(true);
|
||||
}, []);
|
||||
|
||||
const handleProxySaved = useCallback((savedProxy: StoredProxy) => {
|
||||
setStoredProxies((prev) => {
|
||||
const existingIndex = prev.findIndex((p) => p.id === savedProxy.id);
|
||||
if (existingIndex >= 0) {
|
||||
// Update existing proxy
|
||||
const updated = [...prev];
|
||||
updated[existingIndex] = savedProxy;
|
||||
return updated;
|
||||
} else {
|
||||
// Add new proxy
|
||||
return [...prev, savedProxy];
|
||||
}
|
||||
});
|
||||
setSelectedProxyId(savedProxy.id);
|
||||
setShowProxyForm(false);
|
||||
}, []);
|
||||
|
||||
const handleProxyFormClose = useCallback(() => {
|
||||
setShowProxyForm(false);
|
||||
}, []);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(selectedProxyId);
|
||||
};
|
||||
|
||||
const hasChanged = () => {
|
||||
return selectedProxyId !== initialProxyId;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-md max-h-[80vh] my-8 flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>Proxy Settings</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-6 py-4">
|
||||
{isProxyDisabled && (
|
||||
<div className="p-4 bg-yellow-50 rounded-md border border-yellow-200 dark:bg-yellow-900/20 dark:border-yellow-800">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
Tor Browser has its own built-in proxy system and doesn't
|
||||
support additional proxy configuration.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isProxyDisabled && (
|
||||
<>
|
||||
{/* Proxy Selection */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-base font-medium">
|
||||
Select Proxy
|
||||
</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCreateProxy}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<FiPlus className="w-4 h-4" />
|
||||
Create
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Create a new proxy configuration</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto p-2 space-y-2 h-full">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setSelectedProxyId(null)}
|
||||
asChild
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
"w-full bg-card cursor-pointer transition-colors",
|
||||
selectedProxyId === null
|
||||
? "ring-2 ring-blue-500"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-4 w-full">
|
||||
<div className="flex items-center space-x-3">
|
||||
<input
|
||||
type="radio"
|
||||
id="no-proxy"
|
||||
name="proxy-selection"
|
||||
checked={selectedProxyId === null}
|
||||
onChange={() => setSelectedProxyId(null)}
|
||||
/>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Label
|
||||
htmlFor="no-proxy"
|
||||
className="font-medium cursor-pointer"
|
||||
>
|
||||
No Proxy
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Button>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Loading proxies...
|
||||
</p>
|
||||
) : (
|
||||
storedProxies.map((proxy) => (
|
||||
<Button
|
||||
key={proxy.id}
|
||||
variant="ghost"
|
||||
onClick={() => setSelectedProxyId(proxy.id)}
|
||||
asChild
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
"w-full bg-card cursor-pointer transition-colors",
|
||||
selectedProxyId === proxy.id
|
||||
? "ring-2 ring-blue-500"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-4 w-full">
|
||||
<div className="flex items-center space-x-3">
|
||||
<input
|
||||
type="radio"
|
||||
id={`proxy-${proxy.id}`}
|
||||
name="proxy-selection"
|
||||
checked={selectedProxyId === proxy.id}
|
||||
onChange={() => setSelectedProxyId(proxy.id)}
|
||||
/>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Label
|
||||
htmlFor={`proxy-${proxy.id}`}
|
||||
className="font-medium cursor-pointer"
|
||||
>
|
||||
{proxy.name}
|
||||
</Label>
|
||||
<Badge variant="outline">
|
||||
{proxy.proxy_settings.proxy_type.toUpperCase()}
|
||||
</Badge>
|
||||
<Badge>{proxyUsage[proxy.id] ?? 0}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
|
||||
{!loading && storedProxies.length === 0 && (
|
||||
<div className="py-4 text-center">
|
||||
<p className="mb-2 text-sm text-muted-foreground">
|
||||
No saved proxies available.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCreateProxy}
|
||||
>
|
||||
<FiPlus className="mr-2 w-4 h-4" />
|
||||
Create First Proxy
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<RippleButton variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</RippleButton>
|
||||
<RippleButton onClick={handleSave} disabled={!hasChanged()}>
|
||||
Save
|
||||
</RippleButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ProxyFormDialog
|
||||
isOpen={showProxyForm}
|
||||
onClose={handleProxyFormClose}
|
||||
onSave={handleProxySaved}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -63,6 +63,12 @@ function DialogContent({
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[10000] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
onInteractOutside={(event) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest('[data-window-drag-area="true"]')) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -16,11 +16,11 @@ export function WindowDragArea() {
|
||||
checkPlatform();
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
// Only handle left mouse button
|
||||
const handlePointerDown = (e: React.PointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Start dragging asynchronously
|
||||
const startDrag = async () => {
|
||||
try {
|
||||
const window = getCurrentWindow();
|
||||
@@ -42,7 +42,8 @@ export function WindowDragArea() {
|
||||
<button
|
||||
type="button"
|
||||
className="fixed top-0 right-0 left-0 h-10 bg-transparent border-0 z-[9999] select-none"
|
||||
onMouseDown={handleMouseDown}
|
||||
data-window-drag-area="true"
|
||||
onPointerDown={handlePointerDown}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
Reference in New Issue
Block a user