feat: add proxy management

This commit is contained in:
zhom
2025-07-04 01:56:41 +04:00
parent d8f07ddb11
commit 7b6ea00838
14 changed files with 1689 additions and 748 deletions
+1 -1
View File
@@ -519,7 +519,7 @@ mod tests {
browser: browser.to_string(),
version: version.to_string(),
process_id: None,
proxy: None,
proxy_id: None,
last_launch: None,
release_type: "stable".to_string(),
}
+6 -13
View File
@@ -1,9 +1,8 @@
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProxySettings {
pub enabled: bool,
pub proxy_type: String, // "http", "https", "socks4", or "socks5"
pub host: String,
pub port: u16,
@@ -636,12 +635,11 @@ impl Browser for ChromiumBrowser {
// Add proxy configuration if provided
if let Some(proxy) = proxy_settings {
if proxy.enabled {
args.push(format!(
"--proxy-server=http://{}:{}",
proxy.host, proxy.port
));
}
// Apply proxy settings
args.push(format!(
"--proxy-server=http://{}:{}",
proxy.host, proxy.port
));
}
if let Some(url) = url {
@@ -887,7 +885,6 @@ mod tests {
#[test]
fn test_proxy_settings_creation() {
let proxy = ProxySettings {
enabled: true,
proxy_type: "http".to_string(),
host: "127.0.0.1".to_string(),
port: 8080,
@@ -895,14 +892,12 @@ mod tests {
password: None,
};
assert!(proxy.enabled);
assert_eq!(proxy.proxy_type, "http");
assert_eq!(proxy.host, "127.0.0.1");
assert_eq!(proxy.port, 8080);
// Test different proxy types
let socks_proxy = ProxySettings {
enabled: true,
proxy_type: "socks5".to_string(),
host: "proxy.example.com".to_string(),
port: 1080,
@@ -980,7 +975,6 @@ mod tests {
#[test]
fn test_proxy_settings_serialization() {
let proxy = ProxySettings {
enabled: true,
proxy_type: "http".to_string(),
host: "127.0.0.1".to_string(),
port: 8080,
@@ -996,7 +990,6 @@ mod tests {
// Test that it can be deserialized (implements Deserialize)
let deserialized: ProxySettings = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.enabled, proxy.enabled);
assert_eq!(deserialized.proxy_type, proxy.proxy_type);
assert_eq!(deserialized.host, proxy.host);
assert_eq!(deserialized.port, proxy.port);
+223 -155
View File
@@ -24,7 +24,7 @@ pub struct BrowserProfile {
pub browser: String,
pub version: String,
#[serde(default)]
pub proxy: Option<ProxySettings>,
pub proxy_id: Option<String>, // Reference to stored proxy
#[serde(default)]
pub process_id: Option<u32>,
#[serde(default)]
@@ -1150,6 +1150,79 @@ impl BrowserRunner {
// Now update the profile with UUID (no need to store profile_path anymore)
old_profile["id"] = serde_json::Value::String(profile_id.to_string());
// Handle proxy migration - extract proxy to separate storage if it exists
if let Some(proxy_value) = old_profile.get("proxy").cloned() {
if !proxy_value.is_null() {
// Try to deserialize the proxy settings
if let Ok(proxy_settings) = serde_json::from_value::<ProxySettings>(proxy_value) {
// Create a stored proxy with the profile name (all proxies are now enabled by default)
let proxy_name = format!("{profile_name} Proxy");
match PROXY_MANAGER.create_stored_proxy(proxy_name.clone(), proxy_settings.clone()) {
Ok(stored_proxy) => {
// Update profile to reference the stored proxy
old_profile["proxy_id"] = serde_json::Value::String(stored_proxy.id);
println!(
"Migrated proxy for profile '{}' to stored proxy '{}'",
profile_name, stored_proxy.name
);
}
Err(e) => {
println!("Warning: Failed to migrate proxy for profile '{profile_name}': {e}");
// If creation fails (e.g., name collision), try to find existing proxy with same settings
let existing_proxies = PROXY_MANAGER.get_stored_proxies();
if let Some(existing_proxy) = existing_proxies.iter().find(|p| {
p.proxy_settings.proxy_type == proxy_settings.proxy_type
&& p.proxy_settings.host == proxy_settings.host
&& p.proxy_settings.port == proxy_settings.port
&& p.proxy_settings.username == proxy_settings.username
&& p.proxy_settings.password == proxy_settings.password
}) {
old_profile["proxy_id"] = serde_json::Value::String(existing_proxy.id.clone());
println!(
"Reused existing proxy '{}' for profile '{}'",
existing_proxy.name, profile_name
);
} else {
// Try with a different name if the original failed due to name collision
let alt_proxy_name = format!(
"{profile_name} Proxy {}",
&uuid::Uuid::new_v4().to_string()[..8]
);
match PROXY_MANAGER
.create_stored_proxy(alt_proxy_name.clone(), proxy_settings.clone())
{
Ok(stored_proxy) => {
old_profile["proxy_id"] = serde_json::Value::String(stored_proxy.id);
println!(
"Migrated proxy for profile '{}' to stored proxy '{}' with fallback name",
profile_name, stored_proxy.name
);
}
Err(e2) => {
println!("Error: Could not migrate proxy for profile '{profile_name}' even with fallback name: {e2}");
}
}
}
}
}
} else {
println!(
"Warning: Could not deserialize proxy settings for profile '{profile_name}'"
);
}
}
}
// Always remove the old proxy field after migration attempt, whether successful or not
if old_profile
.as_object_mut()
.unwrap()
.remove("proxy")
.is_some()
{
println!("Removed legacy proxy field from profile '{profile_name}'");
}
// Move old profile directory contents to new UUID/profile directory if it exists
if old_profile_dir.exists() && old_profile_dir.is_dir() {
// Copy all contents from old directory to new profile subdirectory
@@ -1272,7 +1345,7 @@ impl BrowserRunner {
browser: &str,
version: &str,
release_type: &str,
proxy: Option<ProxySettings>,
proxy_id: Option<String>,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
println!("Attempting to create profile: {name}");
@@ -1301,7 +1374,7 @@ impl BrowserRunner {
name: name.to_string(),
browser: browser.to_string(),
version: version.to_string(),
proxy: proxy.clone(),
proxy_id: proxy_id.clone(),
process_id: None,
last_launch: None,
release_type: release_type.to_string(),
@@ -1318,8 +1391,13 @@ impl BrowserRunner {
println!("Profile '{name}' created successfully with ID: {profile_id}");
// Create user.js with common Firefox preferences and apply proxy settings if provided
if let Some(proxy_settings) = &proxy {
self.apply_proxy_settings_to_profile(&profile_data_dir, proxy_settings, None)?;
if let Some(proxy_id_ref) = &proxy_id {
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_ref) {
self.apply_proxy_settings_to_profile(&profile_data_dir, &proxy_settings, None)?;
} else {
// Proxy ID provided but not found, disable proxy
self.disable_proxy_settings_in_profile(&profile_data_dir)?;
}
} else {
// Create user.js with common Firefox preferences but no proxy
self.disable_proxy_settings_in_profile(&profile_data_dir)?;
@@ -1332,7 +1410,7 @@ impl BrowserRunner {
&self,
app_handle: tauri::AppHandle,
profile_name: &str,
proxy: Option<ProxySettings>,
proxy_id: Option<String>,
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
// Find the profile by name
let profiles =
@@ -1356,14 +1434,14 @@ impl BrowserRunner {
.await?;
// If browser is running, stop existing proxy
if browser_is_running && profile.proxy.is_some() {
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 = proxy.clone();
profile.proxy_id = proxy_id.clone();
// Save the updated profile
self
@@ -1373,71 +1451,78 @@ impl BrowserRunner {
})?;
// Handle proxy startup/configuration
if let Some(proxy_settings) = &proxy {
if proxy_settings.enabled && 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(), proxy_settings, pid, Some(profile_name))
.await
{
Ok(internal_proxy_settings) => {
let browser_runner = BrowserRunner::new();
let profiles_dir = browser_runner.get_profiles_dir();
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
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(), &proxy_settings, pid, Some(profile_name))
.await
{
Ok(internal_proxy_settings) => {
let browser_runner = BrowserRunner::new();
let profiles_dir = browser_runner.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
browser_runner
.apply_proxy_settings_to_profile(
&profile_path,
proxy_settings,
Some(&internal_proxy_settings),
)
.map_err(|e| format!("Failed to update profile proxy: {e}"))?;
// Apply the proxy settings with the internal proxy to the profile directory
browser_runner
.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);
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;
Some(internal_proxy_settings)
}
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()
})?;
None
// 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}");
// 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 {
// No PID available, apply proxy settings without internal proxy
// 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)
.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()
})?;
None
}
} else {
// Proxy disabled or browser not running, just apply settings
// Proxy ID provided but proxy not found, disable 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)
.disable_proxy_settings_in_profile(&profile_path)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to apply proxy settings: {e}").into()
format!("Failed to disable proxy settings: {e}").into()
})?;
None
}
} else {
// No proxy settings, disable proxy
// No proxy ID provided, disable proxy
let profiles_dir = self.get_profiles_dir();
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
self
@@ -1445,8 +1530,7 @@ impl BrowserRunner {
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to disable proxy settings: {e}").into()
})?;
None
};
}
Ok(profile)
}
@@ -1563,63 +1647,50 @@ impl BrowserRunner {
// Add common Firefox preferences (like disabling default browser check)
preferences.extend(self.get_common_firefox_preferences());
if proxy.enabled {
// Use embedded PAC template instead of reading from file
const PAC_TEMPLATE: &str = r#"function FindProxyForURL(url, host) {
// Use embedded PAC template instead of reading from file
const PAC_TEMPLATE: &str = r#"function FindProxyForURL(url, host) {
return "{{proxy_url}}";
}"#;
// Format proxy URL based on type and whether we have an internal proxy
let proxy_url = if let Some(internal) = internal_proxy {
// Use internal proxy as the primary proxy
format!("HTTP {}:{}", internal.host, internal.port)
} else {
// Use user-configured proxy directly
match proxy.proxy_type.as_str() {
"http" => format!("HTTP {}:{}", proxy.host, proxy.port),
"https" => format!("HTTPS {}:{}", proxy.host, proxy.port),
"socks4" => format!("SOCKS4 {}:{}", proxy.host, proxy.port),
"socks5" => format!("SOCKS5 {}:{}", proxy.host, proxy.port),
_ => return Err(format!("Unsupported proxy type: {}", proxy.proxy_type).into()),
}
};
// Replace placeholders in PAC file
let pac_content = PAC_TEMPLATE
.replace("{{proxy_url}}", &proxy_url)
.replace("{{proxy_credentials}}", ""); // Credentials are now handled by the PAC file
// Save PAC file in UUID directory
let pac_path = uuid_dir.join("proxy.pac");
fs::write(&pac_path, pac_content)?;
// Configure Firefox to use the PAC file
preferences.extend([
"user_pref(\"network.proxy.type\", 2);".to_string(),
format!(
"user_pref(\"network.proxy.autoconfig_url\", \"file://{}\");",
pac_path.to_string_lossy()
),
"user_pref(\"network.proxy.failover_direct\", false);".to_string(),
"user_pref(\"network.proxy.socks_remote_dns\", true);".to_string(),
"user_pref(\"network.proxy.no_proxies_on\", \"\");".to_string(),
"user_pref(\"signon.autologin.proxy\", true);".to_string(),
"user_pref(\"network.proxy.share_proxy_settings\", false);".to_string(),
"user_pref(\"network.automatic-ntlm-auth.allow-proxies\", false);".to_string(),
"user_pref(\"network.auth-use-sspi\", false);".to_string(),
]);
// Format proxy URL based on type and whether we have an internal proxy
let proxy_url = if let Some(internal) = internal_proxy {
// Use internal proxy as the primary proxy
format!("HTTP {}:{}", internal.host, internal.port)
} else {
preferences.push("user_pref(\"network.proxy.type\", 0);".to_string());
preferences.push("user_pref(\"network.proxy.failover_direct\", true);".to_string());
// Use user-configured proxy directly
match proxy.proxy_type.as_str() {
"http" => format!("HTTP {}:{}", proxy.host, proxy.port),
"https" => format!("HTTPS {}:{}", proxy.host, proxy.port),
"socks4" => format!("SOCKS4 {}:{}", proxy.host, proxy.port),
"socks5" => format!("SOCKS5 {}:{}", proxy.host, proxy.port),
_ => return Err(format!("Unsupported proxy type: {}", proxy.proxy_type).into()),
}
};
let pac_content = "function FindProxyForURL(url, host) { return 'DIRECT'; }";
let pac_path = uuid_dir.join("proxy.pac");
fs::write(&pac_path, pac_content)?;
preferences.push(format!(
// Replace placeholders in PAC file
let pac_content = PAC_TEMPLATE
.replace("{{proxy_url}}", &proxy_url)
.replace("{{proxy_credentials}}", ""); // Credentials are now handled by the PAC file
// Save PAC file in UUID directory
let pac_path = uuid_dir.join("proxy.pac");
fs::write(&pac_path, pac_content)?;
// Configure Firefox to use the PAC file
preferences.extend([
"user_pref(\"network.proxy.type\", 2);".to_string(),
format!(
"user_pref(\"network.proxy.autoconfig_url\", \"file://{}\");",
pac_path.to_string_lossy()
));
}
),
"user_pref(\"network.proxy.failover_direct\", false);".to_string(),
"user_pref(\"network.proxy.socks_remote_dns\", true);".to_string(),
"user_pref(\"network.proxy.no_proxies_on\", \"\");".to_string(),
"user_pref(\"signon.autologin.proxy\", true);".to_string(),
"user_pref(\"network.proxy.share_proxy_settings\", false);".to_string(),
"user_pref(\"network.automatic-ntlm-auth.allow-proxies\", false);".to_string(),
"user_pref(\"network.auth-use-sspi\", false);".to_string(),
]);
// Write settings to user.js file
fs::write(user_js_path, preferences.join("\n"))?;
@@ -1726,10 +1797,16 @@ impl BrowserRunner {
}
// For Chromium browsers, use local proxy settings if available
// For Firefox browsers, continue using original proxy settings (handled via PAC files)
// For Firefox browsers, proxy settings are handled via PAC files
let stored_proxy_settings = profile
.proxy_id
.as_ref()
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id));
let proxy_for_launch_args = match browser_type {
BrowserType::Chromium | BrowserType::Brave => local_proxy_settings.or(profile.proxy.as_ref()),
_ => profile.proxy.as_ref(),
BrowserType::Chromium | BrowserType::Brave => {
local_proxy_settings.or(stored_proxy_settings.as_ref())
}
_ => None, // Firefox browsers use PAC files, not launch args
};
// Get profile data path and launch arguments
@@ -2362,8 +2439,8 @@ impl BrowserRunner {
}
// Handle proxy management based on browser status
if let Some(proxy) = &inner_profile.proxy {
if proxy.enabled {
if let Some(proxy_id) = &inner_profile.proxy_id {
if let Some(proxy) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id) {
if is_running {
// Browser is running, check if proxy is active
let proxy_active = PROXY_MANAGER
@@ -2372,27 +2449,23 @@ impl BrowserRunner {
if !proxy_active {
// Browser is running but proxy is not - restart the proxy
if let Some(proxy_settings) = PROXY_MANAGER.get_profile_proxy_info(&inner_profile.name)
match PROXY_MANAGER
.start_proxy(
app_handle,
&proxy,
inner_profile.process_id.unwrap(),
Some(&inner_profile.name),
)
.await
{
// Restart the proxy with the same configuration
match PROXY_MANAGER
.start_proxy(
app_handle,
&proxy_settings,
inner_profile.process_id.unwrap(),
Some(&inner_profile.name),
)
.await
{
Ok(_) => {
println!("Restarted proxy for profile {}", inner_profile.name);
}
Err(e) => {
eprintln!(
"Failed to restart proxy for profile {}: {}",
inner_profile.name, e
);
}
Ok(_) => {
println!("Restarted proxy for profile {}", inner_profile.name);
}
Err(e) => {
eprintln!(
"Failed to restart proxy for profile {}: {}",
inner_profile.name, e
);
}
}
}
@@ -2840,11 +2913,11 @@ pub fn create_browser_profile(
browser: String,
version: String,
release_type: String,
proxy: Option<ProxySettings>,
proxy_id: Option<String>,
) -> Result<BrowserProfile, String> {
let browser_runner = BrowserRunner::new();
browser_runner
.create_profile(&name, &browser, &version, &release_type, proxy)
.create_profile(&name, &browser, &version, &release_type, proxy_id)
.map_err(|e| format!("Failed to create profile: {e}"))
}
@@ -2870,14 +2943,14 @@ pub async fn launch_browser_profile(
// 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) = &profile.proxy {
if proxy.enabled {
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;
// Start the proxy first
match PROXY_MANAGER
.start_proxy(app_handle.clone(), proxy, temp_pid, Some(&profile.name))
.start_proxy(app_handle.clone(), &proxy, temp_pid, Some(&profile.name))
.await
{
Ok(internal_proxy) => {
@@ -2890,7 +2963,7 @@ pub async fn launch_browser_profile(
// 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))
.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);
@@ -2907,7 +2980,7 @@ pub async fn launch_browser_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, &proxy, None)
.map_err(|e| format!("Failed to update profile proxy: {e}"))?;
}
}
@@ -2930,8 +3003,8 @@ pub async fn launch_browser_profile(
})?;
// Now update the proxy with the correct PID if we have one
if let Some(proxy) = &profile.proxy {
if proxy.enabled {
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) {
@@ -2953,11 +3026,11 @@ pub async fn launch_browser_profile(
pub async fn update_profile_proxy(
app_handle: tauri::AppHandle,
profile_name: String,
proxy: Option<ProxySettings>,
proxy_id: Option<String>,
) -> Result<BrowserProfile, String> {
let browser_runner = BrowserRunner::new();
browser_runner
.update_profile_proxy(app_handle, &profile_name, proxy)
.update_profile_proxy(app_handle, &profile_name, proxy_id)
.await
.map_err(|e| format!("Failed to update profile: {e}"))
}
@@ -3132,7 +3205,7 @@ pub fn create_browser_profile_new(
browser_str: String,
version: String,
release_type: String,
proxy: Option<ProxySettings>,
proxy_id: Option<String>,
) -> Result<BrowserProfile, String> {
let browser_type =
BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {e}"))?;
@@ -3141,7 +3214,7 @@ pub fn create_browser_profile_new(
browser_type.as_str().to_string(),
version,
release_type,
proxy,
proxy_id,
)
}
@@ -3245,7 +3318,7 @@ mod tests {
assert_eq!(profile.name, "Test Profile");
assert_eq!(profile.browser, "firefox");
assert_eq!(profile.version, "139.0");
assert!(profile.proxy.is_none());
assert!(profile.proxy_id.is_none());
assert!(profile.process_id.is_none());
}
@@ -3253,8 +3326,7 @@ mod tests {
fn test_create_profile_with_proxy() {
let (runner, _temp_dir) = create_test_browser_runner();
let proxy = ProxySettings {
enabled: true,
let _proxy = ProxySettings {
proxy_type: "http".to_string(),
host: "127.0.0.1".to_string(),
port: 8080,
@@ -3268,16 +3340,13 @@ mod tests {
"firefox",
"139.0",
"stable",
Some(proxy.clone()),
None, // Tests now use separate proxy storage system
)
.unwrap();
assert_eq!(profile.name, "Test Profile with Proxy");
assert!(profile.proxy.is_some());
let profile_proxy = profile.proxy.unwrap();
assert_eq!(profile_proxy.proxy_type, "http");
assert_eq!(profile_proxy.host, "127.0.0.1");
assert_eq!(profile_proxy.port, 8080);
// Note: Proxy settings are now stored separately in the proxy storage system
assert!(profile.proxy_id.is_none()); // No proxy assigned in this test
}
#[test]
@@ -3440,9 +3509,8 @@ mod tests {
assert!(user_js_content.contains("app.update.enabled"));
assert!(user_js_content.contains("app.update.auto"));
// Create profile with proxy
let proxy = ProxySettings {
enabled: true,
// Create profile with proxy (proxy object unused in new architecture)
let _proxy = ProxySettings {
proxy_type: "http".to_string(),
host: "127.0.0.1".to_string(),
port: 8080,
@@ -3456,7 +3524,7 @@ mod tests {
"firefox",
"139.0",
"stable",
Some(proxy),
None, // Tests now use separate proxy storage system
)
.unwrap();
+37
View File
@@ -174,6 +174,39 @@ async fn check_and_handle_startup_url(app_handle: tauri::AppHandle) -> Result<bo
Ok(false)
}
#[tauri::command]
async fn create_stored_proxy(
name: String,
proxy_settings: crate::browser::ProxySettings,
) -> Result<crate::proxy_manager::StoredProxy, String> {
crate::proxy_manager::PROXY_MANAGER
.create_stored_proxy(name, proxy_settings)
.map_err(|e| format!("Failed to create stored proxy: {e}"))
}
#[tauri::command]
async fn get_stored_proxies() -> Result<Vec<crate::proxy_manager::StoredProxy>, String> {
Ok(crate::proxy_manager::PROXY_MANAGER.get_stored_proxies())
}
#[tauri::command]
async fn update_stored_proxy(
proxy_id: String,
name: Option<String>,
proxy_settings: Option<crate::browser::ProxySettings>,
) -> Result<crate::proxy_manager::StoredProxy, String> {
crate::proxy_manager::PROXY_MANAGER
.update_stored_proxy(&proxy_id, name, proxy_settings)
.map_err(|e| format!("Failed to update stored proxy: {e}"))
}
#[tauri::command]
async fn delete_stored_proxy(proxy_id: String) -> Result<(), String> {
crate::proxy_manager::PROXY_MANAGER
.delete_stored_proxy(&proxy_id)
.map_err(|e| format!("Failed to delete stored proxy: {e}"))
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let args: Vec<String> = env::args().collect();
@@ -396,6 +429,10 @@ pub fn run() {
import_browser_profile,
check_missing_binaries,
ensure_all_binaries_exist,
create_stored_proxy,
get_stored_proxies,
update_stored_proxy,
delete_stored_proxy,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+1 -1
View File
@@ -685,7 +685,7 @@ impl ProfileImporter {
name: new_profile_name.to_string(),
browser: browser_type.to_string(),
version: available_versions,
proxy: None,
proxy_id: None,
process_id: None,
last_launch: None,
release_type: "stable".to_string(),
+230 -18
View File
@@ -1,6 +1,9 @@
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::Mutex;
use tauri_plugin_shell::ShellExt;
@@ -17,19 +20,237 @@ pub struct ProxyInfo {
pub local_port: u16,
}
// Global proxy manager to track active proxies
// Stored proxy configuration with name and ID for reuse
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredProxy {
pub id: String,
pub name: String,
pub proxy_settings: ProxySettings,
}
impl StoredProxy {
pub fn new(name: String, proxy_settings: ProxySettings) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
name,
proxy_settings,
}
}
pub fn update_settings(&mut self, proxy_settings: ProxySettings) {
self.proxy_settings = proxy_settings;
}
pub fn update_name(&mut self, name: String) {
self.name = name;
}
}
// Global proxy manager to track active proxies and stored proxy configurations
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
base_dirs: BaseDirs,
}
impl ProxyManager {
pub fn new() -> Self {
Self {
let base_dirs = BaseDirs::new().expect("Failed to get base directories");
let manager = Self {
active_proxies: Mutex::new(HashMap::new()),
profile_proxies: Mutex::new(HashMap::new()),
stored_proxies: Mutex::new(HashMap::new()),
base_dirs,
};
// Load stored proxies on initialization
if let Err(e) = manager.load_stored_proxies() {
eprintln!("Warning: Failed to load stored proxies: {e}");
}
manager
}
// Get the path to the proxies directory
fn get_proxies_dir(&self) -> PathBuf {
let mut path = self.base_dirs.data_local_dir().to_path_buf();
path.push(if cfg!(debug_assertions) {
"DonutBrowserDev"
} else {
"DonutBrowser"
});
path.push("proxies");
path
}
// Get the path to a specific proxy file
fn get_proxy_file_path(&self, proxy_id: &str) -> PathBuf {
self.get_proxies_dir().join(format!("{proxy_id}.json"))
}
// Load stored proxies from disk
fn load_stored_proxies(&self) -> Result<(), Box<dyn std::error::Error>> {
let proxies_dir = self.get_proxies_dir();
if !proxies_dir.exists() {
return Ok(()); // No proxies directory yet
}
let mut stored_proxies = self.stored_proxies.lock().unwrap();
// Read all JSON files from the proxies directory
for entry in fs::read_dir(&proxies_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "json") {
let content = fs::read_to_string(&path)?;
let proxy: StoredProxy = serde_json::from_str(&content)?;
stored_proxies.insert(proxy.id.clone(), proxy);
}
}
Ok(())
}
// Save a single proxy to disk
fn save_proxy(&self, proxy: &StoredProxy) -> Result<(), Box<dyn std::error::Error>> {
let proxies_dir = self.get_proxies_dir();
// Ensure directory exists
fs::create_dir_all(&proxies_dir)?;
let proxy_file = self.get_proxy_file_path(&proxy.id);
let content = serde_json::to_string_pretty(proxy)?;
fs::write(&proxy_file, content)?;
Ok(())
}
// Delete a proxy file from disk
fn delete_proxy_file(&self, proxy_id: &str) -> Result<(), Box<dyn std::error::Error>> {
let proxy_file = self.get_proxy_file_path(proxy_id);
if proxy_file.exists() {
fs::remove_file(proxy_file)?;
}
Ok(())
}
// Create a new stored proxy
pub fn create_stored_proxy(
&self,
name: String,
proxy_settings: ProxySettings,
) -> Result<StoredProxy, String> {
// Check if name already exists
{
let stored_proxies = self.stored_proxies.lock().unwrap();
if stored_proxies.values().any(|p| p.name == name) {
return Err(format!("Proxy with name '{name}' already exists"));
}
}
let stored_proxy = StoredProxy::new(name, proxy_settings);
{
let mut stored_proxies = self.stored_proxies.lock().unwrap();
stored_proxies.insert(stored_proxy.id.clone(), stored_proxy.clone());
}
if let Err(e) = self.save_proxy(&stored_proxy) {
eprintln!("Warning: Failed to save proxy: {e}");
}
Ok(stored_proxy)
}
// Get all stored proxies
pub fn get_stored_proxies(&self) -> Vec<StoredProxy> {
let stored_proxies = self.stored_proxies.lock().unwrap();
stored_proxies.values().cloned().collect()
}
// Get a stored proxy by ID
#[allow(dead_code)]
pub fn get_stored_proxy(&self, proxy_id: &str) -> Option<StoredProxy> {
let stored_proxies = self.stored_proxies.lock().unwrap();
stored_proxies.get(proxy_id).cloned()
}
// Update a stored proxy
pub fn update_stored_proxy(
&self,
proxy_id: &str,
name: Option<String>,
proxy_settings: Option<ProxySettings>,
) -> Result<StoredProxy, String> {
// First, check for conflicts without holding a mutable reference
{
let stored_proxies = self.stored_proxies.lock().unwrap();
// Check if proxy exists
if !stored_proxies.contains_key(proxy_id) {
return Err(format!("Proxy with ID '{proxy_id}' not found"));
}
// Check if new name conflicts with existing proxies
if let Some(ref new_name) = name {
if stored_proxies
.values()
.any(|p| p.id != proxy_id && p.name == *new_name)
{
return Err(format!("Proxy with name '{new_name}' already exists"));
}
}
} // Release the lock here
// Now get mutable access for updates
let updated_proxy = {
let mut stored_proxies = self.stored_proxies.lock().unwrap();
let stored_proxy = stored_proxies.get_mut(proxy_id).unwrap(); // Safe because we checked above
if let Some(new_name) = name {
stored_proxy.update_name(new_name);
}
if let Some(new_settings) = proxy_settings {
stored_proxy.update_settings(new_settings);
}
stored_proxy.clone()
};
if let Err(e) = self.save_proxy(&updated_proxy) {
eprintln!("Warning: Failed to save proxy: {e}");
}
Ok(updated_proxy)
}
// Delete a stored proxy
pub fn delete_stored_proxy(&self, proxy_id: &str) -> Result<(), String> {
{
let mut stored_proxies = self.stored_proxies.lock().unwrap();
if stored_proxies.remove(proxy_id).is_none() {
return Err(format!("Proxy with ID '{proxy_id}' not found"));
}
}
if let Err(e) = self.delete_proxy_file(proxy_id) {
eprintln!("Warning: Failed to delete proxy file: {e}");
}
Ok(())
}
// Get proxy settings for a stored proxy ID
pub fn get_proxy_settings_by_id(&self, proxy_id: &str) -> Option<ProxySettings> {
let stored_proxies = self.stored_proxies.lock().unwrap();
stored_proxies
.get(proxy_id)
.map(|p| p.proxy_settings.clone())
}
// Start a proxy for given proxy settings and associate it with a browser process ID
@@ -45,8 +266,7 @@ impl ProxyManager {
let proxies = self.active_proxies.lock().unwrap();
if let Some(proxy) = proxies.get(&browser_pid) {
return Ok(ProxySettings {
enabled: true,
proxy_type: proxy.upstream_type.clone(),
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,
@@ -154,7 +374,6 @@ impl ProxyManager {
// Return proxy settings for the browser
Ok(ProxySettings {
enabled: true,
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_info.local_port,
@@ -202,7 +421,6 @@ impl ProxyManager {
pub fn get_proxy_settings(&self, browser_pid: u32) -> Option<ProxySettings> {
let proxies = self.active_proxies.lock().unwrap();
proxies.get(&browser_pid).map(|proxy| ProxySettings {
enabled: true,
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,
@@ -212,6 +430,7 @@ impl ProxyManager {
}
// Get stored proxy info for a profile
#[allow(dead_code)]
pub fn get_profile_proxy_info(&self, profile_name: &str) -> Option<ProxySettings> {
let profile_proxies = self.profile_proxies.lock().unwrap();
profile_proxies.get(profile_name).cloned()
@@ -321,7 +540,6 @@ mod tests {
let proxy_manager = ProxyManager::new();
let proxy_settings = ProxySettings {
enabled: true,
proxy_type: "socks5".to_string(),
host: "127.0.0.1".to_string(),
port: 1080,
@@ -373,9 +591,8 @@ mod tests {
let proxy_settings = proxy_manager.get_proxy_settings(browser_pid);
assert!(proxy_settings.is_some());
let settings = proxy_settings.unwrap();
assert!(settings.enabled);
assert_eq!(settings.host, "127.0.0.1");
assert_eq!(settings.port, 8080);
assert!(settings.host == "127.0.0.1");
assert!(settings.port == 8080);
// Test non-existent browser PID
let non_existent = proxy_manager.get_proxy_settings(99999);
@@ -386,7 +603,6 @@ mod tests {
fn test_proxy_settings_validation() {
// Test valid proxy settings
let valid_settings = ProxySettings {
enabled: true,
proxy_type: "http".to_string(),
host: "127.0.0.1".to_string(),
port: 8080,
@@ -394,14 +610,11 @@ mod tests {
password: Some("pass".to_string()),
};
assert!(valid_settings.enabled);
assert_eq!(valid_settings.proxy_type, "http");
assert!(!valid_settings.host.is_empty());
assert!(valid_settings.port > 0);
// Test disabled proxy settings
let disabled_settings = ProxySettings {
enabled: false,
// Test proxy settings with empty values
let empty_settings = ProxySettings {
proxy_type: "http".to_string(),
host: "".to_string(),
port: 0,
@@ -409,7 +622,7 @@ mod tests {
password: None,
};
assert!(!disabled_settings.enabled);
assert!(empty_settings.host.is_empty());
}
#[tokio::test]
@@ -563,7 +776,6 @@ mod tests {
#[test]
fn test_proxy_command_construction() {
let proxy_settings = ProxySettings {
enabled: true,
proxy_type: "http".to_string(),
host: "proxy.example.com".to_string(),
port: 8080,
+42 -17
View File
@@ -5,6 +5,7 @@ import { listen } from "@tauri-apps/api/event";
import { getCurrent } from "@tauri-apps/plugin-deep-link";
import { useCallback, useEffect, useRef, useState } from "react";
import { FaDownload } from "react-icons/fa";
import { FiWifi } from "react-icons/fi";
import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go";
import { ChangeVersionDialog } from "@/components/change-version-dialog";
import { CreateProfileDialog } from "@/components/create-profile-dialog";
@@ -12,6 +13,7 @@ import { ImportProfileDialog } from "@/components/import-profile-dialog";
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 { Button } from "@/components/ui/button";
@@ -34,7 +36,7 @@ import { useUpdateNotifications } from "@/hooks/use-update-notifications";
import { useVersionUpdater } from "@/hooks/use-version-updater";
import { showErrorToast } from "@/lib/toast-utils";
import { sleep } from "@/lib/utils";
import type { BrowserProfile, ProxySettings } from "@/types";
import type { BrowserProfile } from "@/types";
type BrowserTypeString =
| "mullvad-browser"
@@ -58,6 +60,8 @@ export default function Home() {
const [changeVersionDialogOpen, setChangeVersionDialogOpen] = useState(false);
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
const [importProfileDialogOpen, setImportProfileDialogOpen] = useState(false);
const [proxyManagementDialogOpen, setProxyManagementDialogOpen] =
useState(false);
const [pendingUrls, setPendingUrls] = useState<PendingUrl[]>([]);
const [currentProfileForProxy, setCurrentProfileForProxy] =
useState<BrowserProfile | null>(null);
@@ -67,6 +71,7 @@ export default function Home() {
const [permissionDialogOpen, setPermissionDialogOpen] = useState(false);
const [currentPermissionType, setCurrentPermissionType] =
useState<PermissionType>("microphone");
const [proxyDataReloadTrigger, setProxyDataReloadTrigger] = useState(0);
const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } =
usePermissions();
@@ -140,6 +145,11 @@ export default function Home() {
}
}, [checkMissingBinaries]);
// Trigger proxy data reload in ProfilesDataTable
const triggerProxyDataReload = useCallback(() => {
setProxyDataReloadTrigger((prev) => prev + 1);
}, []);
const handleUrlOpen = useCallback(async (url: string) => {
try {
// Use smart profile selection
@@ -317,7 +327,7 @@ export default function Home() {
}, []);
const handleSaveProxy = useCallback(
async (proxySettings: ProxySettings) => {
async (proxyId: string | null) => {
setProxyDialogOpen(false);
setError(null);
@@ -325,16 +335,18 @@ export default function Home() {
if (currentProfileForProxy) {
await invoke("update_profile_proxy", {
profileName: currentProfileForProxy.name,
proxy: proxySettings,
proxyId: proxyId,
});
}
await loadProfiles();
// Trigger proxy data reload in the table
triggerProxyDataReload();
} catch (err: unknown) {
console.error("Failed to update proxy settings:", err);
setError(`Failed to update proxy settings: ${JSON.stringify(err)}`);
}
},
[currentProfileForProxy, loadProfiles],
[currentProfileForProxy, loadProfiles, triggerProxyDataReload],
);
const handleCreateProfile = useCallback(
@@ -343,30 +355,25 @@ export default function Home() {
browserStr: BrowserTypeString;
version: string;
releaseType: string;
proxy?: ProxySettings;
proxyId?: string;
}) => {
setError(null);
try {
const profile = await invoke<BrowserProfile>(
const _profile = await invoke<BrowserProfile>(
"create_browser_profile_new",
{
name: profileData.name,
browserStr: profileData.browserStr,
version: profileData.version,
releaseType: profileData.releaseType,
proxyId: profileData.proxyId,
},
);
// Update proxy if provided
if (profileData.proxy) {
await invoke("update_profile_proxy", {
profileName: profile.name,
proxy: profileData.proxy,
});
}
await loadProfiles();
// Trigger proxy data reload in the table
triggerProxyDataReload();
} catch (error) {
setError(
`Failed to create profile: ${
@@ -376,7 +383,7 @@ export default function Home() {
throw error;
}
},
[loadProfiles],
[loadProfiles, triggerProxyDataReload],
);
const [runningProfiles, setRunningProfiles] = useState<Set<string>>(
@@ -607,6 +614,14 @@ export default function Home() {
<GoGear className="mr-2 w-4 h-4" />
Settings
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setProxyManagementDialogOpen(true);
}}
>
<FiWifi className="mr-2 w-4 h-4" />
Proxies
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setImportProfileDialogOpen(true);
@@ -645,6 +660,9 @@ export default function Home() {
onChangeVersion={openChangeVersionDialog}
runningProfiles={runningProfiles}
isUpdating={isUpdating}
onReloadProxyData={
proxyDataReloadTrigger > 0 ? triggerProxyDataReload : undefined
}
/>
</CardContent>
</Card>
@@ -655,8 +673,8 @@ export default function Home() {
onClose={() => {
setProxyDialogOpen(false);
}}
onSave={(proxy: ProxySettings) => void handleSaveProxy(proxy)}
initialSettings={currentProfileForProxy?.proxy}
onSave={handleSaveProxy}
initialProxyId={currentProfileForProxy?.proxy_id}
browserType={currentProfileForProxy?.browser}
/>
@@ -692,6 +710,13 @@ export default function Home() {
onImportComplete={() => void loadProfiles()}
/>
<ProxyManagementDialog
isOpen={proxyManagementDialogOpen}
onClose={() => {
setProxyManagementDialogOpen(false);
}}
/>
{pendingUrls.map((pendingUrl) => (
<ProfileSelectorDialog
key={pendingUrl.id}
+274 -283
View File
@@ -2,11 +2,12 @@
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { FiPlus } from "react-icons/fi";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
import { ReleaseTypeSelector } from "@/components/release-type-selector";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
@@ -31,11 +32,7 @@ import {
import { useBrowserDownload } from "@/hooks/use-browser-download";
import { useBrowserSupport } from "@/hooks/use-browser-support";
import { getBrowserDisplayName } from "@/lib/browser-utils";
import type {
BrowserProfile,
BrowserReleaseTypes,
ProxySettings,
} from "@/types";
import type { BrowserProfile, BrowserReleaseTypes, StoredProxy } from "@/types";
import { Alert, AlertDescription } from "./ui/alert";
type BrowserTypeString =
@@ -55,7 +52,7 @@ interface CreateProfileDialogProps {
browserStr: BrowserTypeString;
version: string;
releaseType: string;
proxy?: ProxySettings;
proxyId?: string;
}) => Promise<void>;
}
@@ -80,13 +77,11 @@ export function CreateProfileDialog({
);
const [isLoadingReleaseTypes, setIsLoadingReleaseTypes] = useState(false);
// Proxy settings
const [proxyEnabled, setProxyEnabled] = useState(false);
const [proxyType, setProxyType] = useState("http");
const [proxyHost, setProxyHost] = useState("");
const [proxyPort, setProxyPort] = useState(8080);
const [proxyUsername, setProxyUsername] = useState("");
const [proxyPassword, setProxyPassword] = useState("");
// Proxy settings - now using stored proxy selection
const [selectedProxyId, setSelectedProxyId] = useState<string | null>(null);
const [storedProxies, setStoredProxies] = useState<StoredProxy[]>([]);
const [isLoadingProxies, setIsLoadingProxies] = useState(false);
const [showProxyForm, setShowProxyForm] = useState(false);
const {
downloadBrowser,
@@ -136,6 +131,19 @@ export function CreateProfileDialog({
}
}, []);
const loadStoredProxies = useCallback(async () => {
try {
setIsLoadingProxies(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 available proxies");
} finally {
setIsLoadingProxies(false);
}
}, []);
const loadReleaseTypes = useCallback(async (browser: string) => {
try {
setIsLoadingReleaseTypes(true);
@@ -191,12 +199,37 @@ export function CreateProfileDialog({
// Helper to determine if proxy should be disabled for the selected browser
const isProxyDisabled = selectedBrowser === "tor-browser";
// Update proxy enabled state when browser changes to tor-browser
// Update proxy selection when browser changes to tor-browser
useEffect(() => {
if (selectedBrowser === "tor-browser" && proxyEnabled) {
setProxyEnabled(false);
if (selectedBrowser === "tor-browser" && selectedProxyId) {
setSelectedProxyId(null);
}
}, [selectedBrowser, proxyEnabled]);
}, [selectedBrowser, selectedProxyId]);
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 handleCreate = useCallback(async () => {
if (!profileName.trim() || !selectedBrowser || !selectedReleaseType) return;
@@ -219,34 +252,18 @@ export function CreateProfileDialog({
setIsCreating(true);
try {
const proxy =
proxyEnabled && !isProxyDisabled
? {
enabled: true,
proxy_type: proxyType,
host: proxyHost,
port: proxyPort,
username: proxyUsername || undefined,
password: proxyPassword || undefined,
}
: undefined;
await onCreateProfile({
name: profileName.trim(),
browserStr: selectedBrowser,
version,
releaseType: selectedReleaseType,
proxy,
proxyId: isProxyDisabled ? undefined : (selectedProxyId ?? undefined),
});
// Reset form
setProfileName("");
setSelectedReleaseType(null);
setProxyEnabled(false);
setProxyHost("");
setProxyPort(8080);
setProxyUsername("");
setProxyPassword("");
setSelectedProxyId(null);
onClose();
} catch (error) {
console.error("Failed to create profile:", error);
@@ -258,14 +275,9 @@ export function CreateProfileDialog({
selectedBrowser,
selectedReleaseType,
onCreateProfile,
proxyEnabled,
isProxyDisabled,
selectedProxyId,
onClose,
proxyHost,
proxyPassword,
proxyPort,
proxyType,
proxyUsername,
releaseTypes.nightly,
releaseTypes.stable,
validateProfileName,
@@ -286,14 +298,14 @@ export function CreateProfileDialog({
selectedReleaseType &&
selectedVersion &&
isVersionDownloaded(selectedVersion) &&
(!proxyEnabled || isProxyDisabled || (proxyHost && proxyPort)) &&
!nameError;
useEffect(() => {
if (isOpen) {
void loadExistingProfiles();
void loadStoredProxies();
}
}, [isOpen, loadExistingProfiles]);
}, [isOpen, loadExistingProfiles, loadStoredProxies]);
useEffect(() => {
if (isOpen && selectedBrowser) {
@@ -305,260 +317,239 @@ export function CreateProfileDialog({
}, [isOpen, selectedBrowser, loadDownloadedVersions, loadReleaseTypes]);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md max-h-[80vh] my-8 flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle>Create New Profile</DialogTitle>
</DialogHeader>
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md max-h-[80vh] my-8 flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle>Create New Profile</DialogTitle>
</DialogHeader>
<div className="grid overflow-y-scroll flex-1 gap-6 py-4 min-h-0">
{/* Profile Name */}
<div className="grid gap-2">
<Label htmlFor="profile-name">Profile Name</Label>
<Input
id="profile-name"
value={profileName}
onChange={(e) => {
setProfileName(e.target.value);
}}
placeholder="Enter profile name"
className={nameError ? "border-red-500" : ""}
/>
{nameError && <p className="text-sm text-red-600">{nameError}</p>}
</div>
{/* Browser Selection */}
<div className="grid gap-2">
<Label>Browser</Label>
<Select
value={selectedBrowser ?? undefined}
onValueChange={(value) => {
setSelectedBrowser(value as BrowserTypeString);
}}
disabled={isLoadingSupport}
>
<SelectTrigger>
<SelectValue
placeholder={
isLoadingSupport ? "Loading browsers..." : "Select browser"
}
/>
</SelectTrigger>
<SelectContent>
{(
[
"mullvad-browser",
"firefox",
"firefox-developer",
"chromium",
"brave",
"zen",
"tor-browser",
] as BrowserTypeString[]
).map((browser) => {
const isSupported = isBrowserSupported(browser);
const displayName = getBrowserDisplayName(browser);
if (!isSupported) {
return (
<Tooltip key={browser}>
<TooltipTrigger asChild>
<SelectItem
value={browser}
disabled={true}
className="opacity-50"
>
{displayName} (Not supported)
</SelectItem>
</TooltipTrigger>
<TooltipContent>
<p>
{displayName} is not supported on your current
platform or architecture.
</p>
</TooltipContent>
</Tooltip>
);
}
return (
<SelectItem key={browser} value={browser}>
{displayName}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
{selectedBrowser ? (
<div className="grid overflow-y-scroll flex-1 gap-6 py-4 min-h-0">
{/* Profile Name */}
<div className="grid gap-2">
<Label>Release Type</Label>
{isLoadingReleaseTypes ? (
<div className="text-sm text-muted-foreground">
Loading release types...
</div>
) : Object.keys(releaseTypes).length === 0 ? (
<Alert>
<AlertDescription>
No releases are available for{" "}
{getBrowserDisplayName(selectedBrowser)}.
</AlertDescription>
</Alert>
) : (
<div className="space-y-4">
{(!releaseTypes.stable || !releaseTypes.nightly) && (
<Alert>
<AlertDescription>
Only {(releaseTypes.stable && "Stable") ?? "Nightly"}{" "}
releases are available for{" "}
{getBrowserDisplayName(selectedBrowser)}.
</AlertDescription>
</Alert>
<Label htmlFor="profile-name">Profile Name</Label>
<Input
id="profile-name"
value={profileName}
onChange={(e) => {
setProfileName(e.target.value);
}}
placeholder="Enter profile name"
className={nameError ? "border-red-500" : ""}
/>
{nameError && <p className="text-sm text-red-600">{nameError}</p>}
</div>
{/* Browser Selection */}
<div className="grid gap-2">
<Label>Browser</Label>
<Select
value={selectedBrowser ?? undefined}
onValueChange={(value) => {
setSelectedBrowser(value as BrowserTypeString);
}}
disabled={isLoadingSupport}
>
<SelectTrigger>
<SelectValue
placeholder={
isLoadingSupport
? "Loading browsers..."
: "Select browser"
}
/>
</SelectTrigger>
<SelectContent>
{(
[
"mullvad-browser",
"firefox",
"firefox-developer",
"chromium",
"brave",
"zen",
"tor-browser",
] as BrowserTypeString[]
).map((browser) => {
const isSupported = isBrowserSupported(browser);
const displayName = getBrowserDisplayName(browser);
if (!isSupported) {
return (
<Tooltip key={browser}>
<TooltipTrigger asChild>
<SelectItem
value={browser}
disabled={true}
className="opacity-50"
>
{displayName} (Not supported)
</SelectItem>
</TooltipTrigger>
<TooltipContent>
<p>
{displayName} is not supported on your current
platform or architecture.
</p>
</TooltipContent>
</Tooltip>
);
}
return (
<SelectItem key={browser} value={browser}>
{displayName}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
{selectedBrowser ? (
<div className="grid gap-2">
<Label>Release Type</Label>
{isLoadingReleaseTypes ? (
<div className="text-sm text-muted-foreground">
Loading release types...
</div>
) : Object.keys(releaseTypes).length === 0 ? (
<Alert>
<AlertDescription>
No releases are available for{" "}
{getBrowserDisplayName(selectedBrowser)}.
</AlertDescription>
</Alert>
) : (
<div className="space-y-4">
{(!releaseTypes.stable || !releaseTypes.nightly) && (
<Alert>
<AlertDescription>
Only {(releaseTypes.stable && "Stable") ?? "Nightly"}{" "}
releases are available for{" "}
{getBrowserDisplayName(selectedBrowser)}.
</AlertDescription>
</Alert>
)}
<ReleaseTypeSelector
selectedReleaseType={selectedReleaseType}
onReleaseTypeSelect={setSelectedReleaseType}
availableReleaseTypes={releaseTypes}
browser={selectedBrowser}
isDownloading={isDownloading}
onDownload={() => {
void handleDownload();
}}
placeholder="Select release type..."
downloadedVersions={downloadedVersions}
/>
</div>
)}
</div>
) : null}
{/* Proxy Settings */}
<div className="grid gap-4 pt-4 border-t">
<div className="grid gap-2">
<div className="flex justify-between items-center">
<Label>Proxy Settings</Label>
{!isProxyDisabled && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={handleCreateProxy}
className="flex gap-2 items-center"
>
<FiPlus className="w-4 h-4" />
Create Proxy
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Create a new proxy configuration</p>
</TooltipContent>
</Tooltip>
)}
<ReleaseTypeSelector
selectedReleaseType={selectedReleaseType}
onReleaseTypeSelect={setSelectedReleaseType}
availableReleaseTypes={releaseTypes}
browser={selectedBrowser}
isDownloading={isDownloading}
onDownload={() => {
void handleDownload();
}}
placeholder="Select release type..."
downloadedVersions={downloadedVersions}
/>
</div>
)}
</div>
) : null}
{/* Proxy Settings */}
<div className="grid gap-4 pt-4 border-t">
<div className="flex items-center space-x-2">
{isProxyDisabled ? (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-2 opacity-50">
<Checkbox
id="proxy-enabled"
checked={false}
disabled={true}
/>
<Label htmlFor="proxy-enabled" className="text-gray-500">
Enable Proxy
</Label>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
Tor Browser has its own built-in proxy system and
doesn&apos;t support additional proxy configuration
</p>
</TooltipContent>
</Tooltip>
) : (
<>
<Checkbox
id="proxy-enabled"
checked={proxyEnabled}
onCheckedChange={(checked) => {
setProxyEnabled(checked as boolean);
{isProxyDisabled ? (
<Tooltip>
<TooltipTrigger asChild>
<div className="p-3 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&apos;t support additional proxy configuration.
</p>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
Tor Browser manages its own proxy routing automatically
</p>
</TooltipContent>
</Tooltip>
) : (
<Select
value={selectedProxyId ?? "none"}
onValueChange={(value) => {
setSelectedProxyId(value === "none" ? null : value);
}}
/>
<Label htmlFor="proxy-enabled">Enable Proxy</Label>
</>
)}
</div>
{proxyEnabled && !isProxyDisabled && (
<>
<div className="grid gap-2">
<Label>Proxy Type</Label>
<Select value={proxyType} onValueChange={setProxyType}>
disabled={isLoadingProxies}
>
<SelectTrigger>
<SelectValue />
<SelectValue
placeholder={
isLoadingProxies
? "Loading proxies..."
: "Select proxy (optional)"
}
/>
</SelectTrigger>
<SelectContent>
{["http", "https", "socks4", "socks5"].map((type) => (
<SelectItem key={type} value={type}>
{type.toUpperCase()}
<SelectItem value="none">No Proxy</SelectItem>
{storedProxies.map((proxy) => (
<SelectItem key={proxy.id} value={proxy.id}>
{proxy.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="grid gap-2">
<Label htmlFor="proxy-host">Host</Label>
<Input
id="proxy-host"
value={proxyHost}
onChange={(e) => {
setProxyHost(e.target.value);
}}
placeholder="e.g. 127.0.0.1"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-port">Port</Label>
<Input
id="proxy-port"
type="number"
value={proxyPort}
onChange={(e) => {
setProxyPort(Number.parseInt(e.target.value, 10) || 0);
}}
placeholder="e.g. 8080"
min="1"
max="65535"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-username">Username (optional)</Label>
<Input
id="proxy-username"
value={proxyUsername}
onChange={(e) => {
setProxyUsername(e.target.value);
}}
placeholder="Proxy username"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-password">Password (optional)</Label>
<Input
id="proxy-password"
type="password"
value={proxyPassword}
onChange={(e) => {
setProxyPassword(e.target.value);
}}
placeholder="Proxy password"
/>
</div>
</>
)}
{!isProxyDisabled &&
storedProxies.length === 0 &&
!isLoadingProxies && (
<p className="text-sm text-muted-foreground">
No saved proxies available. Use the "Create Proxy" button
above to create proxy configurations.
</p>
)}
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<LoadingButton
isLoading={isCreating}
onClick={() => void handleCreate()}
disabled={!canCreate}
>
Create Profile
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<LoadingButton
isLoading={isCreating}
onClick={() => void handleCreate()}
disabled={!canCreate}
>
Create Profile
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
<ProxyFormDialog
isOpen={showProxyForm}
onClose={handleProxyFormClose}
onSave={handleProxySaved}
/>
</>
);
}
+87 -19
View File
@@ -8,6 +8,7 @@ import {
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import { invoke } from "@tauri-apps/api/core";
import * as React from "react";
import { CiCircleCheck } from "react-icons/ci";
import { IoEllipsisHorizontal } from "react-icons/io5";
@@ -44,7 +45,7 @@ import {
} from "@/components/ui/tooltip";
import { useTableSorting } from "@/hooks/use-table-sorting";
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
import type { BrowserProfile } from "@/types";
import type { BrowserProfile, StoredProxy } from "@/types";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
@@ -58,6 +59,7 @@ interface ProfilesDataTableProps {
onChangeVersion: (profile: BrowserProfile) => void;
runningProfiles: Set<string>;
isUpdating?: (browser: string) => boolean;
onReloadProxyData?: () => void | Promise<void>;
}
export function ProfilesDataTable({
@@ -70,6 +72,7 @@ export function ProfilesDataTable({
onChangeVersion,
runningProfiles,
isUpdating = () => false,
onReloadProxyData,
}: ProfilesDataTableProps) {
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
const [sorting, setSorting] = React.useState<SortingState>([]);
@@ -83,12 +86,65 @@ export function ProfilesDataTable({
React.useState("");
const [deleteError, setDeleteError] = React.useState<string | null>(null);
const [isClient, setIsClient] = React.useState(false);
const [storedProxies, setStoredProxies] = React.useState<StoredProxy[]>([]);
// 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;
},
[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],
);
// Ensure we're on the client side to prevent hydration mismatches
React.useEffect(() => {
setIsClient(true);
}, []);
// Load stored proxies
const loadStoredProxies = React.useCallback(async () => {
try {
const proxiesList = await invoke<StoredProxy[]>("get_stored_proxies");
setStoredProxies(proxiesList);
} catch (error) {
console.error("Failed to load stored proxies:", error);
}
}, []);
React.useEffect(() => {
if (isClient) {
void loadStoredProxies();
}
}, [isClient, loadStoredProxies]);
// Reload proxy data when requested from parent
React.useEffect(() => {
if (onReloadProxyData) {
void loadStoredProxies();
}
}, [onReloadProxyData, loadStoredProxies]);
// Update local sorting state when settings are loaded
React.useEffect(() => {
if (isLoaded && isClient) {
@@ -320,32 +376,41 @@ export function ProfilesDataTable({
header: "Proxy",
cell: ({ row }) => {
const profile = row.original;
const hasProxy = profile.proxy?.enabled;
const regularText = hasProxy ? profile.proxy?.proxy_type : "Disabled";
const regularTooltipText = hasProxy
? `${profile.proxy?.proxy_type.toUpperCase()} proxy enabled (${
profile.proxy?.host
}:${profile.proxy?.port})`
: "No proxy configured";
const profileHasProxy = hasProxy(profile);
const proxyDisplayName = getProxyDisplayName(profile);
const proxyInfo = getProxyInfo(profile);
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})`
: "No proxy configured";
return (
<Tooltip>
<TooltipTrigger>
<div className="flex gap-2 items-center">
{hasProxy && (
{profileHasProxy && (
<CiCircleCheck className="w-4 h-4 text-green-500" />
)}
<span className="text-sm text-muted-foreground">
{profile.browser === "tor-browser"
? "Not supported"
: regularText}
</span>
{proxyDisplayName.length > 10 ? (
<span className="text-sm truncate text-muted-foreground">
{proxyDisplayName.slice(0, 10)}...
</span>
) : (
<span className="text-sm text-muted-foreground">
{profile.browser === "tor-browser"
? "Not supported"
: proxyDisplayName}
</span>
)}
</div>
</TooltipTrigger>
<TooltipContent>
{profile.browser === "tor-browser"
? "Proxies are not supported for TOR browser"
: regularTooltipText}
</TooltipContent>
<TooltipContent>{tooltipText}</TooltipContent>
</Tooltip>
);
},
@@ -426,6 +491,9 @@ export function ProfilesDataTable({
onKillProfile,
onProxySettings,
onChangeVersion,
getProxyInfo,
hasProxy,
getProxyDisplayName,
],
);
+20 -6
View File
@@ -28,7 +28,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
import type { BrowserProfile } from "@/types";
import type { BrowserProfile, StoredProxy } from "@/types";
interface ProfileSelectorDialogProps {
isOpen: boolean;
@@ -47,6 +47,17 @@ export function ProfileSelectorDialog({
const [selectedProfile, setSelectedProfile] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isLaunching, setIsLaunching] = useState(false);
const [storedProxies, setStoredProxies] = useState<StoredProxy[]>([]);
// Helper function to check if a profile has a proxy
const hasProxy = useCallback(
(profile: BrowserProfile): boolean => {
if (!profile.proxy_id) return false;
const proxy = storedProxies.find((p) => p.id === profile.proxy_id);
return proxy !== undefined;
},
[storedProxies],
);
// Helper function to determine if a profile can be used for opening links
const canUseProfileForLinks = useCallback(
@@ -86,15 +97,18 @@ export function ProfileSelectorDialog({
const loadProfiles = useCallback(async () => {
setIsLoading(true);
try {
const profileList = await invoke<BrowserProfile[]>(
"list_browser_profiles",
);
// Load both profiles and stored proxies
const [profileList, proxiesList] = await Promise.all([
invoke<BrowserProfile[]>("list_browser_profiles"),
invoke<StoredProxy[]>("get_stored_proxies"),
]);
// Sort profiles by name
profileList.sort((a, b) => a.name.localeCompare(b.name));
// Don't filter any profiles, show all of them
// Set both profiles and proxies
setProfiles(profileList);
setStoredProxies(proxiesList);
// Auto-select first available profile for link opening
if (profileList.length > 0) {
@@ -305,7 +319,7 @@ export function ProfileSelectorDialog({
<Badge variant="secondary" className="text-xs">
{getBrowserDisplayName(profile.browser)}
</Badge>
{profile.proxy?.enabled && (
{hasProxy(profile) && (
<Badge variant="outline" className="text-xs">
Proxy
</Badge>
+285
View File
@@ -0,0 +1,285 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { StoredProxy } from "@/types";
interface ProxyFormData {
name: string;
proxy_type: string;
host: string;
port: number;
username: string;
password: string;
}
interface ProxyFormDialogProps {
isOpen: boolean;
onClose: () => void;
onSave: (proxy: StoredProxy) => void;
editingProxy?: StoredProxy | null;
}
export function ProxyFormDialog({
isOpen,
onClose,
onSave,
editingProxy,
}: ProxyFormDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState<ProxyFormData>({
name: "",
proxy_type: "http",
host: "",
port: 8080,
username: "",
password: "",
});
const resetForm = useCallback(() => {
setFormData({
name: "",
proxy_type: "http",
host: "",
port: 8080,
username: "",
password: "",
});
}, []);
// Load editing proxy data when dialog opens
useEffect(() => {
if (isOpen) {
if (editingProxy) {
setFormData({
name: editingProxy.name,
proxy_type: editingProxy.proxy_settings.proxy_type,
host: editingProxy.proxy_settings.host,
port: editingProxy.proxy_settings.port,
username: editingProxy.proxy_settings.username || "",
password: editingProxy.proxy_settings.password || "",
});
} else {
resetForm();
}
}
}, [isOpen, editingProxy, resetForm]);
const handleSubmit = useCallback(async () => {
if (!formData.name.trim()) {
toast.error("Proxy name is required");
return;
}
if (!formData.host.trim() || !formData.port) {
toast.error("Host and port are required");
return;
}
setIsSubmitting(true);
try {
const proxySettings = {
proxy_type: formData.proxy_type,
host: formData.host.trim(),
port: formData.port,
username: formData.username.trim() || undefined,
password: formData.password.trim() || undefined,
};
let savedProxy: StoredProxy;
if (editingProxy) {
// Update existing proxy
savedProxy = await invoke<StoredProxy>("update_stored_proxy", {
proxyId: editingProxy.id,
name: formData.name.trim(),
proxySettings,
});
toast.success("Proxy updated successfully");
} else {
// Create new proxy
savedProxy = await invoke<StoredProxy>("create_stored_proxy", {
name: formData.name.trim(),
proxySettings,
});
toast.success("Proxy created successfully");
}
onSave(savedProxy);
onClose();
} catch (error) {
console.error("Failed to save proxy:", error);
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(`Failed to save proxy: ${errorMessage}`);
} finally {
setIsSubmitting(false);
}
}, [formData, editingProxy, onSave, onClose]);
const handleClose = useCallback(() => {
if (!isSubmitting) {
onClose();
}
}, [isSubmitting, onClose]);
const isFormValid =
formData.name.trim() &&
formData.host.trim() &&
formData.port > 0 &&
formData.port <= 65535;
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{editingProxy ? "Edit Proxy" : "Create New Proxy"}
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="proxy-name">Proxy Name</Label>
<Input
id="proxy-name"
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
placeholder="e.g. Office Proxy, Home VPN, etc."
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label>Proxy Type</Label>
<Select
value={formData.proxy_type}
onValueChange={(value) =>
setFormData({ ...formData, proxy_type: value })
}
disabled={isSubmitting}
>
<SelectTrigger>
<SelectValue placeholder="Select proxy type" />
</SelectTrigger>
<SelectContent>
{["http", "https", "socks4", "socks5"].map((type) => (
<SelectItem key={type} value={type}>
{type.toUpperCase()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="proxy-host">Host</Label>
<Input
id="proxy-host"
value={formData.host}
onChange={(e) =>
setFormData({ ...formData, host: e.target.value })
}
placeholder="e.g. 127.0.0.1"
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-port">Port</Label>
<Input
id="proxy-port"
type="number"
value={formData.port}
onChange={(e) =>
setFormData({
...formData,
port: parseInt(e.target.value, 10) || 0,
})
}
placeholder="e.g. 8080"
min="1"
max="65535"
disabled={isSubmitting}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="proxy-username">Username (optional)</Label>
<Input
id="proxy-username"
value={formData.username}
onChange={(e) =>
setFormData({
...formData,
username: e.target.value,
})
}
placeholder="Proxy username"
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-password">Password (optional)</Label>
<Input
id="proxy-password"
type="password"
value={formData.password}
onChange={(e) =>
setFormData({
...formData,
password: e.target.value,
})
}
placeholder="Proxy password"
disabled={isSubmitting}
/>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleClose}
disabled={isSubmitting}
>
Cancel
</Button>
<LoadingButton
isLoading={isSubmitting}
onClick={handleSubmit}
disabled={!isFormValid}
>
{editingProxy ? "Update Proxy" : "Create Proxy"}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+240
View File
@@ -0,0 +1,240 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { FiEdit2, FiPlus, FiTrash2, FiWifi } from "react-icons/fi";
import { toast } from "sonner";
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { StoredProxy } from "@/types";
interface ProxyManagementDialogProps {
isOpen: boolean;
onClose: () => void;
}
export function ProxyManagementDialog({
isOpen,
onClose,
}: ProxyManagementDialogProps) {
const [storedProxies, setStoredProxies] = useState<StoredProxy[]>([]);
const [loading, setLoading] = useState(false);
const [showProxyForm, setShowProxyForm] = useState(false);
const [editingProxy, setEditingProxy] = useState<StoredProxy | null>(null);
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);
}
}, []);
useEffect(() => {
if (isOpen) {
loadStoredProxies();
}
}, [isOpen, loadStoredProxies]);
const handleDeleteProxy = useCallback(async (proxy: StoredProxy) => {
if (
!confirm(`Are you sure you want to delete the proxy "${proxy.name}"?`)
) {
return;
}
try {
await invoke("delete_stored_proxy", { proxyId: proxy.id });
setStoredProxies((prev) => prev.filter((p) => p.id !== proxy.id));
toast.success("Proxy deleted successfully");
} catch (error) {
console.error("Failed to delete proxy:", error);
toast.error("Failed to delete proxy");
}
}, []);
const handleCreateProxy = useCallback(() => {
setEditingProxy(null);
setShowProxyForm(true);
}, []);
const handleEditProxy = useCallback((proxy: StoredProxy) => {
setEditingProxy(proxy);
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];
}
});
setShowProxyForm(false);
setEditingProxy(null);
}, []);
const handleProxyFormClose = useCallback(() => {
setShowProxyForm(false);
setEditingProxy(null);
}, []);
const trimName = useCallback((name: string) => {
return name.length > 30 ? `${name.substring(0, 30)}...` : name;
}, []);
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<div className="flex gap-2 items-center">
<FiWifi className="w-5 h-5" />
<DialogTitle>Proxy Management</DialogTitle>
</div>
</DialogHeader>
<div className="flex flex-col flex-1 gap-4 py-4 min-h-0">
{/* Header with Create Button */}
<div className="flex flex-shrink-0 justify-between items-center">
<div>
<h3 className="text-lg font-medium">Stored Proxies</h3>
<p className="text-sm text-muted-foreground">
Manage your saved proxy configurations for reuse across
profiles
</p>
</div>
<Button
onClick={handleCreateProxy}
className="flex gap-2 items-center"
>
<FiPlus className="w-4 h-4" />
Create Proxy
</Button>
</div>
{/* Proxy List - Scrollable */}
<div className="flex-1 min-h-0">
{loading ? (
<div className="flex justify-center items-center h-32">
<p className="text-sm text-muted-foreground">
Loading proxies...
</p>
</div>
) : storedProxies.length === 0 ? (
<div className="flex flex-col justify-center items-center h-32 text-center">
<FiWifi className="mx-auto mb-4 w-12 h-12 text-muted-foreground" />
<p className="mb-2 text-muted-foreground">
No proxies configured
</p>
<p className="mb-4 text-sm text-muted-foreground">
Create your first proxy configuration to get started
</p>
<Button variant="outline" onClick={handleCreateProxy}>
<FiPlus className="mr-2 w-4 h-4" />
Create First Proxy
</Button>
</div>
) : (
<div className="overflow-y-auto pr-2 space-y-2 h-full">
{storedProxies.map((proxy) => (
<div
key={proxy.id}
className="flex justify-between items-center p-1 rounded border bg-card"
>
<div className="flex-1 ml-2 min-w-0">
{proxy.name.length > 30 ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="block font-medium truncate text-card-foreground">
{trimName(proxy.name)}
</span>
</TooltipTrigger>
<TooltipContent>
<span className="text-sm font-medium text-card-foreground">
{proxy.name}
</span>
</TooltipContent>
</Tooltip>
) : (
<span className="text-sm font-medium text-card-foreground">
{proxy.name}
</span>
)}
</div>
<div className="flex flex-shrink-0 gap-1 items-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditProxy(proxy)}
>
<FiEdit2 className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit proxy</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteProxy(proxy)}
className="text-destructive hover:text-destructive"
>
<FiTrash2 className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete proxy</p>
</TooltipContent>
</Tooltip>
</div>
</div>
))}
</div>
)}
</div>
</div>
<DialogFooter className="flex-shrink-0">
<Button onClick={onClose}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ProxyFormDialog
isOpen={showProxyForm}
onClose={handleProxyFormClose}
onSave={handleProxySaved}
editingProxy={editingProxy}
/>
</>
);
}
+235 -232
View File
@@ -1,8 +1,13 @@
"use client";
import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
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 { Checkbox } from "@/components/ui/checkbox";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
@@ -10,35 +15,20 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface ProxySettings {
enabled: boolean;
proxy_type: string;
host: string;
port: number;
username?: string;
password?: string;
}
import { cn } from "@/lib/utils";
import type { StoredProxy } from "@/types";
interface ProxySettingsDialogProps {
isOpen: boolean;
onClose: () => void;
onSave: (proxySettings: ProxySettings) => void;
initialSettings?: ProxySettings;
onSave: (proxyId: string | null) => void;
initialProxyId?: string | null;
browserType?: string;
}
@@ -46,232 +36,245 @@ export function ProxySettingsDialog({
isOpen,
onClose,
onSave,
initialSettings,
initialProxyId,
browserType,
}: ProxySettingsDialogProps) {
const [settings, setSettings] = useState<ProxySettings>({
enabled: initialSettings?.enabled ?? false,
proxy_type: initialSettings?.proxy_type ?? "http",
host: initialSettings?.host ?? "",
port: initialSettings?.port ?? 8080,
username: initialSettings?.username ?? "",
password: initialSettings?.password ?? "",
});
const [initialSettingsState, setInitialSettingsState] =
useState<ProxySettings>({
enabled: false,
proxy_type: "http",
host: "",
port: 8080,
username: "",
password: "",
});
useEffect(() => {
if (isOpen && initialSettings) {
const newSettings = {
enabled: initialSettings.enabled,
proxy_type: initialSettings.proxy_type,
host: initialSettings.host,
port: initialSettings.port,
username: initialSettings.username ?? "",
password: initialSettings.password ?? "",
};
setSettings(newSettings);
setInitialSettingsState(newSettings);
} else if (isOpen) {
const defaultSettings = {
enabled: false,
proxy_type: "http",
host: "",
port: 80,
username: "",
password: "",
};
setSettings(defaultSettings);
setInitialSettingsState(defaultSettings);
}
}, [isOpen, initialSettings]);
const handleSubmit = () => {
onSave(settings);
};
// Check if settings have changed
const hasChanged = () => {
return (
settings.enabled !== initialSettingsState.enabled ||
settings.proxy_type !== initialSettingsState.proxy_type ||
settings.host !== initialSettingsState.host ||
settings.port !== initialSettingsState.port ||
settings.username !== initialSettingsState.username ||
settings.password !== initialSettingsState.password
);
};
const [storedProxies, setStoredProxies] = useState<StoredProxy[]>([]);
const [selectedProxyId, setSelectedProxyId] = useState<string | null>(
initialProxyId || null,
);
const [loading, setLoading] = useState(false);
const [showProxyForm, setShowProxyForm] = useState(false);
// Helper to determine if proxy should be disabled for the selected browser
const isProxyDisabled = browserType === "tor-browser";
// Update proxy enabled state when browser is tor-browser
useEffect(() => {
if (browserType === "tor-browser" && settings.enabled) {
setSettings((prev) => ({ ...prev, enabled: false }));
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);
}
}, [browserType, settings.enabled]);
}, []);
useEffect(() => {
if (isOpen) {
loadStoredProxies();
if (isProxyDisabled) {
setSelectedProxyId(null);
}
}
}, [isOpen, isProxyDisabled, loadStoredProxies]);
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>
<DialogHeader>
<DialogTitle>Proxy Settings</DialogTitle>
</DialogHeader>
<>
<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-4 py-4">
<div className="flex items-center space-x-2">
{isProxyDisabled ? (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-2 opacity-50">
<Checkbox
id="proxy-enabled"
checked={false}
disabled={true}
/>
<Label htmlFor="proxy-enabled" className="text-gray-500">
Enable Proxy
</Label>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
Tor Browser has its own built-in proxy system and
doesn&apos;t support additional proxy configuration
</p>
</TooltipContent>
</Tooltip>
) : (
<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 && (
<>
<Checkbox
id="proxy-enabled"
checked={settings.enabled}
onCheckedChange={(checked) => {
setSettings({ ...settings, enabled: checked as boolean });
}}
/>
<Label htmlFor="proxy-enabled">Enable Proxy</Label>
{/* 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>
<Button
variant="outline"
size="sm"
onClick={handleCreateProxy}
className="flex gap-2 items-center"
>
<FiPlus className="w-4 h-4" />
Create New
</Button>
</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>
</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>
{settings.enabled && !isProxyDisabled && (
<>
<div className="grid gap-2">
<Label>Proxy Type</Label>
<Select
value={settings.proxy_type}
onValueChange={(value) => {
setSettings({
...settings,
proxy_type: value,
});
}}
>
<SelectTrigger>
<SelectValue placeholder="Select proxy type" />
</SelectTrigger>
<SelectContent>
{["http", "https", "socks4", "socks5"].map((type) => (
<SelectItem key={type} value={type}>
{type.toUpperCase()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!hasChanged()}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className="grid gap-2">
<Label htmlFor="host">Host</Label>
<Input
id="host"
value={settings.host}
onChange={(e) => {
setSettings({ ...settings, host: e.target.value });
}}
placeholder="e.g. 127.0.0.1"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="port">Port</Label>
<Input
id="port"
type="number"
value={settings.port}
onChange={(e) => {
setSettings({
...settings,
port: Number.parseInt(e.target.value, 10) || 0,
});
}}
placeholder="e.g. 8080"
min="1"
max="65535"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="username">Username (optional)</Label>
<Input
id="username"
value={settings.username}
onChange={(e) => {
setSettings({ ...settings, username: e.target.value });
}}
placeholder="Proxy username"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="password">Password (optional)</Label>
<Input
id="password"
type="password"
value={settings.password}
onChange={(e) => {
setSettings({ ...settings, password: e.target.value });
}}
placeholder="Proxy password"
/>
</div>
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={
!hasChanged() ||
(!isProxyDisabled &&
settings.enabled &&
(!settings.host || !settings.port))
}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ProxyFormDialog
isOpen={showProxyForm}
onClose={handleProxyFormClose}
onSave={handleProxySaved}
/>
</>
);
}
+8 -3
View File
@@ -1,5 +1,4 @@
export interface ProxySettings {
enabled: boolean;
proxy_type: string; // "http", "https", "socks4", or "socks5"
host: string;
port: number;
@@ -13,16 +12,22 @@ export interface TableSortingSettings {
}
export interface BrowserProfile {
id: string; // UUID of the profile
name: string;
browser: string;
version: string;
profile_path: string;
proxy?: ProxySettings;
proxy_id?: string; // Reference to stored proxy
process_id?: number;
last_launch?: number;
release_type: string; // "stable" or "nightly"
}
export interface StoredProxy {
id: string;
name: string;
proxy_settings: ProxySettings;
}
export interface DetectedProfile {
browser: string;
name: string;