refactor: cleanup

This commit is contained in:
zhom
2026-03-13 10:19:34 +04:00
parent 756bd69a84
commit e9b5442340
53 changed files with 1930 additions and 2096 deletions
+1
View File
@@ -131,6 +131,7 @@
"ntlm",
"numpy",
"objc",
"oneshot",
"opencode",
"orhun",
"orjson",
+16 -1
View File
@@ -17,4 +17,19 @@
## UI Theming
- When modifying the UI, don't add random colors that are not controlled by `src/lib/themes.ts`
- Never use hardcoded Tailwind color classes (e.g., `text-red-500`, `bg-green-600`, `border-yellow-400`). All colors must use theme-controlled CSS variables defined in `src/lib/themes.ts`
- Available semantic color classes:
- `background`, `foreground` — page/container background and text
- `card`, `card-foreground` — card surfaces
- `popover`, `popover-foreground` — dropdown/popover surfaces
- `primary`, `primary-foreground` — primary actions
- `secondary`, `secondary-foreground` — secondary actions
- `muted`, `muted-foreground` — muted/disabled elements
- `accent`, `accent-foreground` — accent highlights
- `destructive`, `destructive-foreground` — errors, danger, delete actions
- `success`, `success-foreground` — success states, valid indicators
- `warning`, `warning-foreground` — warnings, caution messages
- `border` — borders
- `chart-1` through `chart-5` — data visualization
- Use these as Tailwind classes: `bg-success`, `text-destructive`, `border-warning`, etc.
- For lighter variants use opacity: `bg-destructive/10`, `bg-success/10`, `border-warning/50`
+1 -29
View File
@@ -106,35 +106,7 @@ impl AutoUpdater {
// Check each profile for updates
for profile in profiles {
if let Some(update) = self.check_profile_update(&profile, &versions)? {
// Apply chromium threshold logic
if browser == "chromium" {
// For chromium, only show notifications if there's a significant version jump
// Compare the major version component (first number before the dot)
let current_major: u32 = profile
.version
.split('.')
.next()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let new_major: u32 = update
.new_version
.split('.')
.next()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let result = new_major.saturating_sub(current_major);
log::info!(
"Current major version: {current_major}, New major version: {new_major}, Diff: {result}"
);
if result > 0 {
notifications.push(update);
} else {
log::info!("Skipping chromium update notification: same major version");
}
} else {
notifications.push(update);
}
notifications.push(update);
}
}
}
+75 -552
View File
@@ -13,11 +13,6 @@ pub struct ProxySettings {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum BrowserType {
Chromium,
Firefox,
FirefoxDeveloper,
Brave,
Zen,
Camoufox,
Wayfern,
}
@@ -25,11 +20,6 @@ pub enum BrowserType {
impl BrowserType {
pub fn as_str(&self) -> &'static str {
match self {
BrowserType::Chromium => "chromium",
BrowserType::Firefox => "firefox",
BrowserType::FirefoxDeveloper => "firefox-developer",
BrowserType::Brave => "brave",
BrowserType::Zen => "zen",
BrowserType::Camoufox => "camoufox",
BrowserType::Wayfern => "wayfern",
}
@@ -37,11 +27,6 @@ impl BrowserType {
pub fn from_str(s: &str) -> Result<Self, String> {
match s {
"chromium" => Ok(BrowserType::Chromium),
"firefox" => Ok(BrowserType::Firefox),
"firefox-developer" => Ok(BrowserType::FirefoxDeveloper),
"brave" => Ok(BrowserType::Brave),
"zen" => Ok(BrowserType::Zen),
"camoufox" => Ok(BrowserType::Camoufox),
"wayfern" => Ok(BrowserType::Wayfern),
_ => Err(format!("Unknown browser type: {s}")),
@@ -49,6 +34,7 @@ impl BrowserType {
}
}
#[allow(dead_code)]
pub trait Browser: Send + Sync {
fn get_executable_path(&self, install_dir: &Path) -> Result<PathBuf, Box<dyn std::error::Error>>;
fn create_launch_args(
@@ -88,10 +74,7 @@ mod macos {
.filter(|entry| {
let binding = entry.file_name();
let name = binding.to_string_lossy();
name.starts_with("firefox")
|| name.starts_with("zen")
|| name.starts_with("camoufox")
|| name.contains("Browser")
name.starts_with("firefox") || name.starts_with("camoufox") || name.contains("Browser")
})
.map(|entry| entry.path())
.collect();
@@ -200,34 +183,6 @@ mod macos {
Ok(executable_path)
}
pub fn get_chromium_executable_path(
install_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
// Find the .app directory
let app_path = std::fs::read_dir(install_dir)?
.filter_map(Result::ok)
.find(|entry| entry.path().extension().is_some_and(|ext| ext == "app"))
.ok_or("Browser app not found")?;
// Construct the browser executable path
let mut executable_dir = app_path.path();
executable_dir.push("Contents");
executable_dir.push("MacOS");
// Find the first executable in the MacOS directory
let executable_path = std::fs::read_dir(&executable_dir)?
.filter_map(Result::ok)
.find(|entry| {
let binding = entry.file_name();
let name = binding.to_string_lossy();
name.contains("Chromium") || name.contains("Brave") || name.contains("Google Chrome")
})
.map(|entry| entry.path())
.ok_or("No executable found in MacOS directory")?;
Ok(executable_path)
}
pub fn get_wayfern_executable_path(
install_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
@@ -281,18 +236,7 @@ mod macos {
false
}
pub fn is_chromium_version_downloaded(install_dir: &Path) -> bool {
// On macOS, check for .app files
if let Ok(entries) = std::fs::read_dir(install_dir) {
for entry in entries.flatten() {
if entry.path().extension().is_some_and(|ext| ext == "app") {
return true;
}
}
}
false
}
#[allow(dead_code)]
pub fn prepare_executable(_executable_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
// On macOS, no special preparation needed
Ok(())
@@ -316,20 +260,6 @@ mod linux {
// Try common firefox executable locations (nested and flat)
let possible_executables = match browser_type {
BrowserType::Firefox | BrowserType::FirefoxDeveloper => vec![
// Nested "firefox/firefox" or "firefox/firefox-bin"
install_dir.join("firefox").join("firefox"),
install_dir.join("firefox").join("firefox-bin"),
// Flat under version directory
install_dir.join("firefox"),
install_dir.join("firefox-bin"),
// Under a subdirectory matching the browser type
browser_subdir.join("firefox"),
browser_subdir.join("firefox-bin"),
],
BrowserType::Zen => {
vec![browser_subdir.join("zen"), browser_subdir.join("zen-bin")]
}
BrowserType::Camoufox => {
vec![
install_dir.join("camoufox-bin"),
@@ -360,36 +290,10 @@ mod linux {
browser_type: &BrowserType,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
let possible_executables = match browser_type {
BrowserType::Chromium => vec![
// Direct paths (for manual installations)
install_dir.join("chromium"),
install_dir.join("chrome"),
install_dir.join("chromium-browser"),
// Subdirectory paths (for downloaded archives)
install_dir.join("chrome-linux").join("chrome"),
install_dir.join("chrome-linux").join("chromium"),
install_dir.join("chromium").join("chromium"),
install_dir.join("chromium").join("chrome"),
// Binary subdirectory
install_dir.join("bin").join("chromium"),
install_dir.join("bin").join("chrome"),
],
BrowserType::Brave => vec![
install_dir.join("brave"),
install_dir.join("brave-browser"),
install_dir.join("brave-browser-nightly"),
install_dir.join("brave-browser-beta"),
// Subdirectory paths
install_dir.join("brave").join("brave"),
install_dir.join("brave-browser").join("brave"),
install_dir.join("bin").join("brave"),
],
BrowserType::Wayfern => vec![
// Wayfern extracts to a directory with chromium executable
install_dir.join("chromium"),
install_dir.join("chrome"),
install_dir.join("wayfern"),
// Subdirectory paths (tar.xz may extract to a subdirectory)
install_dir.join("wayfern").join("chromium"),
install_dir.join("wayfern").join("chrome"),
install_dir.join("chrome-linux").join("chrome"),
@@ -421,19 +325,6 @@ mod linux {
let browser_subdir = install_dir.join(browser_type.as_str());
let possible_executables = match browser_type {
BrowserType::Firefox | BrowserType::FirefoxDeveloper => {
vec![
// Preferred: executable inside a subdirectory named after the browser type
browser_subdir.join("firefox-bin"),
browser_subdir.join("firefox"),
// Fallback: executable inside a generic "firefox" subdirectory
install_dir.join("firefox").join("firefox-bin"),
install_dir.join("firefox").join("firefox"),
]
}
BrowserType::Zen => {
vec![browser_subdir.join("zen"), browser_subdir.join("zen-bin")]
}
BrowserType::Camoufox => {
vec![
install_dir.join("camoufox-bin"),
@@ -454,36 +345,10 @@ mod linux {
pub fn is_chromium_version_downloaded(install_dir: &Path, browser_type: &BrowserType) -> bool {
let possible_executables = match browser_type {
BrowserType::Chromium => vec![
// Direct paths (for manual installations)
install_dir.join("chromium"),
install_dir.join("chrome"),
install_dir.join("chromium-browser"),
// Subdirectory paths (for downloaded archives)
install_dir.join("chrome-linux").join("chrome"),
install_dir.join("chrome-linux").join("chromium"),
install_dir.join("chromium").join("chromium"),
install_dir.join("chromium").join("chrome"),
// Binary subdirectory
install_dir.join("bin").join("chromium"),
install_dir.join("bin").join("chrome"),
],
BrowserType::Brave => vec![
install_dir.join("brave"),
install_dir.join("brave-browser"),
install_dir.join("brave-browser-nightly"),
install_dir.join("brave-browser-beta"),
// Subdirectory paths
install_dir.join("brave").join("brave"),
install_dir.join("brave-browser").join("brave"),
install_dir.join("bin").join("brave"),
],
BrowserType::Wayfern => vec![
// Wayfern extracts to a directory with chromium executable
install_dir.join("chromium"),
install_dir.join("chrome"),
install_dir.join("wayfern"),
// Subdirectory paths
install_dir.join("wayfern").join("chromium"),
install_dir.join("wayfern").join("chrome"),
install_dir.join("chrome-linux").join("chrome"),
@@ -500,6 +365,7 @@ mod linux {
false
}
#[allow(dead_code)]
pub fn prepare_executable(executable_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
// On Linux, ensure the executable has proper permissions
log::info!("Setting execute permissions for: {:?}", executable_path);
@@ -551,10 +417,7 @@ mod windows {
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
if name.starts_with("firefox")
|| name.starts_with("zen")
|| name.starts_with("camoufox")
|| name.contains("browser")
if name.starts_with("firefox") || name.starts_with("camoufox") || name.contains("browser")
{
return Ok(path);
}
@@ -571,30 +434,11 @@ mod windows {
) -> Result<PathBuf, Box<dyn std::error::Error>> {
// On Windows, look for .exe files
let possible_paths = match browser_type {
BrowserType::Chromium => vec![
install_dir.join("chromium.exe"),
install_dir.join("chrome.exe"),
install_dir.join("chromium-browser.exe"),
install_dir.join("bin").join("chromium.exe"),
// Common archive extraction patterns
install_dir.join("chrome-win").join("chrome.exe"),
install_dir.join("chromium").join("chromium.exe"),
install_dir.join("chromium").join("chrome.exe"),
],
BrowserType::Brave => vec![
install_dir.join("brave.exe"),
install_dir.join("brave-browser.exe"),
install_dir.join("bin").join("brave.exe"),
// Subdirectory patterns
install_dir.join("brave").join("brave.exe"),
install_dir.join("brave-browser").join("brave.exe"),
],
BrowserType::Wayfern => vec![
install_dir.join("chromium.exe"),
install_dir.join("chrome.exe"),
install_dir.join("wayfern.exe"),
install_dir.join("bin").join("chromium.exe"),
// Subdirectory patterns
install_dir.join("wayfern").join("chromium.exe"),
install_dir.join("wayfern").join("chrome.exe"),
install_dir.join("chrome-win").join("chrome.exe"),
@@ -618,18 +462,14 @@ mod windows {
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
if name.contains("chromium")
|| name.contains("brave")
|| name.contains("chrome")
|| name.contains("wayfern")
{
if name.contains("chromium") || name.contains("chrome") || name.contains("wayfern") {
return Ok(path);
}
}
}
}
Err("Chromium/Brave/Wayfern executable not found in Windows installation directory".into())
Err("Chromium/Wayfern executable not found in Windows installation directory".into())
}
pub fn is_firefox_version_downloaded(install_dir: &Path) -> bool {
@@ -657,10 +497,7 @@ mod windows {
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
if name.starts_with("firefox")
|| name.starts_with("zen")
|| name.starts_with("camoufox")
|| name.contains("browser")
if name.starts_with("firefox") || name.starts_with("camoufox") || name.contains("browser")
{
return true;
}
@@ -674,30 +511,11 @@ mod windows {
pub fn is_chromium_version_downloaded(install_dir: &Path, browser_type: &BrowserType) -> bool {
// On Windows, check for .exe files
let possible_executables = match browser_type {
BrowserType::Chromium => vec![
install_dir.join("chromium.exe"),
install_dir.join("chrome.exe"),
install_dir.join("chromium-browser.exe"),
install_dir.join("bin").join("chromium.exe"),
// Common archive extraction patterns
install_dir.join("chrome-win").join("chrome.exe"),
install_dir.join("chromium").join("chromium.exe"),
install_dir.join("chromium").join("chrome.exe"),
],
BrowserType::Brave => vec![
install_dir.join("brave.exe"),
install_dir.join("brave-browser.exe"),
install_dir.join("bin").join("brave.exe"),
// Subdirectory patterns
install_dir.join("brave").join("brave.exe"),
install_dir.join("brave-browser").join("brave.exe"),
],
BrowserType::Wayfern => vec![
install_dir.join("chromium.exe"),
install_dir.join("chrome.exe"),
install_dir.join("wayfern.exe"),
install_dir.join("bin").join("chromium.exe"),
// Subdirectory patterns
install_dir.join("wayfern").join("chromium.exe"),
install_dir.join("wayfern").join("chrome.exe"),
install_dir.join("chrome-win").join("chrome.exe"),
@@ -722,11 +540,7 @@ mod windows {
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
if name.contains("chromium")
|| name.contains("brave")
|| name.contains("chrome")
|| name.contains("wayfern")
{
if name.contains("chromium") || name.contains("chrome") || name.contains("wayfern") {
return true;
}
}
@@ -736,236 +550,13 @@ mod windows {
false
}
#[allow(dead_code)]
pub fn prepare_executable(_executable_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
// On Windows, no special preparation needed
Ok(())
}
}
pub struct FirefoxBrowser {
browser_type: BrowserType,
}
impl FirefoxBrowser {
pub fn new(browser_type: BrowserType) -> Self {
Self { browser_type }
}
}
impl Browser for FirefoxBrowser {
fn get_executable_path(&self, install_dir: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
#[cfg(target_os = "macos")]
return macos::get_firefox_executable_path(install_dir);
#[cfg(target_os = "linux")]
return linux::get_firefox_executable_path(install_dir, &self.browser_type);
#[cfg(target_os = "windows")]
return windows::get_firefox_executable_path(install_dir);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
Err("Unsupported platform".into())
}
fn create_launch_args(
&self,
profile_path: &str,
_proxy_settings: Option<&ProxySettings>,
url: Option<String>,
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let mut args = vec!["-profile".to_string(), profile_path.to_string()];
// Add remote debugging if requested
if let Some(port) = remote_debugging_port {
args.push("--start-debugger-server".to_string());
args.push(port.to_string());
}
// Add headless mode if requested
if headless {
args.push("--headless".to_string());
}
// Use -no-remote when remote debugging to avoid conflicts with existing instances
if remote_debugging_port.is_some() {
args.push("-no-remote".to_string());
}
// Firefox-based browsers use profile directory and user.js for proxy configuration
if let Some(url) = url {
args.push(url);
}
Ok(args)
}
fn is_version_downloaded(&self, version: &str, binaries_dir: &Path) -> bool {
// Expected structure: binaries/<browser>/<version>
let browser_dir = binaries_dir.join(self.browser_type.as_str()).join(version);
log::info!("Firefox browser checking version {version} in directory: {browser_dir:?}");
if !browser_dir.exists() {
log::info!("Directory does not exist: {browser_dir:?}");
return false;
}
log::info!("Directory exists, checking for browser files...");
#[cfg(target_os = "macos")]
return macos::is_firefox_version_downloaded(&browser_dir);
#[cfg(target_os = "linux")]
return linux::is_firefox_version_downloaded(&browser_dir, &self.browser_type);
#[cfg(target_os = "windows")]
return windows::is_firefox_version_downloaded(&browser_dir);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
log::info!("Unsupported platform for browser verification");
false
}
}
fn prepare_executable(&self, executable_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
#[cfg(target_os = "macos")]
return macos::prepare_executable(executable_path);
#[cfg(target_os = "linux")]
return linux::prepare_executable(executable_path);
#[cfg(target_os = "windows")]
return windows::prepare_executable(executable_path);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
Err("Unsupported platform".into())
}
}
// Chromium-based browsers (Chromium, Brave)
pub struct ChromiumBrowser {
#[allow(dead_code)]
browser_type: BrowserType,
}
impl ChromiumBrowser {
pub fn new(browser_type: BrowserType) -> Self {
Self { browser_type }
}
}
impl Browser for ChromiumBrowser {
fn get_executable_path(&self, install_dir: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
#[cfg(target_os = "macos")]
return macos::get_chromium_executable_path(install_dir);
#[cfg(target_os = "linux")]
return linux::get_chromium_executable_path(install_dir, &self.browser_type);
#[cfg(target_os = "windows")]
return windows::get_chromium_executable_path(install_dir, &self.browser_type);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
Err("Unsupported platform".into())
}
fn create_launch_args(
&self,
profile_path: &str,
proxy_settings: Option<&ProxySettings>,
url: Option<String>,
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let mut args = vec![
format!("--user-data-dir={}", profile_path),
"--no-default-browser-check".to_string(),
"--disable-background-mode".to_string(),
"--disable-component-update".to_string(),
"--disable-background-timer-throttling".to_string(),
"--crash-server-url=".to_string(),
"--disable-updater".to_string(),
// Disable quit confirmation and session restore prompts
"--disable-session-crashed-bubble".to_string(),
"--hide-crash-restore-bubble".to_string(),
"--disable-infobars".to_string(),
// Disable QUIC/HTTP3 to ensure traffic goes through HTTP proxy
"--disable-quic".to_string(),
];
// Add remote debugging if requested
if let Some(port) = remote_debugging_port {
args.push("--remote-debugging-address=0.0.0.0".to_string());
args.push(format!("--remote-debugging-port={port}"));
}
// Add headless mode if requested
if headless {
args.push("--headless".to_string());
}
// Add proxy configuration if provided
if let Some(proxy) = proxy_settings {
args.push(format!(
"--proxy-server=http://{}:{}",
proxy.host, proxy.port
));
}
if let Some(url) = url {
args.push(url);
}
Ok(args)
}
fn is_version_downloaded(&self, version: &str, binaries_dir: &Path) -> bool {
// Expected structure: binaries/<browser>/<version>
let browser_dir = binaries_dir.join(self.browser_type.as_str()).join(version);
log::info!("Chromium browser checking version {version} in directory: {browser_dir:?}");
if !browser_dir.exists() {
log::info!("Directory does not exist: {browser_dir:?}");
return false;
}
log::info!("Directory exists, checking for browser files...");
#[cfg(target_os = "macos")]
return macos::is_chromium_version_downloaded(&browser_dir);
#[cfg(target_os = "linux")]
return linux::is_chromium_version_downloaded(&browser_dir, &self.browser_type);
#[cfg(target_os = "windows")]
return windows::is_chromium_version_downloaded(&browser_dir, &self.browser_type);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
log::info!("Unsupported platform for browser verification");
false
}
}
fn prepare_executable(&self, executable_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
#[cfg(target_os = "macos")]
return macos::prepare_executable(executable_path);
#[cfg(target_os = "linux")]
return linux::prepare_executable(executable_path);
#[cfg(target_os = "windows")]
return windows::prepare_executable(executable_path);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
Err("Unsupported platform".into())
}
}
pub struct CamoufoxBrowser;
impl CamoufoxBrowser {
@@ -1175,10 +766,6 @@ impl BrowserFactory {
pub fn create_browser(&self, browser_type: BrowserType) -> Box<dyn Browser> {
match browser_type {
BrowserType::Firefox | BrowserType::FirefoxDeveloper | BrowserType::Zen => {
Box::new(FirefoxBrowser::new(browser_type))
}
BrowserType::Chromium | BrowserType::Brave => Box::new(ChromiumBrowser::new(browser_type)),
BrowserType::Camoufox => Box::new(CamoufoxBrowser::new()),
BrowserType::Wayfern => Box::new(WayfernBrowser::new()),
}
@@ -1272,35 +859,10 @@ mod tests {
#[test]
fn test_browser_type_conversions() {
// Test as_str
assert_eq!(BrowserType::Firefox.as_str(), "firefox");
assert_eq!(BrowserType::FirefoxDeveloper.as_str(), "firefox-developer");
assert_eq!(BrowserType::Chromium.as_str(), "chromium");
assert_eq!(BrowserType::Brave.as_str(), "brave");
assert_eq!(BrowserType::Zen.as_str(), "zen");
assert_eq!(BrowserType::Camoufox.as_str(), "camoufox");
assert_eq!(BrowserType::Wayfern.as_str(), "wayfern");
// Test from_str - use expect with descriptive messages instead of unwrap
assert_eq!(
BrowserType::from_str("firefox").expect("firefox should be valid"),
BrowserType::Firefox
);
assert_eq!(
BrowserType::from_str("firefox-developer").expect("firefox-developer should be valid"),
BrowserType::FirefoxDeveloper
);
assert_eq!(
BrowserType::from_str("chromium").expect("chromium should be valid"),
BrowserType::Chromium
);
assert_eq!(
BrowserType::from_str("brave").expect("brave should be valid"),
BrowserType::Brave
);
assert_eq!(
BrowserType::from_str("zen").expect("zen should be valid"),
BrowserType::Zen
);
// Test from_str
assert_eq!(
BrowserType::from_str("camoufox").expect("camoufox should be valid"),
BrowserType::Camoufox
@@ -1320,25 +882,25 @@ mod tests {
let empty_result = BrowserType::from_str("");
assert!(empty_result.is_err(), "Empty string should return error");
let case_sensitive_result = BrowserType::from_str("Firefox");
assert!(
case_sensitive_result.is_err(),
"Case sensitive check should fail"
BrowserType::from_str("firefox").is_err(),
"Removed browser types should return error"
);
assert!(
BrowserType::from_str("chromium").is_err(),
"Removed browser types should return error"
);
}
#[test]
fn test_firefox_launch_args() {
// Test regular Firefox (should not use -no-remote for normal launch)
let browser = FirefoxBrowser::new(BrowserType::Firefox);
fn test_camoufox_launch_args() {
let browser = CamoufoxBrowser::new();
let args = browser
.create_launch_args("/path/to/profile", None, None, None, false)
.expect("Failed to create launch args for Firefox");
assert_eq!(args, vec!["-profile", "/path/to/profile"]);
assert!(
!args.contains(&"-no-remote".to_string()),
"Firefox should not use -no-remote for normal launch"
);
.expect("Failed to create launch args for Camoufox");
assert!(args.contains(&"-profile".to_string()));
assert!(args.contains(&"/path/to/profile".to_string()));
assert!(args.contains(&"-no-remote".to_string()));
let args = browser
.create_launch_args(
@@ -1348,40 +910,20 @@ mod tests {
None,
false,
)
.expect("Failed to create launch args for Firefox with URL");
assert_eq!(
args,
vec!["-profile", "/path/to/profile", "https://example.com"]
);
.expect("Failed to create launch args for Camoufox with URL");
assert!(args.contains(&"https://example.com".to_string()));
// Test Firefox with remote debugging (should use -no-remote)
// Test with remote debugging
let args = browser
.create_launch_args("/path/to/profile", None, None, Some(9222), false)
.expect("Failed to create launch args for Firefox with remote debugging");
assert!(
args.contains(&"-no-remote".to_string()),
"Firefox should use -no-remote for remote debugging"
);
assert!(
args.contains(&"--start-debugger-server".to_string()),
"Firefox should include debugger server arg"
);
assert!(
args.contains(&"9222".to_string()),
"Firefox should include debugging port"
);
// Test Zen Browser (no special flags without remote debugging)
let browser = FirefoxBrowser::new(BrowserType::Zen);
let args = browser
.create_launch_args("/path/to/profile", None, None, None, false)
.expect("Failed to create launch args for Zen Browser");
assert_eq!(args, vec!["-profile", "/path/to/profile"]);
.expect("Failed to create launch args for Camoufox with remote debugging");
assert!(args.contains(&"--start-debugger-server".to_string()));
assert!(args.contains(&"9222".to_string()));
// Test headless mode
let args = browser
.create_launch_args("/path/to/profile", None, None, None, true)
.expect("Failed to create launch args for Zen Browser headless");
.expect("Failed to create launch args for Camoufox headless");
assert!(
args.contains(&"--headless".to_string()),
"Browser should include headless flag when requested"
@@ -1389,30 +931,27 @@ mod tests {
}
#[test]
fn test_chromium_launch_args() {
let browser = ChromiumBrowser::new(BrowserType::Chromium);
fn test_wayfern_launch_args() {
let browser = WayfernBrowser::new();
let args = browser
.create_launch_args("/path/to/profile", None, None, None, false)
.expect("Failed to create launch args for Chromium");
.expect("Failed to create launch args for Wayfern");
// Test that basic required arguments are present
assert!(
args.contains(&"--user-data-dir=/path/to/profile".to_string()),
"Chromium args should contain user-data-dir"
"Wayfern args should contain user-data-dir"
);
assert!(
args.contains(&"--no-default-browser-check".to_string()),
"Chromium args should contain no-default-browser-check"
"Wayfern args should contain no-default-browser-check"
);
// Test that automatic update disabling arguments are present
assert!(
args.contains(&"--disable-background-mode".to_string()),
"Chromium args should contain disable-background-mode"
"Wayfern args should contain disable-background-mode"
);
assert!(
args.contains(&"--disable-component-update".to_string()),
"Chromium args should contain disable-component-update"
"Wayfern args should contain disable-component-update"
);
let args_with_url = browser
@@ -1423,13 +962,11 @@ mod tests {
None,
false,
)
.expect("Failed to create launch args for Chromium with URL");
.expect("Failed to create launch args for Wayfern with URL");
assert!(
args_with_url.contains(&"https://example.com".to_string()),
"Chromium args should contain the URL"
"Wayfern args should contain the URL"
);
// Verify URL is at the end
assert_eq!(
args_with_url.last().expect("Args should not be empty"),
"https://example.com"
@@ -1438,23 +975,19 @@ mod tests {
// Test remote debugging
let args_with_debug = browser
.create_launch_args("/path/to/profile", None, None, Some(9222), false)
.expect("Failed to create launch args for Chromium with remote debugging");
.expect("Failed to create launch args for Wayfern with remote debugging");
assert!(
args_with_debug.contains(&"--remote-debugging-port=9222".to_string()),
"Chromium args should contain remote debugging port"
);
assert!(
args_with_debug.contains(&"--remote-debugging-address=0.0.0.0".to_string()),
"Chromium args should contain remote debugging address"
"Wayfern args should contain remote debugging port"
);
// Test headless mode
let args_headless = browser
.create_launch_args("/path/to/profile", None, None, None, true)
.expect("Failed to create launch args for Chromium headless");
.expect("Failed to create launch args for Wayfern headless");
assert!(
args_headless.contains(&"--headless".to_string()),
"Chromium args should contain headless flag when requested"
args_headless.contains(&"--headless=new".to_string()),
"Wayfern args should contain headless flag when requested"
);
}
@@ -1491,26 +1024,21 @@ mod tests {
let temp_dir = TempDir::new().expect("Failed to create temp directory");
let binaries_dir = temp_dir.path();
// Create a mock Firefox browser installation with new path structure: binaries/<browser>/<version>/
let browser_dir = binaries_dir.join("firefox").join("139.0");
// Create a mock Camoufox browser installation
let browser_dir = binaries_dir.join("camoufox").join("135.0.1");
fs::create_dir_all(&browser_dir).expect("Failed to create browser directory");
#[cfg(target_os = "macos")]
{
// Create a mock .app directory for macOS
let app_dir = browser_dir.join("Firefox.app");
fs::create_dir_all(&app_dir).expect("Failed to create Firefox.app directory");
let app_dir = browser_dir.join("Camoufox.app");
fs::create_dir_all(&app_dir).expect("Failed to create Camoufox.app directory");
}
#[cfg(target_os = "linux")]
{
// Create a mock firefox subdirectory and executable for Linux
let firefox_subdir = browser_dir.join("firefox");
fs::create_dir_all(&firefox_subdir).expect("Failed to create firefox subdirectory");
let executable_path = firefox_subdir.join("firefox");
let executable_path = browser_dir.join("camoufox");
fs::write(&executable_path, "mock executable").expect("Failed to write mock executable");
// Set executable permissions on Linux
use std::os::unix::fs::PermissionsExt;
let mut permissions = executable_path
.metadata()
@@ -1523,67 +1051,62 @@ mod tests {
#[cfg(target_os = "windows")]
{
// Create a mock firefox.exe for Windows
let executable_path = browser_dir.join("firefox.exe");
fs::write(&executable_path, "mock executable").expect("Failed to write mock executable");
}
let browser = FirefoxBrowser::new(BrowserType::Firefox);
assert!(browser.is_version_downloaded("139.0", binaries_dir));
assert!(!browser.is_version_downloaded("140.0", binaries_dir));
let browser = CamoufoxBrowser::new();
assert!(browser.is_version_downloaded("135.0.1", binaries_dir));
assert!(!browser.is_version_downloaded("999.0", binaries_dir));
// Test with Chromium browser with new path structure
let chromium_dir = binaries_dir.join("chromium").join("1465660");
fs::create_dir_all(&chromium_dir).expect("Failed to create chromium directory");
// Test with Wayfern browser
let wayfern_dir = binaries_dir.join("wayfern").join("1.0.0");
fs::create_dir_all(&wayfern_dir).expect("Failed to create wayfern directory");
#[cfg(target_os = "macos")]
{
let chromium_app_dir = chromium_dir.join("Chromium.app");
fs::create_dir_all(chromium_app_dir.join("Contents").join("MacOS"))
let wayfern_app_dir = wayfern_dir.join("Chromium.app");
fs::create_dir_all(wayfern_app_dir.join("Contents").join("MacOS"))
.expect("Failed to create Chromium.app structure");
// Create a mock executable
let executable_path = chromium_app_dir
let executable_path = wayfern_app_dir
.join("Contents")
.join("MacOS")
.join("Chromium");
fs::write(&executable_path, "mock executable")
.expect("Failed to write mock Chromium executable");
.expect("Failed to write mock Wayfern executable");
}
#[cfg(target_os = "linux")]
{
// Create a mock chromium executable for Linux
let executable_path = chromium_dir.join("chromium");
let executable_path = wayfern_dir.join("chromium");
fs::write(&executable_path, "mock executable")
.expect("Failed to write mock chromium executable");
.expect("Failed to write mock wayfern executable");
// Set executable permissions on Linux
use std::os::unix::fs::PermissionsExt;
let mut permissions = executable_path
.metadata()
.expect("Failed to get chromium metadata")
.expect("Failed to get wayfern metadata")
.permissions();
permissions.set_mode(0o755);
fs::set_permissions(&executable_path, permissions)
.expect("Failed to set chromium permissions");
.expect("Failed to set wayfern permissions");
}
#[cfg(target_os = "windows")]
{
// Create a mock chromium.exe for Windows
let executable_path = chromium_dir.join("chromium.exe");
let executable_path = wayfern_dir.join("chromium.exe");
fs::write(&executable_path, "mock executable").expect("Failed to write mock chromium.exe");
}
let chromium_browser = ChromiumBrowser::new(BrowserType::Chromium);
let wayfern_browser = WayfernBrowser::new();
assert!(
chromium_browser.is_version_downloaded("1465660", binaries_dir),
"Chromium version should be detected as downloaded"
wayfern_browser.is_version_downloaded("1.0.0", binaries_dir),
"Wayfern version should be detected as downloaded"
);
assert!(
!chromium_browser.is_version_downloaded("1465661", binaries_dir),
"Non-existent Chromium version should not be detected as downloaded"
!wayfern_browser.is_version_downloaded("9.9.9", binaries_dir),
"Non-existent Wayfern version should not be detected as downloaded"
);
}
@@ -1593,28 +1116,28 @@ mod tests {
let binaries_dir = temp_dir.path();
// Create browser directory but no proper executable structure
let browser_dir = binaries_dir.join("firefox").join("139.0");
let browser_dir = binaries_dir.join("camoufox").join("135.0.1");
fs::create_dir_all(&browser_dir).expect("Failed to create browser directory");
// Create some other files but no proper executable structure
fs::write(browser_dir.join("readme.txt"), "Some content").expect("Failed to write readme file");
let browser = FirefoxBrowser::new(BrowserType::Firefox);
let browser = CamoufoxBrowser::new();
assert!(
!browser.is_version_downloaded("139.0", binaries_dir),
"Firefox version should not be detected without proper executable structure"
!browser.is_version_downloaded("135.0.1", binaries_dir),
"Camoufox version should not be detected without proper executable structure"
);
}
#[test]
fn test_browser_type_clone_and_debug() {
let browser_type = BrowserType::Firefox;
let browser_type = BrowserType::Camoufox;
let cloned = browser_type.clone();
assert_eq!(browser_type, cloned);
// Test Debug trait
let debug_str = format!("{browser_type:?}");
assert!(debug_str.contains("Firefox"));
assert!(debug_str.contains("Camoufox"));
}
#[test]
+6 -395
View File
@@ -1,4 +1,4 @@
use crate::browser::{create_browser, BrowserType, ProxySettings};
use crate::browser::ProxySettings;
use crate::camoufox_manager::{CamoufoxConfig, CamoufoxManager};
use crate::cloud_auth::CLOUD_AUTH;
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
@@ -98,9 +98,9 @@ impl BrowserRunner {
app_handle: tauri::AppHandle,
profile: &BrowserProfile,
url: Option<String>,
local_proxy_settings: Option<&ProxySettings>,
_local_proxy_settings: Option<&ProxySettings>,
remote_debugging_port: Option<u16>,
headless: bool,
_headless: bool,
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
// Handle Camoufox profiles using CamoufoxManager
if profile.browser == "camoufox" {
@@ -613,248 +613,12 @@ impl BrowserRunner {
return Ok(updated_profile);
}
// Create browser instance
let browser_type = BrowserType::from_str(&profile.browser)
.map_err(|_| format!("Invalid browser type: {}", profile.browser))?;
let browser = create_browser(browser_type.clone());
// Get executable path using common helper
let executable_path = self
.get_browser_executable_path(profile)
.expect("Failed to get executable path");
log::info!("Executable path: {executable_path:?}");
// Prepare the executable (set permissions, etc.)
if let Err(e) = browser.prepare_executable(&executable_path) {
log::warn!("Warning: Failed to prepare executable: {e}");
// Continue anyway, the error might not be critical
}
// Refresh cloud proxy credentials if needed before resolving
let _stored_proxy_settings = self
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
.await;
// 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.profile_manager.get_profiles_dir();
let profile_data_path = profile.get_profile_data_path(&profiles_dir);
let browser_args = browser
.create_launch_args(
&profile_data_path.to_string_lossy(),
proxy_for_launch_args,
url,
remote_debugging_port,
headless,
)
.expect("Failed to create launch arguments");
// Launch browser using platform-specific method
let child = {
#[cfg(target_os = "macos")]
{
platform_browser::macos::launch_browser_process(&executable_path, &browser_args).await?
}
#[cfg(target_os = "windows")]
{
platform_browser::windows::launch_browser_process(&executable_path, &browser_args).await?
}
#[cfg(target_os = "linux")]
{
platform_browser::linux::launch_browser_process(&executable_path, &browser_args).await?
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
return Err("Unsupported platform for browser launching".into());
}
};
let launcher_pid = child.id();
log::info!(
"Launched browser with launcher PID: {} for profile: {} (ID: {})",
launcher_pid,
profile.name,
profile.id
);
// 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.
let actual_pid = {
#[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.profile_manager.get_profiles_dir();
let profile_data_path = profile.get_profile_data_path(&profiles_dir);
let profile_data_path_str = profile_data_path.to_string_lossy();
let mut resolved_pid = launcher_pid;
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("camoufox")
}
"firefox-developer" => {
// More flexible detection for Firefox Developer Edition
(exe_name_lower.contains("firefox") && exe_name_lower.contains("developer"))
|| (exe_name_lower.contains("firefox")
&& cmd.iter().any(|arg| {
let arg_str = arg.to_str().unwrap_or("");
arg_str.contains("Developer")
|| arg_str.contains("developer")
|| arg_str.contains("FirefoxDeveloperEdition")
|| arg_str.contains("firefox-developer")
}))
|| exe_name_lower == "firefox" // Firefox Developer might just show as "firefox"
}
"zen" => exe_name_lower.contains("zen"),
"chromium" => exe_name_lower.contains("chromium") || exe_name_lower.contains("chrome"),
"brave" => exe_name_lower.contains("brave") || exe_name_lower.contains("Brave"),
_ => false,
};
if !is_correct_browser {
continue;
}
// Check for profile path match
let profile_path_match = if matches!(
profile.browser.as_str(),
"firefox" | "firefox-developer" | "zen"
) {
// Firefox-based browsers: look for -profile argument followed by path
let mut found_profile_arg = false;
for (i, arg) in cmd.iter().enumerate() {
if let Some(arg_str) = arg.to_str() {
if arg_str == "-profile" && i + 1 < cmd.len() {
if let Some(next_arg) = cmd.get(i + 1).and_then(|a| a.to_str()) {
if next_arg == profile_data_path_str {
found_profile_arg = true;
break;
}
}
}
// Also check for combined -profile=path format
if arg_str == format!("-profile={profile_data_path_str}") {
found_profile_arg = true;
break;
}
// Check if the argument is the profile path directly
if arg_str == profile_data_path_str {
found_profile_arg = true;
break;
}
}
}
found_profile_arg
} else {
// Chromium-based browsers: look for --user-data-dir argument
cmd.iter().any(|s| {
if let Some(arg) = s.to_str() {
arg == format!("--user-data-dir={profile_data_path_str}")
|| arg == profile_data_path_str
} else {
false
}
})
};
if profile_path_match {
let pid_u32 = pid.as_u32();
if pid_u32 != launcher_pid {
resolved_pid = pid_u32;
break;
}
}
}
resolved_pid
}
#[cfg(not(target_os = "macos"))]
{
launcher_pid
}
};
// Update profile with process info and save
let mut updated_profile = profile.clone();
updated_profile.process_id = Some(actual_pid);
updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs());
self.save_process_info(&updated_profile)?;
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
let _ = tm.rebuild_from_profiles(&self.profile_manager.list_profiles().unwrap_or_default());
});
// Apply proxy settings if needed (for Firefox-based browsers)
if profile.proxy_id.is_some()
&& matches!(
browser_type,
BrowserType::Firefox | BrowserType::FirefoxDeveloper | BrowserType::Zen
)
{
// Proxy settings for Firefox-based browsers are applied via user.js file
// which is already handled in the profile creation process
}
log::info!(
"Emitting profile events for successful launch: {} (ID: {})",
updated_profile.name,
updated_profile.id
);
// Emit profile update event to frontend
if let Err(e) = events::emit("profile-updated", &updated_profile) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
// Emit minimal running changed event to frontend with a small delay to ensure UI consistency
#[derive(Serialize)]
struct RunningChangedPayload {
id: String,
is_running: bool,
}
let payload = RunningChangedPayload {
id: updated_profile.id.to_string(),
is_running: updated_profile.process_id.is_some(),
};
if let Err(e) = events::emit("profile-running-changed", &payload) {
log::warn!("Warning: Failed to emit profile running changed event: {e}");
} else {
log::info!(
"Successfully emitted profile-running-changed event for {}: running={}",
updated_profile.name,
payload.is_running
);
}
Ok(updated_profile)
Err(format!("Unsupported browser type: {}", profile.browser).into())
}
pub async fn open_url_in_existing_browser(
&self,
app_handle: tauri::AppHandle,
_app_handle: tauri::AppHandle,
profile: &BrowserProfile,
url: &str,
_internal_proxy_settings: Option<&ProxySettings>,
@@ -948,134 +712,7 @@ impl BrowserRunner {
}
}
// Use the comprehensive browser status check for non-camoufox/wayfern browsers
let is_running = self
.check_browser_status(app_handle.clone(), profile)
.await?;
if !is_running {
return Err("Browser is not running".into());
}
// Get the updated profile with current PID
let profiles = self
.profile_manager
.list_profiles()
.expect("Failed to list profiles");
let updated_profile = profiles
.into_iter()
.find(|p| p.id == profile.id)
.unwrap_or_else(|| profile.clone());
// Ensure we have a valid process ID
if updated_profile.process_id.is_none() {
return Err("No valid process ID found for the browser".into());
}
let browser_type = BrowserType::from_str(&updated_profile.browser)
.map_err(|_| format!("Invalid browser type: {}", updated_profile.browser))?;
// Get browser directory for all platforms - path structure: binaries/<browser>/<version>/
let mut browser_dir = self.get_binaries_dir();
browser_dir.push(&updated_profile.browser);
browser_dir.push(&updated_profile.version);
match browser_type {
BrowserType::Firefox | BrowserType::FirefoxDeveloper | BrowserType::Zen => {
#[cfg(target_os = "macos")]
{
let profiles_dir = self.profile_manager.get_profiles_dir();
return platform_browser::macos::open_url_in_existing_browser_firefox_like(
&updated_profile,
url,
browser_type,
&browser_dir,
&profiles_dir,
)
.await;
}
#[cfg(target_os = "windows")]
{
let profiles_dir = self.profile_manager.get_profiles_dir();
return platform_browser::windows::open_url_in_existing_browser_firefox_like(
&updated_profile,
url,
browser_type,
&browser_dir,
&profiles_dir,
)
.await;
}
#[cfg(target_os = "linux")]
{
let profiles_dir = self.profile_manager.get_profiles_dir();
return platform_browser::linux::open_url_in_existing_browser_firefox_like(
&updated_profile,
url,
browser_type,
&browser_dir,
&profiles_dir,
)
.await;
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
return Err("Unsupported platform".into());
}
BrowserType::Camoufox => {
// Camoufox URL opening is handled differently
Err("URL opening in existing Camoufox instance is not supported".into())
}
BrowserType::Wayfern => {
// Wayfern URL opening is handled differently
Err("URL opening in existing Wayfern instance is not supported".into())
}
BrowserType::Chromium | BrowserType::Brave => {
#[cfg(target_os = "macos")]
{
let profiles_dir = self.profile_manager.get_profiles_dir();
return platform_browser::macos::open_url_in_existing_browser_chromium(
&updated_profile,
url,
browser_type,
&browser_dir,
&profiles_dir,
)
.await;
}
#[cfg(target_os = "windows")]
{
let profiles_dir = self.profile_manager.get_profiles_dir();
return platform_browser::windows::open_url_in_existing_browser_chromium(
&updated_profile,
url,
browser_type,
&browser_dir,
&profiles_dir,
)
.await;
}
#[cfg(target_os = "linux")]
{
let profiles_dir = self.profile_manager.get_profiles_dir();
return platform_browser::linux::open_url_in_existing_browser_chromium(
&updated_profile,
url,
browser_type,
&browser_dir,
&profiles_dir,
)
.await;
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
return Err("Unsupported platform".into());
}
}
Err(format!("Unsupported browser type: {}", profile.browser).into())
}
pub async fn launch_browser_with_debugging(
@@ -1115,32 +752,6 @@ impl BrowserRunner {
let internal_proxy_settings = Some(internal_proxy.clone());
// Configure Firefox profiles to use local proxy
{
// For Firefox-based browsers, apply PAC/user.js to point to the local proxy
if matches!(
profile.browser.as_str(),
"firefox" | "firefox-developer" | "zen"
) {
let profiles_dir = self.profile_manager.get_profiles_dir();
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
// Provide a dummy upstream (ignored when internal proxy is provided)
let dummy_upstream = ProxySettings {
proxy_type: "http".to_string(),
host: "127.0.0.1".to_string(),
port: internal_proxy.port,
username: None,
password: None,
};
self
.profile_manager
.apply_proxy_settings_to_profile(&profile_path, &dummy_upstream, Some(&internal_proxy))
.map_err(|e| format!("Failed to update profile proxy: {e}"))?;
}
}
let result = self
.launch_browser_internal(
app_handle.clone(),
+60 -5
View File
@@ -56,6 +56,7 @@ pub struct CamoufoxLaunchResult {
#[serde(alias = "profile_path")]
pub profilePath: Option<String>,
pub url: Option<String>,
pub cdp_port: Option<u16>,
}
#[derive(Debug)]
@@ -65,6 +66,7 @@ struct CamoufoxInstance {
process_id: Option<u32>,
profile_path: Option<String>,
url: Option<String>,
cdp_port: Option<u16>,
}
struct CamoufoxManagerInner {
@@ -88,6 +90,33 @@ impl CamoufoxManager {
&CAMOUFOX_LAUNCHER
}
async fn find_free_port() -> Result<u16, Box<dyn std::error::Error + Send + Sync>> {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let port = listener.local_addr()?.port();
drop(listener);
Ok(port)
}
#[allow(dead_code)]
pub async fn get_cdp_port(&self, profile_path: &str) -> Option<u16> {
let inner = self.inner.lock().await;
let target_path = std::path::Path::new(profile_path)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf());
for instance in inner.instances.values() {
if let Some(path) = &instance.profile_path {
let instance_path = std::path::Path::new(path)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(path).to_path_buf());
if instance_path == target_path {
return instance.cdp_port;
}
}
}
None
}
pub fn get_profiles_dir(&self) -> PathBuf {
crate::app_dirs::profiles_dir()
}
@@ -239,6 +268,9 @@ impl CamoufoxManager {
.to_string(),
];
let cdp_port = Self::find_free_port().await?;
args.push(format!("--remote-debugging-port={cdp_port}"));
// Add URL if provided
if let Some(url) = url {
args.push("-new-tab".to_string());
@@ -294,6 +326,7 @@ impl CamoufoxManager {
process_id,
profile_path: Some(profile_path.to_string()),
url: url.map(String::from),
cdp_port: Some(cdp_port),
};
let launch_result = CamoufoxLaunchResult {
@@ -301,6 +334,7 @@ impl CamoufoxManager {
processId: process_id,
profilePath: Some(profile_path.to_string()),
url: url.map(String::from),
cdp_port: Some(cdp_port),
};
{
@@ -418,6 +452,7 @@ impl CamoufoxManager {
processId: instance.process_id,
profilePath: instance.profile_path.clone(),
url: instance.url.clone(),
cdp_port: instance.cdp_port,
}));
}
}
@@ -428,7 +463,9 @@ impl CamoufoxManager {
// If not found in in-memory instances, scan system processes
// This handles the case where the app was restarted but Camoufox is still running
if let Some((pid, found_profile_path)) = self.find_camoufox_process_by_profile(&target_path) {
if let Some((pid, found_profile_path, cdp_port)) =
self.find_camoufox_process_by_profile(&target_path)
{
log::info!(
"Found running Camoufox process (PID: {}) for profile path via system scan",
pid
@@ -444,6 +481,7 @@ impl CamoufoxManager {
process_id: Some(pid),
profile_path: Some(found_profile_path.clone()),
url: None,
cdp_port,
},
);
@@ -452,6 +490,7 @@ impl CamoufoxManager {
processId: Some(pid),
profilePath: Some(found_profile_path),
url: None,
cdp_port,
}));
}
@@ -462,7 +501,7 @@ impl CamoufoxManager {
fn find_camoufox_process_by_profile(
&self,
target_path: &std::path::Path,
) -> Option<(u32, String)> {
) -> Option<(u32, String, Option<u16>)> {
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
let system = System::new_with_specifics(
@@ -487,6 +526,10 @@ impl CamoufoxManager {
continue;
}
let mut matched = false;
let mut found_profile_path = None;
let mut cdp_port: Option<u16> = None;
// Check if the command line contains our profile path
for (i, arg) in cmd.iter().enumerate() {
if let Some(arg_str) = arg.to_str() {
@@ -498,15 +541,27 @@ impl CamoufoxManager {
.unwrap_or_else(|_| std::path::Path::new(next_arg).to_path_buf());
if cmd_path == target_path {
return Some((pid.as_u32(), next_arg.to_string()));
matched = true;
found_profile_path = Some(next_arg.to_string());
}
}
}
// Also check if the argument contains the profile path directly
if arg_str.contains(&*target_path_str) {
return Some((pid.as_u32(), target_path_str.to_string()));
if !matched && arg_str.contains(&*target_path_str) {
matched = true;
found_profile_path = Some(target_path_str.to_string());
}
if let Some(port_val) = arg_str.strip_prefix("--remote-debugging-port=") {
cdp_port = port_val.parse().ok();
}
}
}
if matched {
if let Some(profile_path) = found_profile_path {
return Some((pid.as_u32(), profile_path, cdp_port));
}
}
}
+58 -324
View File
@@ -56,7 +56,7 @@ impl Downloader {
}
#[cfg(test)]
pub fn new_with_api_client(_api_client: ApiClient) -> Self {
pub fn new_for_test() -> Self {
Self {
client: Client::new(),
api_client: ApiClient::instance(),
@@ -67,87 +67,53 @@ impl Downloader {
}
}
#[cfg(test)]
pub async fn download_file(
&self,
download_url: &str,
dest_path: &Path,
filename: &str,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
let file_path = dest_path.join(filename);
let response = self
.client
.get(download_url)
.header(
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
)
.send()
.await?;
if !response.status().is_success() {
return Err(format!("Download failed with status: {}", response.status()).into());
}
let mut file = std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&file_path)?;
let mut stream = response.bytes_stream();
use futures_util::StreamExt;
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
io::copy(&mut chunk.as_ref(), &mut file)?;
}
Ok(file_path)
}
/// Resolve the actual download URL for browsers that need dynamic asset resolution
pub async fn resolve_download_url(
&self,
browser_type: BrowserType,
version: &str,
download_info: &DownloadInfo,
_download_info: &DownloadInfo,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
match browser_type {
BrowserType::Brave => {
// For Brave, we need to find the actual platform-specific asset
let releases = self
.api_client
.fetch_brave_releases_with_caching(true)
.await?;
// Find the release with the matching version
let release = releases
.iter()
.find(|r| {
r.tag_name == version || r.tag_name == format!("v{}", version.trim_start_matches('v'))
})
.ok_or(format!("Brave version {version} not found"))?;
// Get platform and architecture info
let (os, arch) = Self::get_platform_info();
// Find the appropriate asset based on platform and architecture
let asset_url = self
.find_brave_asset(&release.assets, &os, &arch)
.ok_or(format!(
"No compatible asset found for Brave version {version} on {os}/{arch}"
))?;
Ok(asset_url)
}
BrowserType::Zen => {
// For Zen, verify the asset exists and handle different naming patterns
let releases = match self.api_client.fetch_zen_releases_with_caching(true).await {
Ok(releases) => releases,
Err(e) => {
log::error!("Failed to fetch Zen releases: {e}");
return Err(format!("Failed to fetch Zen releases from GitHub API: {e}. This might be due to GitHub API rate limiting or network issues. Please try again later.").into());
}
};
let release = releases
.iter()
.find(|r| r.tag_name == version)
.ok_or_else(|| {
format!(
"Zen version {} not found. Available versions: {}",
version,
releases
.iter()
.take(5)
.map(|r| r.tag_name.as_str())
.collect::<Vec<_>>()
.join(", ")
)
})?;
// Get platform and architecture info
let (os, arch) = Self::get_platform_info();
// Find the appropriate asset
let asset_url = self
.find_zen_asset(&release.assets, &os, &arch)
.ok_or_else(|| {
let available_assets: Vec<&str> =
release.assets.iter().map(|a| a.name.as_str()).collect();
format!(
"No compatible asset found for Zen version {} on {}/{}. Available assets: {}",
version,
os,
arch,
available_assets.join(", ")
)
})?;
Ok(asset_url)
}
BrowserType::Camoufox => {
// For Camoufox, verify the asset exists and find the correct download URL
let releases = self
@@ -209,10 +175,6 @@ impl Downloader {
Ok(download_url)
}
_ => {
// For other browsers, use the provided URL
Ok(download_info.url.clone())
}
}
}
@@ -239,110 +201,6 @@ impl Downloader {
(os.to_string(), arch.to_string())
}
/// Find the appropriate Brave asset for the current platform and architecture
fn find_brave_asset(
&self,
assets: &[crate::browser::GithubAsset],
os: &str,
arch: &str,
) -> Option<String> {
// Brave asset naming patterns:
// Windows: BraveBrowserStandaloneNightlySetup.exe, BraveBrowserStandaloneSilentNightlySetup.exe
// macOS: Brave-Browser-Nightly-universal.dmg, Brave-Browser-Nightly-universal.pkg
// Linux: brave-browser-1.79.119-linux-arm64.zip, brave-browser-1.79.119-linux-amd64.zip
let asset = match os {
"windows" => {
// For Windows, look for standalone setup EXE (not the auto-updater one)
assets
.iter()
.find(|asset| {
let name = asset.name.to_lowercase();
name.contains("standalone") && name.ends_with(".exe") && !name.contains("silent")
})
.or_else(|| {
// Fallback to any EXE if standalone not found
assets.iter().find(|asset| asset.name.ends_with(".exe"))
})
}
"macos" => {
// For macOS, prefer universal DMG
assets
.iter()
.find(|asset| {
let name = asset.name.to_lowercase();
name.contains("universal") && name.ends_with(".dmg")
})
.or_else(|| {
// Fallback to any DMG
assets.iter().find(|asset| asset.name.ends_with(".dmg"))
})
}
"linux" => {
// For Linux, be strict about architecture matching - same logic as has_compatible_brave_asset
let arch_pattern = if arch == "arm64" { "arm64" } else { "amd64" };
assets.iter().find(|asset| {
let name = asset.name.to_lowercase();
name.contains("linux") && name.contains(arch_pattern) && name.ends_with(".zip")
})
}
_ => None,
};
asset.map(|a| a.browser_download_url.clone())
}
/// Find the appropriate Zen asset for the current platform and architecture
fn find_zen_asset(
&self,
assets: &[crate::browser::GithubAsset],
os: &str,
arch: &str,
) -> Option<String> {
// Zen asset naming patterns:
// Windows: zen.installer.exe, zen.installer-arm64.exe
// macOS: zen.macos-universal.dmg
// Linux: zen.linux-x86_64.tar.xz, zen.linux-aarch64.tar.xz, zen-x86_64.AppImage, zen-aarch64.AppImage
let asset = match (os, arch) {
("windows", "x64") => assets
.iter()
.find(|asset| asset.name == "zen.installer.exe"),
("windows", "arm64") => assets
.iter()
.find(|asset| asset.name == "zen.installer-arm64.exe"),
("macos", _) => assets
.iter()
.find(|asset| asset.name == "zen.macos-universal.dmg"),
("linux", "x64") => {
// Prefer tar.xz, fallback to AppImage
assets
.iter()
.find(|asset| asset.name == "zen.linux-x86_64.tar.xz")
.or_else(|| {
assets
.iter()
.find(|asset| asset.name == "zen-x86_64.AppImage")
})
}
("linux", "arm64") => {
// Prefer tar.xz, fallback to AppImage
assets
.iter()
.find(|asset| asset.name == "zen.linux-aarch64.tar.xz")
.or_else(|| {
assets
.iter()
.find(|asset| asset.name == "zen-aarch64.AppImage")
})
}
_ => None,
};
asset.map(|a| a.browser_download_url.clone())
}
/// Find the appropriate Camoufox asset for the current platform and architecture
fn find_camoufox_asset(
&self,
@@ -457,10 +315,6 @@ impl Downloader {
.resolve_download_url(browser_type.clone(), version, download_info)
.await?;
// Check if this is a twilight release for special handling
let is_twilight =
browser_type == BrowserType::Zen && version.to_lowercase().contains("twilight");
// Determine if we have a partial file to resume
let mut existing_size: u64 = 0;
if let Ok(meta) = std::fs::metadata(&file_path) {
@@ -555,11 +409,7 @@ impl Downloader {
0.0
};
let initial_stage = if is_twilight {
"downloading (twilight rolling release)".to_string()
} else {
"downloading".to_string()
};
let initial_stage = "downloading".to_string();
let progress = DownloadProgress {
browser: browser_type.as_str().to_string(),
@@ -621,11 +471,7 @@ impl Downloader {
None
};
let stage_description = if is_twilight {
"downloading (twilight rolling release)".to_string()
} else {
"downloading".to_string()
};
let stage_description = "downloading".to_string();
let progress = DownloadProgress {
browser: browser_type.as_str().to_string(),
@@ -1267,85 +1113,21 @@ pub fn configure_camoufox_search_engine(
#[cfg(test)]
mod tests {
use super::*;
use crate::api_client::ApiClient;
use crate::browser::BrowserType;
use crate::browser_version_manager::DownloadInfo;
use tempfile::TempDir;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn setup_mock_server() -> MockServer {
MockServer::start().await
}
fn create_test_api_client(server: &MockServer) -> ApiClient {
let base_url = server.uri();
ApiClient::new_with_base_urls(
base_url.clone(), // firefox_api_base
base_url.clone(), // firefox_dev_api_base
base_url.clone(), // github_api_base
base_url.clone(), // chromium_api_base
)
}
#[tokio::test]
async fn test_resolve_firefox_download_url() {
let server = setup_mock_server().await;
async fn test_download_file_with_progress() {
let server = MockServer::start().await;
let downloader = Downloader::new_for_test();
let api_client = create_test_api_client(&server);
let downloader = Downloader::new_with_api_client(api_client);
let download_info = DownloadInfo {
url: "https://download.mozilla.org/?product=firefox-139.0&os=osx&lang=en-US".to_string(),
filename: "firefox-test.dmg".to_string(),
is_archive: true,
};
let result = downloader
.resolve_download_url(BrowserType::Firefox, "139.0", &download_info)
.await;
assert!(result.is_ok());
let url = result.unwrap();
assert_eq!(url, download_info.url);
}
#[tokio::test]
async fn test_resolve_chromium_download_url() {
let server = setup_mock_server().await;
let api_client = create_test_api_client(&server);
let downloader = Downloader::new_with_api_client(api_client);
let download_info = DownloadInfo {
url: "https://commondatastorage.googleapis.com/chromium-browser-snapshots/Mac/1465660/chrome-mac.zip".to_string(),
filename: "chromium-test.zip".to_string(),
is_archive: true,
};
let result = downloader
.resolve_download_url(BrowserType::Chromium, "1465660", &download_info)
.await;
assert!(result.is_ok());
let url = result.unwrap();
assert_eq!(url, download_info.url);
}
#[tokio::test]
async fn test_download_browser_with_progress() {
let server = setup_mock_server().await;
let api_client = create_test_api_client(&server);
let downloader = Downloader::new_with_api_client(api_client);
// Create a temporary directory for the test
let temp_dir = TempDir::new().unwrap();
let dest_path = temp_dir.path();
// Create test file content (simulating a small download)
let test_content = b"This is a test file content for download simulation";
// Mock the download endpoint
Mock::given(method("GET"))
.and(path("/test-download"))
.respond_with(
@@ -1357,85 +1139,51 @@ mod tests {
.mount(&server)
.await;
let download_info = DownloadInfo {
url: format!("{}/test-download", server.uri()),
filename: "test-file.dmg".to_string(),
is_archive: true,
};
// Create a mock app handle for testing
let app = tauri::test::mock_app();
let app_handle = app.handle().clone();
let download_url = format!("{}/test-download", server.uri());
let result = downloader
.download_browser(
&app_handle,
BrowserType::Firefox,
"139.0",
&download_info,
dest_path,
None,
)
.download_file(&download_url, dest_path, "test-file.dmg")
.await;
assert!(result.is_ok());
let downloaded_file = result.unwrap();
assert!(downloaded_file.exists());
// Verify file content
let downloaded_content = std::fs::read(&downloaded_file).unwrap();
assert_eq!(downloaded_content, test_content);
}
#[tokio::test]
async fn test_download_browser_network_error() {
let server = setup_mock_server().await;
let api_client = create_test_api_client(&server);
let downloader = Downloader::new_with_api_client(api_client);
async fn test_download_file_network_error() {
let server = MockServer::start().await;
let downloader = Downloader::new_for_test();
let temp_dir = TempDir::new().unwrap();
let dest_path = temp_dir.path();
// Mock a 404 response
Mock::given(method("GET"))
.and(path("/missing-file"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let download_info = DownloadInfo {
url: format!("{}/missing-file", server.uri()),
filename: "missing-file.dmg".to_string(),
is_archive: true,
};
let app = tauri::test::mock_app();
let app_handle = app.handle().clone();
let download_url = format!("{}/missing-file", server.uri());
let result = downloader
.download_browser(
&app_handle,
BrowserType::Firefox,
"139.0",
&download_info,
dest_path,
None,
)
.download_file(&download_url, dest_path, "missing-file.dmg")
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_download_browser_chunked_response() {
let server = setup_mock_server().await;
let api_client = create_test_api_client(&server);
let downloader = Downloader::new_with_api_client(api_client);
async fn test_download_file_chunked_response() {
let server = MockServer::start().await;
let downloader = Downloader::new_for_test();
let temp_dir = TempDir::new().unwrap();
let dest_path = temp_dir.path();
// Create larger test content to simulate chunked transfer
let test_content = vec![42u8; 1024]; // 1KB of data
Mock::given(method("GET"))
@@ -1449,24 +1197,10 @@ mod tests {
.mount(&server)
.await;
let download_info = DownloadInfo {
url: format!("{}/chunked-download", server.uri()),
filename: "chunked-file.dmg".to_string(),
is_archive: true,
};
let app = tauri::test::mock_app();
let app_handle = app.handle().clone();
let download_url = format!("{}/chunked-download", server.uri());
let result = downloader
.download_browser(
&app_handle,
BrowserType::Chromium,
"1465660",
&download_info,
dest_path,
None,
)
.download_file(&download_url, dest_path, "chunked-file.dmg")
.await;
assert!(result.is_ok());
+4 -4
View File
@@ -829,8 +829,8 @@ impl ExtensionManager {
) -> Result<(), Box<dyn std::error::Error>> {
let group = self.get_group(group_id)?;
let browser_type = match browser {
"camoufox" | "firefox" | "firefox-developer" | "zen" => "firefox",
"wayfern" | "chromium" | "brave" => "chromium",
"camoufox" => "firefox",
"wayfern" => "chromium",
_ => return Err(format!("Extensions are not supported for browser '{browser}'").into()),
};
@@ -871,8 +871,8 @@ impl ExtensionManager {
}
let browser_type = match profile.browser.as_str() {
"camoufox" | "firefox" | "firefox-developer" | "zen" => "firefox",
"wayfern" | "chromium" | "brave" => "chromium",
"camoufox" => "firefox",
"wayfern" => "chromium",
_ => return Ok(Vec::new()),
};
+11 -80
View File
@@ -38,12 +38,7 @@ impl Extractor {
"camoufox"
} else if dest_dir.to_string_lossy().contains("wayfern") {
"wayfern"
} else if dest_dir.to_string_lossy().contains("firefox") {
"firefox"
} else if dest_dir.to_string_lossy().contains("zen") {
"zen"
} else {
// For other browsers, assume the structure is already correct
return Ok(());
};
@@ -739,57 +734,19 @@ impl Extractor {
dest_dir: &Path,
browser_type: BrowserType,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
match browser_type {
BrowserType::Zen => {
// Zen installer EXE needs to be run to install
#[cfg(target_os = "windows")]
{
self.install_zen_windows(exe_path, dest_dir).await
}
#[cfg(not(target_os = "windows"))]
{
Err("Zen EXE installation is only supported on Windows".into())
}
}
_ => {
// For other browsers (Firefox, TOR, etc.), the EXE is typically just copied
let exe_name = exe_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("browser.exe");
{
let _ = browser_type;
let exe_name = exe_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("browser.exe");
let dest_path = dest_dir.join(exe_name);
fs::copy(exe_path, &dest_path)?;
Ok(dest_path)
}
let dest_path = dest_dir.join(exe_name);
fs::copy(exe_path, &dest_path)?;
Ok(dest_path)
}
}
#[cfg(target_os = "windows")]
async fn install_zen_windows(
&self,
installer_path: &Path,
dest_dir: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
// For Zen installer, we need to run it silently
let output = Command::new(installer_path)
.args(["/S", &format!("/D={}", dest_dir.display())])
.output()?;
if !output.status.success() {
return Err(
format!(
"Failed to install Zen: {}",
String::from_utf8_lossy(&output.stderr)
)
.into(),
);
}
// Find the installed executable
self.find_extracted_executable(dest_dir).await
}
fn flatten_single_directory_archive(
&self,
dest_dir: &Path,
@@ -954,8 +911,6 @@ impl Extractor {
"firefox.exe",
"chrome.exe",
"chromium.exe",
"zen.exe",
"brave.exe",
"camoufox.exe",
"wayfern.exe",
];
@@ -1023,8 +978,6 @@ impl Extractor {
if file_name.contains("firefox")
|| file_name.contains("chrome")
|| file_name.contains("chromium")
|| file_name.contains("zen")
|| file_name.contains("brave")
|| file_name.contains("browser")
|| file_name.contains("camoufox")
|| file_name.contains("wayfern")
@@ -1075,31 +1028,14 @@ impl Extractor {
// Enhanced list of common browser executable names
let exe_names = [
// Firefox variants
// Firefox variants (used by Camoufox)
"firefox",
"firefox-bin",
"firefox-esr",
"firefox-trunk",
// Chrome/Chromium variants
// Chrome/Chromium variants (used by Wayfern)
"chrome",
"google-chrome",
"google-chrome-stable",
"google-chrome-beta",
"google-chrome-unstable",
"chromium",
"chromium-browser",
"chromium-bin",
// Zen Browser
"zen",
"zen-browser",
"zen-bin",
// Brave variants
"brave",
"brave-browser",
"brave-browser-stable",
"brave-browser-beta",
"brave-browser-dev",
"brave-bin",
// Camoufox variants
"camoufox",
"camoufox-bin",
@@ -1130,17 +1066,12 @@ impl Extractor {
"firefox",
"chrome",
"chromium",
"brave",
"zen",
"camoufox",
"wayfern",
".",
"./",
"firefox",
"Browser",
"browser",
"opt/google/chrome",
"opt/brave.com/brave",
"opt/camoufox",
"usr/lib/firefox",
"usr/lib/chromium",
+10 -33
View File
@@ -1068,41 +1068,18 @@ pub fn run() {
version_updater::VersionUpdater::run_background_task().await;
});
// TODO(v0.17+): Remove this migration block after a few releases.
// Migrate proxy/VPN worker configs from old proxies/ dir to new proxy_workers/ cache dir.
// Before v0.16, ephemeral worker configs (proxy_*, vpnw_*) lived alongside persistent
// StoredProxy files in proxies/. Now they live in cache_dir/proxy_workers/.
// Auto-start MCP server if it was previously enabled
{
let old_dir = crate::app_dirs::proxies_dir();
let new_dir = crate::app_dirs::proxy_workers_dir();
if old_dir.exists() {
if let Err(e) = std::fs::create_dir_all(&new_dir) {
log::error!("Failed to create proxy_workers dir: {e}");
} else if let Ok(entries) = std::fs::read_dir(&old_dir) {
for entry in entries.flatten() {
let path = entry.path();
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if (name.starts_with("proxy_") || name.starts_with("vpnw_"))
&& name.ends_with(".json")
{
let dest = new_dir.join(name);
match std::fs::rename(&path, &dest) {
Ok(()) => log::info!("Migrated worker config {name} to proxy_workers/"),
Err(e) => {
// rename fails across filesystems, fall back to copy+delete
if let Ok(content) = std::fs::read(&path) {
if std::fs::write(&dest, &content).is_ok() {
let _ = std::fs::remove_file(&path);
log::info!("Migrated worker config {name} to proxy_workers/ (copy)");
}
} else {
log::warn!("Failed to migrate worker config {name}: {e}");
}
}
}
}
let mcp_handle = app.handle().clone();
let settings_mgr = settings_manager::SettingsManager::instance();
if let Ok(settings) = settings_mgr.load_settings() {
if settings.mcp_enabled {
tauri::async_runtime::spawn(async move {
match mcp_server::McpServer::instance().start(mcp_handle).await {
Ok(port) => log::info!("MCP server auto-started on port {port}"),
Err(e) => log::warn!("Failed to auto-start MCP server: {e}"),
}
}
});
}
}
}
+778 -4
View File
@@ -1,5 +1,3 @@
#![allow(dead_code)]
use axum::{
body::Body,
extract::State,
@@ -833,6 +831,161 @@ impl McpServer {
"required": ["profile_id"]
}),
},
// Browser interaction tools
McpTool {
name: "navigate".to_string(),
description: "Navigate a running browser profile to a URL".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the running profile"
},
"url": {
"type": "string",
"description": "The URL to navigate to"
}
},
"required": ["profile_id", "url"]
}),
},
McpTool {
name: "screenshot".to_string(),
description: "Take a screenshot of the current page in a running browser profile. Returns base64-encoded image."
.to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the running profile"
},
"format": {
"type": "string",
"enum": ["png", "jpeg", "webp"],
"description": "Image format (default: png)"
},
"quality": {
"type": "integer",
"description": "Image quality 0-100 for jpeg/webp (default: 80)"
},
"full_page": {
"type": "boolean",
"description": "Capture the full scrollable page (default: false)"
}
},
"required": ["profile_id"]
}),
},
McpTool {
name: "evaluate_javascript".to_string(),
description:
"Execute JavaScript in the context of the current page and return the result. Works with both static and dynamically-generated content."
.to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the running profile"
},
"expression": {
"type": "string",
"description": "JavaScript expression to evaluate"
},
"await_promise": {
"type": "boolean",
"description": "Whether to await the result if it's a Promise (default: false)"
}
},
"required": ["profile_id", "expression"]
}),
},
McpTool {
name: "click_element".to_string(),
description: "Click on an element identified by a CSS selector".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the running profile"
},
"selector": {
"type": "string",
"description": "CSS selector for the element to click"
}
},
"required": ["profile_id", "selector"]
}),
},
McpTool {
name: "type_text".to_string(),
description: "Focus an element by CSS selector and type text into it".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the running profile"
},
"selector": {
"type": "string",
"description": "CSS selector for the input element"
},
"text": {
"type": "string",
"description": "Text to type into the element"
},
"clear_first": {
"type": "boolean",
"description": "Clear the input before typing (default: true)"
}
},
"required": ["profile_id", "selector", "text"]
}),
},
McpTool {
name: "get_page_content".to_string(),
description:
"Get the content of the current page. Works with both static HTML and JavaScript-rendered content."
.to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the running profile"
},
"format": {
"type": "string",
"enum": ["html", "text"],
"description": "Content format: 'html' for full HTML, 'text' for visible text only (default: text)"
},
"selector": {
"type": "string",
"description": "Optional CSS selector to get content of a specific element instead of the whole page"
}
},
"required": ["profile_id"]
}),
},
McpTool {
name: "get_page_info".to_string(),
description: "Get metadata about the current page including URL, title, and readiness state"
.to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the running profile"
}
},
"required": ["profile_id"]
}),
},
]
}
@@ -953,6 +1106,14 @@ impl McpServer {
// Team lock tools
"get_team_locks" => self.handle_get_team_locks().await,
"get_team_lock_status" => self.handle_get_team_lock_status(&arguments).await,
// Browser interaction tools
"navigate" => self.handle_navigate(&arguments).await,
"screenshot" => self.handle_screenshot(&arguments).await,
"evaluate_javascript" => self.handle_evaluate_javascript(&arguments).await,
"click_element" => self.handle_click_element(&arguments).await,
"type_text" => self.handle_type_text(&arguments).await,
"get_page_content" => self.handle_get_page_content(&arguments).await,
"get_page_info" => self.handle_get_page_info(&arguments).await,
_ => Err(McpError {
code: -32602,
message: format!("Unknown tool: {tool_name}"),
@@ -2469,6 +2630,611 @@ impl McpServer {
}]
}))
}
// --- CDP utility methods for browser interaction ---
async fn get_cdp_port_for_profile(&self, profile: &BrowserProfile) -> Result<u16, McpError> {
let profiles_dir = ProfileManager::instance().get_profiles_dir();
let profile_path = profile.get_profile_data_path(&profiles_dir);
let profile_path_str = profile_path.to_string_lossy();
let port = if profile.browser == "wayfern" {
crate::wayfern_manager::WayfernManager::instance()
.get_cdp_port(&profile_path_str)
.await
} else if profile.browser == "camoufox" {
crate::camoufox_manager::CamoufoxManager::instance()
.get_cdp_port(&profile_path_str)
.await
} else {
None
};
port.ok_or_else(|| McpError {
code: -32000,
message: format!(
"No CDP connection available for profile '{}'. Make sure the browser is running.",
profile.name
),
})
}
async fn get_cdp_ws_url(&self, port: u16) -> Result<String, McpError> {
let url = format!("http://127.0.0.1:{port}/json");
let client = reqwest::Client::new();
let resp = client
.get(&url)
.timeout(std::time::Duration::from_secs(5))
.send()
.await
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to connect to browser CDP endpoint: {e}"),
})?;
let targets: Vec<serde_json::Value> = resp.json().await.map_err(|e| McpError {
code: -32000,
message: format!("Failed to parse CDP targets: {e}"),
})?;
targets
.iter()
.find(|t| t.get("type").and_then(|v| v.as_str()) == Some("page"))
.and_then(|t| t.get("webSocketDebuggerUrl"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| McpError {
code: -32000,
message: "No page target found in browser".to_string(),
})
}
async fn send_cdp(
&self,
ws_url: &str,
method: &str,
params: serde_json::Value,
) -> Result<serde_json::Value, McpError> {
use futures_util::sink::SinkExt;
use futures_util::stream::StreamExt;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message;
let (mut ws_stream, _) = connect_async(ws_url).await.map_err(|e| McpError {
code: -32000,
message: format!("Failed to connect to CDP WebSocket: {e}"),
})?;
let command = serde_json::json!({
"id": 1,
"method": method,
"params": params
});
ws_stream
.send(Message::Text(command.to_string().into()))
.await
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to send CDP command: {e}"),
})?;
while let Some(msg) = ws_stream.next().await {
let msg = msg.map_err(|e| McpError {
code: -32000,
message: format!("CDP WebSocket error: {e}"),
})?;
if let Message::Text(text) = msg {
let response: serde_json::Value =
serde_json::from_str(text.as_str()).map_err(|e| McpError {
code: -32000,
message: format!("Failed to parse CDP response: {e}"),
})?;
if response.get("id") == Some(&serde_json::json!(1)) {
if let Some(error) = response.get("error") {
return Err(McpError {
code: -32000,
message: format!("CDP error: {error}"),
});
}
return Ok(
response
.get("result")
.cloned()
.unwrap_or(serde_json::json!({})),
);
}
}
}
Err(McpError {
code: -32000,
message: "No response received from CDP".to_string(),
})
}
fn get_running_profile(&self, profile_id: &str) -> Result<BrowserProfile, McpError> {
let profiles = ProfileManager::instance()
.list_profiles()
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to list profiles: {e}"),
})?;
let profile = profiles
.into_iter()
.find(|p| p.id.to_string() == profile_id)
.ok_or_else(|| McpError {
code: -32000,
message: format!("Profile not found: {profile_id}"),
})?;
if profile.browser != "wayfern" && profile.browser != "camoufox" {
return Err(McpError {
code: -32000,
message: "MCP only supports Wayfern and Camoufox profiles".to_string(),
});
}
if profile.process_id.is_none() {
return Err(McpError {
code: -32000,
message: format!("Profile '{}' is not running", profile.name),
});
}
Ok(profile)
}
// --- Browser interaction handlers ---
async fn handle_navigate(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing profile_id".to_string(),
})?;
let url = arguments
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing url".to_string(),
})?;
let profile = self.get_running_profile(profile_id)?;
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
let ws_url = self.get_cdp_ws_url(cdp_port).await?;
self
.send_cdp(&ws_url, "Page.navigate", serde_json::json!({ "url": url }))
.await?;
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!("Navigated to {url}")
}]
}))
}
async fn handle_screenshot(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing profile_id".to_string(),
})?;
let format = arguments
.get("format")
.and_then(|v| v.as_str())
.unwrap_or("png");
let quality = arguments.get("quality").and_then(|v| v.as_i64());
let full_page = arguments
.get("full_page")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let profile = self.get_running_profile(profile_id)?;
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
let ws_url = self.get_cdp_ws_url(cdp_port).await?;
let mut params = serde_json::json!({ "format": format });
if let Some(q) = quality {
params["quality"] = serde_json::json!(q);
}
if full_page {
let layout = self
.send_cdp(&ws_url, "Page.getLayoutMetrics", serde_json::json!({}))
.await?;
if let Some(content_size) = layout.get("contentSize") {
params["clip"] = serde_json::json!({
"x": 0,
"y": 0,
"width": content_size.get("width").and_then(|v| v.as_f64()).unwrap_or(1920.0),
"height": content_size.get("height").and_then(|v| v.as_f64()).unwrap_or(1080.0),
"scale": 1
});
params["captureBeyondViewport"] = serde_json::json!(true);
}
}
let result = self
.send_cdp(&ws_url, "Page.captureScreenshot", params)
.await?;
let data = result
.get("data")
.and_then(|v| v.as_str())
.unwrap_or_default();
Ok(serde_json::json!({
"content": [{
"type": "image",
"data": data,
"mimeType": format!("image/{format}")
}]
}))
}
async fn handle_evaluate_javascript(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing profile_id".to_string(),
})?;
let expression = arguments
.get("expression")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing expression".to_string(),
})?;
let await_promise = arguments
.get("await_promise")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let profile = self.get_running_profile(profile_id)?;
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
let ws_url = self.get_cdp_ws_url(cdp_port).await?;
let result = self
.send_cdp(
&ws_url,
"Runtime.evaluate",
serde_json::json!({
"expression": expression,
"returnByValue": true,
"awaitPromise": await_promise,
}),
)
.await?;
let value = if let Some(exception) = result.get("exceptionDetails") {
let text = exception
.get("text")
.or_else(|| {
exception
.get("exception")
.and_then(|e| e.get("description"))
})
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
serde_json::json!({ "error": text })
} else if let Some(r) = result.get("result") {
let val = r.get("value").cloned().unwrap_or(serde_json::json!(null));
serde_json::json!({ "value": val, "type": r.get("type") })
} else {
serde_json::json!({ "value": null })
};
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": serde_json::to_string_pretty(&value).unwrap_or_default()
}]
}))
}
async fn handle_click_element(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing profile_id".to_string(),
})?;
let selector = arguments
.get("selector")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing selector".to_string(),
})?;
let profile = self.get_running_profile(profile_id)?;
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
let ws_url = self.get_cdp_ws_url(cdp_port).await?;
let selector_escaped = selector.replace('\\', "\\\\").replace('\'', "\\'");
let js = format!(
r#"(() => {{
const el = document.querySelector('{}');
if (!el) throw new Error('Element not found: {}');
el.scrollIntoView({{block: 'center'}});
el.click();
return true;
}})()"#,
selector_escaped, selector_escaped
);
let result = self
.send_cdp(
&ws_url,
"Runtime.evaluate",
serde_json::json!({
"expression": js,
"returnByValue": true,
}),
)
.await?;
if let Some(exception) = result.get("exceptionDetails") {
let msg = exception
.get("exception")
.and_then(|e| e.get("description"))
.or_else(|| exception.get("text"))
.and_then(|v| v.as_str())
.unwrap_or("Click failed");
return Err(McpError {
code: -32000,
message: msg.to_string(),
});
}
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!("Clicked element: {selector}")
}]
}))
}
async fn handle_type_text(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing profile_id".to_string(),
})?;
let selector = arguments
.get("selector")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing selector".to_string(),
})?;
let text = arguments
.get("text")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing text".to_string(),
})?;
let clear_first = arguments
.get("clear_first")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let profile = self.get_running_profile(profile_id)?;
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
let ws_url = self.get_cdp_ws_url(cdp_port).await?;
let selector_escaped = selector.replace('\\', "\\\\").replace('\'', "\\'");
let focus_js = if clear_first {
format!(
r#"(() => {{
const el = document.querySelector('{}');
if (!el) throw new Error('Element not found: {}');
el.scrollIntoView({{block: 'center'}});
el.focus();
el.value = '';
el.dispatchEvent(new Event('input', {{bubbles: true}}));
return true;
}})()"#,
selector_escaped, selector_escaped
)
} else {
format!(
r#"(() => {{
const el = document.querySelector('{}');
if (!el) throw new Error('Element not found: {}');
el.scrollIntoView({{block: 'center'}});
el.focus();
return true;
}})()"#,
selector_escaped, selector_escaped
)
};
let focus_result = self
.send_cdp(
&ws_url,
"Runtime.evaluate",
serde_json::json!({
"expression": focus_js,
"returnByValue": true,
}),
)
.await?;
if let Some(exception) = focus_result.get("exceptionDetails") {
let msg = exception
.get("exception")
.and_then(|e| e.get("description"))
.or_else(|| exception.get("text"))
.and_then(|v| v.as_str())
.unwrap_or("Focus failed");
return Err(McpError {
code: -32000,
message: msg.to_string(),
});
}
self
.send_cdp(
&ws_url,
"Input.insertText",
serde_json::json!({ "text": text }),
)
.await?;
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!("Typed text into element: {selector}")
}]
}))
}
async fn handle_get_page_content(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing profile_id".to_string(),
})?;
let format = arguments
.get("format")
.and_then(|v| v.as_str())
.unwrap_or("text");
let selector = arguments.get("selector").and_then(|v| v.as_str());
let profile = self.get_running_profile(profile_id)?;
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
let ws_url = self.get_cdp_ws_url(cdp_port).await?;
let js = if let Some(sel) = selector {
let sel_escaped = sel.replace('\\', "\\\\").replace('\'', "\\'");
if format == "html" {
format!(
r#"(() => {{
const el = document.querySelector('{}');
return el ? el.outerHTML : null;
}})()"#,
sel_escaped
)
} else {
format!(
r#"(() => {{
const el = document.querySelector('{}');
return el ? el.innerText : null;
}})()"#,
sel_escaped
)
}
} else if format == "html" {
"document.documentElement.outerHTML".to_string()
} else {
"document.body.innerText".to_string()
};
let result = self
.send_cdp(
&ws_url,
"Runtime.evaluate",
serde_json::json!({
"expression": js,
"returnByValue": true,
}),
)
.await?;
let content = result
.get("result")
.and_then(|r| r.get("value"))
.and_then(|v| v.as_str())
.unwrap_or("");
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": content
}]
}))
}
async fn handle_get_page_info(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing profile_id".to_string(),
})?;
let profile = self.get_running_profile(profile_id)?;
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
let ws_url = self.get_cdp_ws_url(cdp_port).await?;
let result = self
.send_cdp(
&ws_url,
"Runtime.evaluate",
serde_json::json!({
"expression": "JSON.stringify({url: location.href, title: document.title, readyState: document.readyState})",
"returnByValue": true,
}),
)
.await?;
let info_str = result
.get("result")
.and_then(|r| r.get("value"))
.and_then(|v| v.as_str())
.unwrap_or("{}");
let info: serde_json::Value = serde_json::from_str(info_str).unwrap_or(serde_json::json!({}));
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": serde_json::to_string_pretty(&info).unwrap_or_default()
}]
}))
}
}
lazy_static::lazy_static! {
@@ -2484,8 +3250,8 @@ mod tests {
let server = McpServer::new();
let tools = server.get_tools();
// Should have at least 34 tools (26 + 6 extension tools + 2 team lock tools)
assert!(tools.len() >= 34);
// Should have at least 41 tools (34 + 7 browser interaction tools)
assert!(tools.len() >= 41);
// Check tool names
let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
@@ -2532,6 +3298,14 @@ mod tests {
// Team lock tools
assert!(tool_names.contains(&"get_team_locks"));
assert!(tool_names.contains(&"get_team_lock_status"));
// Browser interaction tools
assert!(tool_names.contains(&"navigate"));
assert!(tool_names.contains(&"screenshot"));
assert!(tool_names.contains(&"evaluate_javascript"));
assert!(tool_names.contains(&"click_element"));
assert!(tool_names.contains(&"type_text"));
assert!(tool_names.contains(&"get_page_content"));
assert!(tool_names.contains(&"get_page_info"));
}
#[test]
+3
View File
@@ -5,6 +5,7 @@ use std::process::Command;
// Platform-specific modules
#[cfg(target_os = "macos")]
#[allow(dead_code)]
pub mod macos {
use super::*;
use sysinfo::{Pid, System};
@@ -468,6 +469,7 @@ end try
}
#[cfg(target_os = "windows")]
#[allow(dead_code)]
pub mod windows {
use super::*;
@@ -680,6 +682,7 @@ pub mod windows {
}
#[cfg(target_os = "linux")]
#[allow(dead_code)]
pub mod linux {
use super::*;
+8 -23
View File
@@ -1242,10 +1242,7 @@ impl ProfileManager {
let profile_path_match = cmd.iter().any(|s| {
let arg = s.to_str().unwrap_or("");
// For Firefox-based browsers, check for exact profile path match
if profile.browser == "firefox"
|| profile.browser == "firefox-developer"
|| profile.browser == "zen"
{
if profile.browser == "camoufox" {
arg == profile_data_path_str
|| arg == format!("-profile={profile_data_path_str}")
|| (arg == "-profile"
@@ -1253,7 +1250,7 @@ impl ProfileManager {
.iter()
.any(|s2| s2.to_str().unwrap_or("") == profile_data_path_str))
} else {
// For Chromium-based browsers, check for user-data-dir
// For Chromium-based browsers (Wayfern), check for user-data-dir
arg.contains(&format!("--user-data-dir={profile_data_path_str}"))
|| arg == profile_data_path_str
}
@@ -1262,7 +1259,6 @@ impl ProfileManager {
if profile_path_match {
is_running = true;
found_pid = Some(pid);
// Found existing browser process
}
}
}
@@ -1275,16 +1271,12 @@ impl ProfileManager {
// Check if this is the right browser executable first
let exe_name = process.name().to_string_lossy().to_lowercase();
let is_correct_browser = match profile.browser.as_str() {
"firefox" => {
exe_name.contains("firefox")
&& !exe_name.contains("developer")
&& !exe_name.contains("camoufox")
"camoufox" => exe_name.contains("camoufox") || exe_name.contains("firefox"),
"wayfern" => {
exe_name.contains("wayfern")
|| exe_name.contains("chromium")
|| exe_name.contains("chrome")
}
"firefox-developer" => exe_name.contains("firefox") && exe_name.contains("developer"),
"zen" => exe_name.contains("zen"),
"chromium" => exe_name.contains("chromium"),
"brave" => exe_name.contains("brave"),
// Camoufox is handled via CamoufoxManager, not PID-based checking
_ => false,
};
@@ -1300,13 +1292,6 @@ impl ProfileManager {
let arg = s.to_str().unwrap_or("");
// For Firefox-based browsers, check for exact profile path match
if profile.browser == "camoufox" {
// Camoufox uses user_data_dir like Chromium browsers
arg.contains(&format!("--user-data-dir={profile_data_path_str}"))
|| arg == profile_data_path_str
} else if profile.browser == "firefox"
|| profile.browser == "firefox-developer"
|| profile.browser == "zen"
{
arg == profile_data_path_str
|| arg == format!("-profile={profile_data_path_str}")
|| (arg == "-profile"
@@ -1314,7 +1299,7 @@ impl ProfileManager {
.iter()
.any(|s2| s2.to_str().unwrap_or("") == profile_data_path_str))
} else {
// For Chromium-based browsers, check for user-data-dir
// For Chromium-based browsers (Wayfern), check for user-data-dir
arg.contains(&format!("--user-data-dir={profile_data_path_str}"))
|| arg == profile_data_path_str
}
+295 -91
View File
@@ -4,22 +4,38 @@ use std::collections::HashSet;
use std::fs::{self, create_dir_all};
use std::path::{Path, PathBuf};
use crate::browser::BrowserType;
use crate::camoufox_manager::CamoufoxConfig;
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
use crate::profile::types::{get_host_os, BrowserProfile, SyncMode};
use crate::profile::ProfileManager;
use crate::proxy_manager::PROXY_MANAGER;
use crate::wayfern_manager::WayfernConfig;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DetectedProfile {
pub browser: String,
pub mapped_browser: String,
pub name: String,
pub path: String,
pub description: String,
}
fn map_browser_type(browser: &str) -> &str {
match browser {
"firefox" | "firefox-developer" | "zen" => "camoufox",
"chromium" | "brave" => "wayfern",
"camoufox" => "camoufox",
"wayfern" => "wayfern",
_ => "wayfern",
}
}
pub struct ProfileImporter {
base_dirs: BaseDirs,
downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
profile_manager: &'static ProfileManager,
camoufox_manager: &'static crate::camoufox_manager::CamoufoxManager,
wayfern_manager: &'static crate::wayfern_manager::WayfernManager,
}
impl ProfileImporter {
@@ -28,6 +44,8 @@ impl ProfileImporter {
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
downloaded_browsers_registry: DownloadedBrowsersRegistry::instance(),
profile_manager: ProfileManager::instance(),
camoufox_manager: crate::camoufox_manager::CamoufoxManager::instance(),
wayfern_manager: crate::wayfern_manager::WayfernManager::instance(),
}
}
@@ -35,31 +53,18 @@ impl ProfileImporter {
&PROFILE_IMPORTER
}
/// Detect existing browser profiles on the system
pub fn detect_existing_profiles(
&self,
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut detected_profiles = Vec::new();
// Detect Firefox profiles
detected_profiles.extend(self.detect_firefox_profiles()?);
// Detect Chrome profiles
detected_profiles.extend(self.detect_chrome_profiles()?);
// Detect Brave profiles
detected_profiles.extend(self.detect_brave_profiles()?);
// Detect Firefox Developer Edition profiles
detected_profiles.extend(self.detect_firefox_developer_profiles()?);
// Detect Chromium profiles
detected_profiles.extend(self.detect_chromium_profiles()?);
// Detect Zen Browser profiles
detected_profiles.extend(self.detect_zen_browser_profiles()?);
// Remove duplicates based on path
let mut seen_paths = HashSet::new();
let unique_profiles: Vec<DetectedProfile> = detected_profiles
.into_iter()
@@ -69,7 +74,6 @@ impl ProfileImporter {
Ok(unique_profiles)
}
/// Detect Firefox profiles
fn detect_firefox_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
@@ -84,12 +88,10 @@ impl ProfileImporter {
#[cfg(target_os = "windows")]
{
// Primary location in AppData\Roaming
let app_data = self.base_dirs.data_dir();
let firefox_dir = app_data.join("Mozilla/Firefox/Profiles");
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?);
// Also check AppData\Local for portable installations
let local_app_data = self.base_dirs.data_local_dir();
let firefox_local_dir = local_app_data.join("Mozilla/Firefox/Profiles");
if firefox_local_dir.exists() {
@@ -106,7 +108,6 @@ impl ProfileImporter {
Ok(profiles)
}
/// Detect Firefox Developer Edition profiles
fn detect_firefox_developer_profiles(
&self,
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
@@ -114,13 +115,11 @@ impl ProfileImporter {
#[cfg(target_os = "macos")]
{
// Firefox Developer Edition on macOS uses separate profile directories
let firefox_dev_alt_dir = self
.base_dirs
.home_dir()
.join("Library/Application Support/Firefox Developer Edition/Profiles");
// Only scan the dedicated dev edition directory if it exists, otherwise skip to avoid duplicates
if firefox_dev_alt_dir.exists() {
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_alt_dir, "firefox-developer")?);
}
@@ -129,7 +128,6 @@ impl ProfileImporter {
#[cfg(target_os = "windows")]
{
let app_data = self.base_dirs.data_dir();
// Firefox Developer Edition on Windows typically uses separate directories
let firefox_dev_dir = app_data.join("Mozilla/Firefox Developer Edition/Profiles");
if firefox_dev_dir.exists() {
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_dir, "firefox-developer")?);
@@ -138,7 +136,6 @@ impl ProfileImporter {
#[cfg(target_os = "linux")]
{
// Firefox Developer Edition on Linux uses separate directories
let firefox_dev_dir = self
.base_dirs
.home_dir()
@@ -151,7 +148,6 @@ impl ProfileImporter {
Ok(profiles)
}
/// Detect Chrome profiles
fn detect_chrome_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
@@ -180,7 +176,6 @@ impl ProfileImporter {
Ok(profiles)
}
/// Detect Chromium profiles
fn detect_chromium_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
@@ -209,7 +204,6 @@ impl ProfileImporter {
Ok(profiles)
}
/// Detect Brave profiles
fn detect_brave_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
let mut profiles = Vec::new();
@@ -241,7 +235,6 @@ impl ProfileImporter {
Ok(profiles)
}
/// Detect Zen Browser profiles
fn detect_zen_browser_profiles(
&self,
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
@@ -272,7 +265,6 @@ impl ProfileImporter {
Ok(profiles)
}
/// Scan Firefox-style profiles directory
fn scan_firefox_profiles_dir(
&self,
profiles_dir: &Path,
@@ -284,7 +276,6 @@ impl ProfileImporter {
return Ok(profiles);
}
// Read profiles.ini file if it exists
let profiles_ini = profiles_dir
.parent()
.unwrap_or(profiles_dir)
@@ -295,7 +286,6 @@ impl ProfileImporter {
}
}
// Also scan directory for any profile folders not in profiles.ini
if let Ok(entries) = fs::read_dir(profiles_dir) {
for entry in entries.flatten() {
let path = entry.path();
@@ -307,11 +297,11 @@ impl ProfileImporter {
.and_then(|n| n.to_str())
.unwrap_or("Unknown Profile");
// Check if this profile was already found in profiles.ini
let already_added = profiles.iter().any(|p| p.path == path.to_string_lossy());
if !already_added {
profiles.push(DetectedProfile {
browser: browser_type.to_string(),
mapped_browser: map_browser_type(browser_type).to_string(),
name: format!(
"{} Profile - {}",
self.get_browser_display_name(browser_type),
@@ -329,7 +319,6 @@ impl ProfileImporter {
Ok(profiles)
}
/// Parse Firefox profiles.ini file
fn parse_firefox_profiles_ini(
&self,
content: &str,
@@ -346,7 +335,6 @@ impl ProfileImporter {
let line = line.trim();
if line.starts_with('[') && line.ends_with(']') {
// Save previous profile if complete
if !current_section.is_empty()
&& current_section.starts_with("Profile")
&& !profile_path.is_empty()
@@ -370,6 +358,7 @@ impl ProfileImporter {
profiles.push(DetectedProfile {
browser: browser_type.to_string(),
mapped_browser: map_browser_type(browser_type).to_string(),
name: display_name,
path: full_path.to_string_lossy().to_string(),
description: format!("Profile: {profile_name}"),
@@ -377,7 +366,6 @@ impl ProfileImporter {
}
}
// Start new section
current_section = line[1..line.len() - 1].to_string();
profile_name.clear();
profile_path.clear();
@@ -398,7 +386,6 @@ impl ProfileImporter {
}
}
// Handle last profile
if !current_section.is_empty()
&& current_section.starts_with("Profile")
&& !profile_path.is_empty()
@@ -422,6 +409,7 @@ impl ProfileImporter {
profiles.push(DetectedProfile {
browser: browser_type.to_string(),
mapped_browser: map_browser_type(browser_type).to_string(),
name: display_name,
path: full_path.to_string_lossy().to_string(),
description: format!("Profile: {profile_name}"),
@@ -432,7 +420,6 @@ impl ProfileImporter {
Ok(profiles)
}
/// Scan Chrome-style profiles directory
fn scan_chrome_profiles_dir(
&self,
browser_dir: &Path,
@@ -444,11 +431,11 @@ impl ProfileImporter {
return Ok(profiles);
}
// Check for Default profile
let default_profile = browser_dir.join("Default");
if default_profile.exists() && default_profile.join("Preferences").exists() {
profiles.push(DetectedProfile {
browser: browser_type.to_string(),
mapped_browser: map_browser_type(browser_type).to_string(),
name: format!(
"{} - Default Profile",
self.get_browser_display_name(browser_type)
@@ -458,7 +445,6 @@ impl ProfileImporter {
});
}
// Check for Profile X directories
if let Ok(entries) = fs::read_dir(browser_dir) {
for entry in entries.flatten() {
let path = entry.path();
@@ -466,9 +452,10 @@ impl ProfileImporter {
let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if dir_name.starts_with("Profile ") && path.join("Preferences").exists() {
let profile_number = &dir_name[8..]; // Remove "Profile " prefix
let profile_number = &dir_name[8..];
profiles.push(DetectedProfile {
browser: browser_type.to_string(),
mapped_browser: map_browser_type(browser_type).to_string(),
name: format!(
"{} - Profile {}",
self.get_browser_display_name(browser_type),
@@ -485,7 +472,6 @@ impl ProfileImporter {
Ok(profiles)
}
/// Get browser display name
fn get_browser_display_name(&self, browser_type: &str) -> &str {
match browser_type {
"firefox" => "Firefox",
@@ -493,28 +479,36 @@ impl ProfileImporter {
"chromium" => "Chrome/Chromium",
"brave" => "Brave",
"zen" => "Zen Browser",
"camoufox" => "Camoufox",
"wayfern" => "Wayfern",
_ => "Unknown Browser",
}
}
/// Import a profile from an existing browser profile
pub fn import_profile(
#[allow(clippy::too_many_arguments)]
pub async fn import_profile(
&self,
app_handle: &tauri::AppHandle,
source_path: &str,
browser_type: &str,
new_profile_name: &str,
proxy_id: Option<String>,
camoufox_config: Option<CamoufoxConfig>,
wayfern_config: Option<WayfernConfig>,
) -> Result<(), Box<dyn std::error::Error>> {
// Validate that source path exists
let source_path = Path::new(source_path);
if !source_path.exists() {
return Err("Source profile path does not exist".into());
}
// Validate browser type
let _browser_type = BrowserType::from_str(browser_type)
.map_err(|_| format!("Invalid browser type: {browser_type}"))?;
let mapped = map_browser_type(browser_type);
if let Some(ref pid) = proxy_id {
if PROXY_MANAGER.is_cloud_or_derived(pid) || pid == crate::proxy_manager::CLOUD_PROXY_ID {
crate::cloud_auth::CLOUD_AUTH.sync_cloud_proxy().await;
}
}
// Check if a profile with this name already exists
let existing_profiles = self.profile_manager.list_profiles()?;
if existing_profiles
.iter()
@@ -523,7 +517,6 @@ impl ProfileImporter {
return Err(format!("Profile with name '{new_profile_name}' already exists").into());
}
// Generate UUID for new profile and create the directory structure
let profile_id = uuid::Uuid::new_v4();
let profiles_dir = self.profile_manager.get_profiles_dir();
let new_profile_uuid_dir = profiles_dir.join(profile_id.to_string());
@@ -532,32 +525,227 @@ impl ProfileImporter {
create_dir_all(&new_profile_uuid_dir)?;
create_dir_all(&new_profile_data_dir)?;
// Copy all files from source to destination profile subdirectory
Self::copy_directory_recursive(source_path, &new_profile_data_dir)?;
// Create the profile metadata without overwriting the imported data
// We need to find a suitable version for this browser type
let available_versions = self.get_default_version_for_browser(browser_type)?;
let version = self.get_default_version_for_browser(mapped)?;
let profile = crate::profile::BrowserProfile {
let final_camoufox_config = if mapped == "camoufox" {
let mut config = camoufox_config.unwrap_or_default();
if config.executable_path.is_none() {
let mut browser_dir = self.profile_manager.get_binaries_dir();
browser_dir.push(mapped);
browser_dir.push(&version);
#[cfg(target_os = "macos")]
let binary_path = browser_dir
.join("Camoufox.app")
.join("Contents")
.join("MacOS")
.join("camoufox");
#[cfg(target_os = "windows")]
let binary_path = browser_dir.join("camoufox.exe");
#[cfg(target_os = "linux")]
let binary_path = browser_dir.join("camoufox");
config.executable_path = Some(binary_path.to_string_lossy().to_string());
}
if let Some(ref proxy_id_val) = proxy_id {
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_val) {
let proxy_url = if let (Some(username), Some(password)) =
(&proxy_settings.username, &proxy_settings.password)
{
format!(
"{}://{}:{}@{}:{}",
proxy_settings.proxy_type.to_lowercase(),
username,
password,
proxy_settings.host,
proxy_settings.port
)
} else {
format!(
"{}://{}:{}",
proxy_settings.proxy_type.to_lowercase(),
proxy_settings.host,
proxy_settings.port
)
};
config.proxy = Some(proxy_url);
}
}
if config.fingerprint.is_none() {
let temp_profile = BrowserProfile {
id: uuid::Uuid::new_v4(),
name: new_profile_name.to_string(),
browser: mapped.to_string(),
version: version.clone(),
proxy_id: proxy_id.clone(),
vpn_id: None,
process_id: None,
last_launch: None,
release_type: "stable".to_string(),
camoufox_config: None,
wayfern_config: None,
group_id: None,
tags: Vec::new(),
note: None,
sync_mode: SyncMode::Disabled,
encryption_salt: None,
last_sync: None,
host_os: None,
ephemeral: false,
extension_group_id: None,
proxy_bypass_rules: Vec::new(),
created_by_id: None,
created_by_email: None,
};
match self
.camoufox_manager
.generate_fingerprint_config(app_handle, &temp_profile, &config)
.await
{
Ok(fp) => config.fingerprint = Some(fp),
Err(e) => {
return Err(
format!(
"Failed to generate fingerprint for imported profile '{new_profile_name}': {e}"
)
.into(),
);
}
}
}
config.proxy = None;
Some(config)
} else {
None
};
let final_wayfern_config = if mapped == "wayfern" {
let mut config = wayfern_config.unwrap_or_default();
if config.executable_path.is_none() {
let mut browser_dir = self.profile_manager.get_binaries_dir();
browser_dir.push(mapped);
browser_dir.push(&version);
#[cfg(target_os = "macos")]
let binary_path = browser_dir
.join("Chromium.app")
.join("Contents")
.join("MacOS")
.join("Chromium");
#[cfg(target_os = "windows")]
let binary_path = browser_dir.join("chrome.exe");
#[cfg(target_os = "linux")]
let binary_path = browser_dir.join("chrome");
config.executable_path = Some(binary_path.to_string_lossy().to_string());
}
if let Some(ref proxy_id_val) = proxy_id {
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_val) {
let proxy_url = if let (Some(username), Some(password)) =
(&proxy_settings.username, &proxy_settings.password)
{
format!(
"{}://{}:{}@{}:{}",
proxy_settings.proxy_type.to_lowercase(),
username,
password,
proxy_settings.host,
proxy_settings.port
)
} else {
format!(
"{}://{}:{}",
proxy_settings.proxy_type.to_lowercase(),
proxy_settings.host,
proxy_settings.port
)
};
config.proxy = Some(proxy_url);
}
}
if config.fingerprint.is_none() {
let temp_profile = BrowserProfile {
id: uuid::Uuid::new_v4(),
name: new_profile_name.to_string(),
browser: mapped.to_string(),
version: version.clone(),
proxy_id: proxy_id.clone(),
vpn_id: None,
process_id: None,
last_launch: None,
release_type: "stable".to_string(),
camoufox_config: None,
wayfern_config: None,
group_id: None,
tags: Vec::new(),
note: None,
sync_mode: SyncMode::Disabled,
encryption_salt: None,
last_sync: None,
host_os: None,
ephemeral: false,
extension_group_id: None,
proxy_bypass_rules: Vec::new(),
created_by_id: None,
created_by_email: None,
};
match self
.wayfern_manager
.generate_fingerprint_config(app_handle, &temp_profile, &config)
.await
{
Ok(fp) => config.fingerprint = Some(fp),
Err(e) => {
return Err(
format!(
"Failed to generate fingerprint for imported profile '{new_profile_name}': {e}"
)
.into(),
);
}
}
}
config.proxy = None;
Some(config)
} else {
None
};
let profile = BrowserProfile {
id: profile_id,
name: new_profile_name.to_string(),
browser: browser_type.to_string(),
version: available_versions,
proxy_id: None,
browser: mapped.to_string(),
version,
proxy_id,
vpn_id: None,
process_id: None,
last_launch: None,
release_type: "stable".to_string(),
camoufox_config: None,
wayfern_config: None,
camoufox_config: final_camoufox_config,
wayfern_config: final_wayfern_config,
group_id: None,
tags: Vec::new(),
note: None,
sync_mode: crate::profile::types::SyncMode::Disabled,
sync_mode: SyncMode::Disabled,
encryption_salt: None,
last_sync: None,
host_os: Some(crate::profile::types::get_host_os()),
host_os: Some(get_host_os()),
ephemeral: false,
extension_group_id: None,
proxy_bypass_rules: Vec::new(),
@@ -565,7 +753,6 @@ impl ProfileImporter {
created_by_email: None,
};
// Save the profile metadata
self.profile_manager.save_profile(&profile)?;
log::info!(
@@ -577,12 +764,10 @@ impl ProfileImporter {
Ok(())
}
/// Get a default version for a browser type
fn get_default_version_for_browser(
&self,
browser_type: &str,
) -> Result<String, Box<dyn std::error::Error>> {
// Check if any version of the browser is downloaded
let downloaded_versions = self
.downloaded_browsers_registry
.get_downloaded_versions(browser_type);
@@ -591,15 +776,16 @@ impl ProfileImporter {
return Ok(version.clone());
}
// If no downloaded versions found, return an error
Err(format!(
"No downloaded versions found for browser '{}'. Please download a version of {} first before importing profiles.",
browser_type,
self.get_browser_display_name(browser_type)
).into())
Err(
format!(
"No downloaded versions found for browser '{}'. Please download a version of {} first before importing profiles.",
browser_type,
self.get_browser_display_name(browser_type)
)
.into(),
)
}
/// Recursively copy directory contents
pub fn copy_directory_recursive(
source: &Path,
destination: &Path,
@@ -624,7 +810,6 @@ impl ProfileImporter {
}
}
// Tauri commands
#[tauri::command]
pub async fn detect_existing_profiles() -> Result<Vec<DetectedProfile>, String> {
let importer = ProfileImporter::instance();
@@ -635,17 +820,41 @@ pub async fn detect_existing_profiles() -> Result<Vec<DetectedProfile>, String>
#[tauri::command]
pub async fn import_browser_profile(
app_handle: tauri::AppHandle,
source_path: String,
browser_type: String,
new_profile_name: String,
proxy_id: Option<String>,
camoufox_config: Option<CamoufoxConfig>,
wayfern_config: Option<WayfernConfig>,
) -> Result<(), String> {
let fingerprint_os = camoufox_config
.as_ref()
.and_then(|c| c.os.as_deref())
.or_else(|| wayfern_config.as_ref().and_then(|c| c.os.as_deref()));
if !crate::cloud_auth::CLOUD_AUTH
.is_fingerprint_os_allowed(fingerprint_os)
.await
{
return Err("Fingerprint OS spoofing requires an active Pro subscription".to_string());
}
let importer = ProfileImporter::instance();
importer
.import_profile(&source_path, &browser_type, &new_profile_name)
.import_profile(
&app_handle,
&source_path,
&browser_type,
&new_profile_name,
proxy_id,
camoufox_config,
wayfern_config,
)
.await
.map_err(|e| format!("Failed to import profile: {e}"))
}
// Global singleton instance
lazy_static::lazy_static! {
static ref PROFILE_IMPORTER: ProfileImporter = ProfileImporter::new();
}
@@ -658,10 +867,7 @@ mod tests {
fn create_test_profile_importer() -> (ProfileImporter, TempDir) {
let temp_dir = TempDir::new().expect("Failed to create temp directory");
// Set up a temporary home directory for testing
env::set_var("HOME", temp_dir.path());
let importer = ProfileImporter::new();
(importer, temp_dir)
}
@@ -669,7 +875,6 @@ mod tests {
#[test]
fn test_profile_importer_creation() {
let (_importer, _temp_dir) = create_test_profile_importer();
// Test passes if no panic occurs
}
#[test]
@@ -693,19 +898,25 @@ mod tests {
);
}
#[test]
fn test_map_browser_type() {
assert_eq!(map_browser_type("firefox"), "camoufox");
assert_eq!(map_browser_type("firefox-developer"), "camoufox");
assert_eq!(map_browser_type("zen"), "camoufox");
assert_eq!(map_browser_type("chromium"), "wayfern");
assert_eq!(map_browser_type("brave"), "wayfern");
assert_eq!(map_browser_type("camoufox"), "camoufox");
assert_eq!(map_browser_type("wayfern"), "wayfern");
assert_eq!(map_browser_type("something_else"), "wayfern");
}
#[test]
fn test_detect_existing_profiles_no_panic() {
let (importer, _temp_dir) = create_test_profile_importer();
// This should not panic even if no browser profiles exist
let result = importer.detect_existing_profiles();
assert!(result.is_ok(), "detect_existing_profiles should not fail");
let _profiles = result.unwrap();
// We can't assert specific profiles since they depend on the system
// but we can verify the result is a valid Vec
// We can't assert specific profiles since they depend on the system
// but we can verify the result is a valid Vec (length check is always true for Vec, but shows intent)
}
#[test]
@@ -764,12 +975,10 @@ mod tests {
fn test_parse_firefox_profiles_ini_valid() {
let (importer, temp_dir) = create_test_profile_importer();
// Create a mock profile directory
let profiles_dir = temp_dir.path().join("profiles");
let profile_dir = profiles_dir.join("test.profile");
fs::create_dir_all(&profile_dir).expect("Should create profile directory");
// Create a prefs.js file to make it look like a valid profile
let prefs_file = profile_dir.join("prefs.js");
fs::write(&prefs_file, "// Firefox preferences").expect("Should create prefs.js");
@@ -788,31 +997,27 @@ Path=test.profile
assert_eq!(profiles.len(), 1, "Should find one profile");
assert_eq!(profiles[0].name, "Firefox - Test Profile");
assert_eq!(profiles[0].browser, "firefox");
assert_eq!(profiles[0].mapped_browser, "camoufox");
}
#[test]
fn test_copy_directory_recursive() {
let temp_dir = TempDir::new().expect("Failed to create temp directory");
// Create source directory structure
let source_dir = temp_dir.path().join("source");
let source_subdir = source_dir.join("subdir");
fs::create_dir_all(&source_subdir).expect("Should create source directories");
// Create some test files
let source_file1 = source_dir.join("file1.txt");
let source_file2 = source_subdir.join("file2.txt");
fs::write(&source_file1, "content1").expect("Should create file1");
fs::write(&source_file2, "content2").expect("Should create file2");
// Create destination directory
let dest_dir = temp_dir.path().join("dest");
// Copy recursively
let result = ProfileImporter::copy_directory_recursive(&source_dir, &dest_dir);
assert!(result.is_ok(), "Should copy directory successfully");
// Verify files were copied
let dest_file1 = dest_dir.join("file1.txt");
let dest_file2 = dest_dir.join("subdir").join("file2.txt");
@@ -830,8 +1035,7 @@ Path=test.profile
fn test_get_default_version_for_browser_no_versions() {
let (importer, _temp_dir) = create_test_profile_importer();
// This should fail since no versions are downloaded in test environment
let result = importer.get_default_version_for_browser("firefox");
let result = importer.get_default_version_for_browser("camoufox");
assert!(
result.is_err(),
"Should fail when no versions are available"
+22 -10
View File
@@ -734,11 +734,17 @@ pub async fn save_app_settings(
.await
.map_err(|e| format!("Failed to store API token: {e}"))?;
} else {
let token = manager
.generate_api_token(&app_handle)
.await
.map_err(|e| format!("Failed to generate API token: {e}"))?;
settings.api_token = Some(token);
// Check if a token already exists on disk before generating a new one
let existing = manager.get_api_token(&app_handle).await.ok().flatten();
if let Some(t) = existing {
settings.api_token = Some(t);
} else {
let token = manager
.generate_api_token(&app_handle)
.await
.map_err(|e| format!("Failed to generate API token: {e}"))?;
settings.api_token = Some(token);
}
}
}
@@ -758,11 +764,17 @@ pub async fn save_app_settings(
.await
.map_err(|e| format!("Failed to store MCP token: {e}"))?;
} else {
let token = manager
.generate_mcp_token(&app_handle)
.await
.map_err(|e| format!("Failed to generate MCP token: {e}"))?;
settings.mcp_token = Some(token);
// Check if a token already exists on disk before generating a new one
let existing = manager.get_mcp_token(&app_handle).await.ok().flatten();
if let Some(t) = existing {
settings.mcp_token = Some(t);
} else {
let token = manager
.generate_mcp_token(&app_handle)
.await
.map_err(|e| format!("Failed to generate MCP token: {e}"))?;
settings.mcp_token = Some(token);
}
}
}
+1 -6
View File
@@ -143,12 +143,7 @@ impl VersionUpdater {
pub async fn check_and_run_startup_update(
&self,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Only run if an update is actually needed
if !Self::should_run_background_update() {
log::debug!("No startup version update needed");
return Ok(());
}
// Always check for updates on launch
if let Some(ref app_handle) = self.app_handle {
log::info!("Running startup version update...");
+19
View File
@@ -783,6 +783,25 @@ impl WayfernManager {
Ok(())
}
pub async fn get_cdp_port(&self, profile_path: &str) -> Option<u16> {
let inner = self.inner.lock().await;
let target_path = std::path::Path::new(profile_path)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf());
for instance in inner.instances.values() {
if let Some(path) = &instance.profile_path {
let instance_path = std::path::Path::new(path)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(path).to_path_buf());
if instance_path == target_path {
return instance.cdp_port;
}
}
}
None
}
pub async fn find_wayfern_by_profile(&self, profile_path: &str) -> Option<WayfernLaunchResult> {
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
+5 -41
View File
@@ -57,14 +57,7 @@ import type {
WayfernConfig,
} from "@/types";
type BrowserTypeString =
| "firefox"
| "firefox-developer"
| "chromium"
| "brave"
| "zen"
| "camoufox"
| "wayfern";
type BrowserTypeString = "camoufox" | "wayfern";
interface PendingUrl {
id: string;
@@ -943,37 +936,6 @@ export default function Home() {
profiles.length,
]);
// Show deprecation warning for unsupported profiles (with names)
useEffect(() => {
if (profiles.length === 0) return;
const deprecatedProfiles = profiles.filter(
(p) => p.release_type === "nightly" && p.browser !== "firefox-developer",
);
if (deprecatedProfiles.length > 0) {
const deprecatedNames = deprecatedProfiles.map((p) => p.name).join(", ");
// Use a stable id to avoid duplicate toasts on re-renders
showToast({
id: "deprecated-profiles-warning",
type: "error",
title: "Some profiles will be deprecated soon",
description: `The following profiles will be deprecated soon: ${deprecatedNames}. Nightly profiles (except Firefox Developers Edition) will be removed in upcoming versions. Please check GitHub for migration instructions.`,
duration: 15000,
action: {
label: "Learn more",
onClick: () => {
const event = new CustomEvent("url-open-request", {
detail: "https://github.com/zhom/donutbrowser/discussions/66",
});
window.dispatchEvent(event);
},
},
});
}
}, [profiles]);
// Show warning for non-wayfern/camoufox profiles (support ending March 15, 2026)
useEffect(() => {
if (profiles.length === 0) return;
@@ -1163,6 +1125,7 @@ export default function Home() {
onClose={() => {
setImportProfileDialogOpen(false);
}}
crossOsUnlocked={crossOsUnlocked}
/>
<ProxyManagementDialog
@@ -1329,13 +1292,14 @@ export default function Home() {
onAccepted={checkTerms}
/>
{/* Commercial Trial Modal - shown once when trial expires */}
{/* Commercial Trial Modal - shown once when trial expires (skip for paid users) */}
<CommercialTrialModal
isOpen={
!termsLoading &&
termsAccepted === true &&
trialStatus?.type === "Expired" &&
!trialAcknowledged
!trialAcknowledged &&
!crossOsUnlocked
}
onClose={checkTrialStatus}
/>
+2 -2
View File
@@ -462,8 +462,8 @@ export function CookieManagementDialog({
{importResult && (
<div className="space-y-4">
<div className="p-4 rounded-lg bg-green-500/10">
<div className="font-medium text-green-600 dark:text-green-400">
<div className="p-4 rounded-lg bg-success/10">
<div className="font-medium text-success">
Successfully imported {importResult.cookies_imported}{" "}
cookies ({importResult.cookies_replaced} replaced)
</div>
+1 -1
View File
@@ -91,7 +91,7 @@ export function CreateGroupDialog({
</div>
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
{error}
</div>
)}
+16 -63
View File
@@ -67,14 +67,7 @@ const getCurrentOS = (): CamoufoxOS => {
import { RippleButton } from "./ui/ripple";
type BrowserTypeString =
| "firefox"
| "firefox-developer"
| "chromium"
| "brave"
| "zen"
| "camoufox"
| "wayfern";
type BrowserTypeString = "camoufox" | "wayfern";
interface CreateProfileDialogProps {
isOpen: boolean;
@@ -103,24 +96,12 @@ interface BrowserOption {
const browserOptions: BrowserOption[] = [
{
value: "firefox",
label: "Firefox",
value: "camoufox",
label: "Camoufox",
},
{
value: "firefox-developer",
label: "Firefox Developer Edition",
},
{
value: "chromium",
label: "Chromium",
},
{
value: "brave",
label: "Brave",
},
{
value: "zen",
label: "Zen Browser",
value: "wayfern",
label: "Wayfern",
},
];
@@ -254,23 +235,9 @@ export function CreateProfileDialog({
// Only update state if this browser is still the one we're loading
if (loadingBrowserRef.current === browser) {
// Filter to enforce stable-only creation, except Firefox Developer (nightly-only)
if (browser === "camoufox" || browser === "wayfern") {
const filtered: BrowserReleaseTypes = {};
if (rawReleaseTypes.stable)
filtered.stable = rawReleaseTypes.stable;
setReleaseTypes(filtered);
} else if (browser === "firefox-developer") {
const filtered: BrowserReleaseTypes = {};
if (rawReleaseTypes.nightly)
filtered.nightly = rawReleaseTypes.nightly;
setReleaseTypes(filtered);
} else {
const filtered: BrowserReleaseTypes = {};
if (rawReleaseTypes.stable)
filtered.stable = rawReleaseTypes.stable;
setReleaseTypes(filtered);
}
const filtered: BrowserReleaseTypes = {};
if (rawReleaseTypes.stable) filtered.stable = rawReleaseTypes.stable;
setReleaseTypes(filtered);
setReleaseTypesError(null);
}
} catch (error) {
@@ -282,11 +249,7 @@ export function CreateProfileDialog({
if (loadingBrowserRef.current === browser && downloaded.length > 0) {
const latest = downloaded[0];
const fallback: BrowserReleaseTypes = {};
if (browser === "firefox-developer") {
fallback.nightly = latest;
} else {
fallback.stable = latest;
}
fallback.stable = latest;
setReleaseTypes(fallback);
setReleaseTypesError(null);
} else if (loadingBrowserRef.current === browser) {
@@ -351,17 +314,9 @@ export function CreateProfileDialog({
// Helper function to get the best available version respecting rules
const getBestAvailableVersion = useCallback(
(browserType?: string) => {
(_browserType?: string) => {
if (!releaseTypes) return null;
// Firefox Developer Edition: nightly-only
if (browserType === "firefox-developer" && releaseTypes.nightly) {
return {
version: releaseTypes.nightly,
releaseType: "nightly" as const,
};
}
// All others: stable-only
if (releaseTypes.stable) {
return { version: releaseTypes.stable, releaseType: "stable" as const };
}
@@ -379,11 +334,9 @@ export function CreateProfileDialog({
const browserDownloaded = downloadedVersionsMap[browserType ?? ""] ?? [];
if (browserDownloaded.length > 0) {
const fallbackVersion = browserDownloaded[0];
const releaseType =
browserType === "firefox-developer" ? "nightly" : "stable";
return {
version: fallbackVersion,
releaseType: releaseType as "stable" | "nightly",
releaseType: "stable" as const,
};
}
return null;
@@ -772,8 +725,8 @@ export function CreateProfileDialog({
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!getBestAvailableVersion("wayfern") && (
<div className="flex gap-3 items-center p-3 rounded-md border border-yellow-500/50 bg-yellow-500/10">
<p className="text-sm text-yellow-500">
<div className="flex gap-3 items-center p-3 rounded-md border border-warning/50 bg-warning/10">
<p className="text-sm text-warning">
Wayfern is not available on your platform
yet.
</p>
@@ -874,8 +827,8 @@ export function CreateProfileDialog({
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!getBestAvailableVersion("camoufox") && (
<div className="flex gap-3 items-center p-3 rounded-md border border-yellow-500/50 bg-yellow-500/10">
<p className="text-sm text-yellow-500">
<div className="flex gap-3 items-center p-3 rounded-md border border-warning/50 bg-warning/10">
<p className="text-sm text-warning">
Camoufox is not available on your platform
yet.
</p>
@@ -933,7 +886,7 @@ export function CreateProfileDialog({
)}
{crossOsUnlocked && (
<Alert className="border-yellow-500/50 bg-yellow-500/10">
<Alert className="border-warning/50 bg-warning/10">
<AlertDescription className="text-sm">
{t("createProfile.camoufoxWarning")}
</AlertDescription>
+1 -1
View File
@@ -347,7 +347,7 @@ export function UnifiedToast(props: ToastProps) {
<>
{stage === "extracting" && (
<p className="mt-1 text-xs text-muted-foreground">
Extracting browser files...
Extracting browser files... Please do not close the app.
</p>
)}
{stage === "verifying" && (
+2 -2
View File
@@ -117,7 +117,7 @@ function DataTableActionBarAction({
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent
sideOffset={6}
className="border bg-accent font-semibold text-foreground dark:bg-zinc-900 [&>span]:hidden"
className="border bg-accent font-semibold text-foreground dark:bg-card [&>span]:hidden"
>
<p>{tooltip}</p>
</TooltipContent>
@@ -155,7 +155,7 @@ function DataTableActionBarSelection<TData>({
</TooltipTrigger>
<TooltipContent
sideOffset={10}
className="flex items-center gap-2 border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900 [&>span]:hidden"
className="flex items-center gap-2 border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-card [&>span]:hidden"
>
<p>Clear selection</p>
<kbd className="select-none rounded border bg-background px-1.5 py-px font-mono font-normal text-[0.7rem] text-foreground shadow-xs">
+2 -2
View File
@@ -162,7 +162,7 @@ export function DeleteGroupDialog({
<RadioGroupItem value="delete" id="delete" />
<Label
htmlFor="delete"
className="text-sm text-red-600"
className="text-sm text-destructive"
>
Delete profiles along with the group
</Label>
@@ -181,7 +181,7 @@ export function DeleteGroupDialog({
)}
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
{error}
</div>
)}
+1 -1
View File
@@ -101,7 +101,7 @@ export function EditGroupDialog({
</div>
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
{error}
</div>
)}
@@ -160,7 +160,7 @@ export function ExtensionGroupAssignmentDialog({
</div>
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
{error}
</div>
)}
@@ -55,10 +55,10 @@ function getSyncStatusDot(
switch (status) {
case "syncing":
return { color: "bg-yellow-500", tooltip: "Syncing...", animate: true };
return { color: "bg-warning", tooltip: "Syncing...", animate: true };
case "synced":
return {
color: "bg-green-500",
color: "bg-success",
tooltip: item.last_sync
? `Synced ${new Date(item.last_sync * 1000).toLocaleString()}`
: "Synced",
@@ -66,18 +66,22 @@ function getSyncStatusDot(
};
case "waiting":
return {
color: "bg-yellow-500",
color: "bg-warning",
tooltip: "Waiting to sync",
animate: false,
};
case "error":
return {
color: "bg-red-500",
color: "bg-destructive",
tooltip: "Sync error",
animate: false,
};
default:
return { color: "bg-gray-400", tooltip: "Not synced", animate: false };
return {
color: "bg-muted-foreground",
tooltip: "Not synced",
animate: false,
};
}
}
+1 -1
View File
@@ -176,7 +176,7 @@ export function GroupAssignmentDialog({
</div>
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
{error}
</div>
)}
+10 -6
View File
@@ -49,10 +49,10 @@ function getSyncStatusDot(
switch (status) {
case "syncing":
return { color: "bg-yellow-500", tooltip: "Syncing...", animate: true };
return { color: "bg-warning", tooltip: "Syncing...", animate: true };
case "synced":
return {
color: "bg-green-500",
color: "bg-success",
tooltip: group.last_sync
? `Synced ${new Date(group.last_sync * 1000).toLocaleString()}`
: "Synced",
@@ -60,18 +60,22 @@ function getSyncStatusDot(
};
case "waiting":
return {
color: "bg-yellow-500",
color: "bg-warning",
tooltip: "Waiting to sync",
animate: false,
};
case "error":
return {
color: "bg-red-500",
color: "bg-destructive",
tooltip: errorMessage ? `Sync error: ${errorMessage}` : "Sync error",
animate: false,
};
default:
return { color: "bg-gray-400", tooltip: "Not synced", animate: false };
return {
color: "bg-muted-foreground",
tooltip: "Not synced",
animate: false,
};
}
}
@@ -252,7 +256,7 @@ export function GroupManagementDialog({
</div>
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
{error}
</div>
)}
+409 -294
View File
@@ -2,10 +2,12 @@
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { FaFolder } from "react-icons/fa";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -23,19 +25,29 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { WayfernConfigForm } from "@/components/wayfern-config-form";
import { useBrowserSupport } from "@/hooks/use-browser-support";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
import type { DetectedProfile } from "@/types";
import type { CamoufoxConfig, DetectedProfile, WayfernConfig } from "@/types";
import { RippleButton } from "./ui/ripple";
const getMappedBrowser = (browser: string): "camoufox" | "wayfern" => {
if (["firefox", "firefox-developer", "zen"].includes(browser))
return "camoufox";
return "wayfern";
};
interface ImportProfileDialogProps {
isOpen: boolean;
onClose: () => void;
crossOsUnlocked?: boolean;
}
export function ImportProfileDialog({
isOpen,
onClose,
crossOsUnlocked,
}: ImportProfileDialogProps) {
const [detectedProfiles, setDetectedProfiles] = useState<DetectedProfile[]>(
[],
@@ -45,6 +57,12 @@ export function ImportProfileDialog({
const [importMode, setImportMode] = useState<"auto-detect" | "manual">(
"auto-detect",
);
const [currentStep, setCurrentStep] = useState<"select" | "configure">(
"select",
);
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>({});
const [wayfernConfig, setWayfernConfig] = useState<WayfernConfig>({});
const [selectedProxyId, setSelectedProxyId] = useState<string | undefined>();
// Auto-detect state
const [selectedDetectedProfile, setSelectedDetectedProfile] = useState<
@@ -61,6 +79,7 @@ export function ImportProfileDialog({
const { supportedBrowsers, isLoading: isLoadingSupport } =
useBrowserSupport();
const { storedProxies } = useProxyEvents();
const importableBrowsers = supportedBrowsers;
@@ -72,14 +91,11 @@ export function ImportProfileDialog({
);
setDetectedProfiles(profiles);
// Auto-switch to manual mode if no profiles detected
if (profiles.length === 0) {
setImportMode("manual");
} else {
// Auto-select first profile if available
setSelectedDetectedProfile(profiles[0].path);
// Generate default name from the detected profile
const profile = profiles[0];
const browserName = getBrowserDisplayName(profile.browser);
const defaultName = `Imported ${browserName} Profile`;
@@ -93,6 +109,10 @@ export function ImportProfileDialog({
}
}, []);
const selectedProfile = detectedProfiles.find(
(p) => p.path === selectedDetectedProfile,
);
const handleBrowseFolder = async () => {
try {
const selected = await open({
@@ -110,40 +130,65 @@ export function ImportProfileDialog({
}
};
const handleAutoDetectImport = useCallback(async () => {
if (!selectedDetectedProfile || !autoDetectProfileName.trim()) {
toast.error("Please select a profile and provide a name");
return;
const handleImport = useCallback(async () => {
let sourcePath: string;
let browserType: string;
let newProfileName: string;
if (importMode === "auto-detect") {
if (!selectedDetectedProfile || !autoDetectProfileName.trim()) {
toast.error("Please select a profile and provide a name");
return;
}
const profile = detectedProfiles.find(
(p) => p.path === selectedDetectedProfile,
);
if (!profile) {
toast.error("Selected profile not found");
return;
}
sourcePath = profile.path;
browserType = profile.browser;
newProfileName = autoDetectProfileName.trim();
} else {
if (
!manualBrowserType ||
!manualProfilePath.trim() ||
!manualProfileName.trim()
) {
toast.error("Please fill in all fields");
return;
}
sourcePath = manualProfilePath.trim();
browserType = manualBrowserType;
newProfileName = manualProfileName.trim();
}
const profile = detectedProfiles.find(
(p) => p.path === selectedDetectedProfile,
);
if (!profile) {
toast.error("Selected profile not found");
return;
}
const mappedBrowser =
importMode === "auto-detect" && selectedProfile
? (selectedProfile.mapped_browser as "camoufox" | "wayfern")
: getMappedBrowser(browserType);
setIsImporting(true);
try {
await invoke("import_browser_profile", {
sourcePath: profile.path,
browserType: profile.browser,
newProfileName: autoDetectProfileName.trim(),
sourcePath,
browserType,
newProfileName,
proxyId: selectedProxyId ?? null,
camoufoxConfig: mappedBrowser === "camoufox" ? camoufoxConfig : null,
wayfernConfig: mappedBrowser === "wayfern" ? wayfernConfig : null,
});
toast.success(
`Successfully imported profile "${autoDetectProfileName.trim()}"`,
);
toast.success(`Successfully imported profile "${newProfileName}"`);
onClose();
} catch (error) {
console.error("Failed to import profile:", error);
const errorMessage =
error instanceof Error ? error.message : String(error);
// Check if error is about browser not being downloaded
if (errorMessage.includes("No downloaded versions found")) {
const browserDisplayName = getBrowserDisplayName(profile.browser);
const browserDisplayName = getBrowserDisplayName(browserType);
toast.error(
`${browserDisplayName} is not installed. Please download ${browserDisplayName} first from the main window, then try importing again.`,
{
@@ -157,63 +202,30 @@ export function ImportProfileDialog({
setIsImporting(false);
}
}, [
importMode,
selectedDetectedProfile,
autoDetectProfileName,
detectedProfiles,
manualBrowserType,
manualProfilePath,
manualProfileName,
selectedProxyId,
camoufoxConfig,
wayfernConfig,
onClose,
selectedProfile,
]);
const handleManualImport = useCallback(async () => {
if (
!manualBrowserType ||
!manualProfilePath.trim() ||
!manualProfileName.trim()
) {
toast.error("Please fill in all fields");
return;
}
setIsImporting(true);
try {
await invoke("import_browser_profile", {
sourcePath: manualProfilePath.trim(),
browserType: manualBrowserType,
newProfileName: manualProfileName.trim(),
});
toast.success(
`Successfully imported profile "${manualProfileName.trim()}"`,
);
onClose();
} catch (error) {
console.error("Failed to import profile:", error);
const errorMessage =
error instanceof Error ? error.message : String(error);
// Check if error is about browser not being downloaded
if (errorMessage.includes("No downloaded versions found")) {
const browserDisplayName = getBrowserDisplayName(manualBrowserType);
toast.error(
`${browserDisplayName} is not installed. Please download ${browserDisplayName} first from the main window, then try importing again.`,
{
duration: 8000,
},
);
} else {
toast.error(`Failed to import profile: ${errorMessage}`);
}
} finally {
setIsImporting(false);
}
}, [manualBrowserType, manualProfilePath, manualProfileName, onClose]);
const handleClose = () => {
setCurrentStep("select");
setCamoufoxConfig({});
setWayfernConfig({});
setSelectedProxyId(undefined);
setSelectedDetectedProfile(null);
setAutoDetectProfileName("");
setManualBrowserType(null);
setManualProfilePath("");
setManualProfileName("");
// Only reset to auto-detect if there are profiles available
if (detectedProfiles.length > 0) {
setImportMode("auto-detect");
} else {
@@ -222,7 +234,6 @@ export function ImportProfileDialog({
onClose();
};
// Update auto-detect profile name when selection changes
useEffect(() => {
if (selectedDetectedProfile) {
const profile = detectedProfiles.find(
@@ -236,9 +247,38 @@ export function ImportProfileDialog({
}
}, [selectedDetectedProfile, detectedProfiles]);
const selectedProfile = detectedProfiles.find(
(p) => p.path === selectedDetectedProfile,
);
const currentMappedBrowser = useMemo(() => {
if (importMode === "auto-detect" && selectedProfile) {
return selectedProfile.mapped_browser as "camoufox" | "wayfern";
}
if (importMode === "manual" && manualBrowserType) {
return manualBrowserType as "camoufox" | "wayfern";
}
return null;
}, [importMode, selectedProfile, manualBrowserType]);
const canProceedToNext = useMemo(() => {
if (importMode === "auto-detect") {
return (
!isLoading &&
!!selectedDetectedProfile &&
!!autoDetectProfileName.trim()
);
}
return (
!!manualBrowserType &&
!!manualProfilePath.trim() &&
!!manualProfileName.trim()
);
}, [
importMode,
isLoading,
selectedDetectedProfile,
autoDetectProfileName,
manualBrowserType,
manualProfilePath,
manualProfileName,
]);
useEffect(() => {
if (isOpen) {
@@ -254,247 +294,322 @@ export function ImportProfileDialog({
</DialogHeader>
<div className="overflow-y-auto flex-1 space-y-6 min-h-0">
{/* Mode Selection */}
<div className="flex gap-2">
<RippleButton
variant={importMode === "auto-detect" ? "default" : "outline"}
onClick={() => {
setImportMode("auto-detect");
}}
className="flex-1"
disabled={isLoading}
>
Auto-Detect
</RippleButton>
<RippleButton
variant={importMode === "manual" ? "default" : "outline"}
onClick={() => {
setImportMode("manual");
}}
className="flex-1"
disabled={isLoading}
>
Manual Import
</RippleButton>
</div>
{currentStep === "select" && (
<>
<div className="flex gap-2">
<RippleButton
variant={importMode === "auto-detect" ? "default" : "outline"}
onClick={() => {
setImportMode("auto-detect");
}}
className="flex-1"
disabled={isLoading}
>
Auto-Detect
</RippleButton>
<RippleButton
variant={importMode === "manual" ? "default" : "outline"}
onClick={() => {
setImportMode("manual");
}}
className="flex-1"
disabled={isLoading}
>
Manual Import
</RippleButton>
</div>
{/* Auto-Detect Mode */}
{importMode === "auto-detect" && (
<div className="space-y-4">
<h3 className="text-lg font-medium">Detected Browser Profiles</h3>
{isLoading ? (
<div className="py-8 text-center">
<p className="text-muted-foreground">
Scanning for browser profiles...
</p>
</div>
) : detectedProfiles.length === 0 ? (
<div className="py-8 text-center">
<p className="text-muted-foreground">
No browser profiles found on your system.
</p>
<p className="mt-2 text-sm text-muted-foreground">
Try the manual import option if you have profiles in custom
locations.
</p>
</div>
) : (
{importMode === "auto-detect" && (
<div className="space-y-4">
<div>
<Label htmlFor="detected-profile-select" className="mb-2">
Select Profile:
</Label>
<Select
value={selectedDetectedProfile ?? undefined}
onValueChange={(value) => {
setSelectedDetectedProfile(value);
}}
>
<SelectTrigger id="detected-profile-select">
<SelectValue placeholder="Choose a detected profile" />
</SelectTrigger>
<SelectContent>
{detectedProfiles.map((profile) => {
const IconComponent = getBrowserIcon(profile.browser);
return (
<SelectItem key={profile.path} value={profile.path}>
<div className="flex gap-2 items-center">
{IconComponent && (
<IconComponent className="w-4 h-4" />
)}
<div className="flex flex-col">
<span className="font-medium">
{profile.name}
</span>
</div>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<h3 className="text-lg font-medium">
Detected Browser Profiles
</h3>
{selectedProfile && (
<div className="p-3 rounded-lg bg-muted">
<p className="text-sm">
<span className="font-medium">Path:</span>{" "}
{selectedProfile.path}
</p>
<p className="text-sm">
<span className="font-medium">Browser:</span>{" "}
{getBrowserDisplayName(selectedProfile.browser)}
{isLoading ? (
<div className="py-8 text-center">
<p className="text-muted-foreground">
Scanning for browser profiles...
</p>
</div>
)}
) : detectedProfiles.length === 0 ? (
<div className="py-8 text-center">
<p className="text-muted-foreground">
No browser profiles found on your system.
</p>
<p className="mt-2 text-sm text-muted-foreground">
Try the manual import option if you have profiles in
custom locations.
</p>
</div>
) : (
<div className="space-y-4">
<div>
<Label
htmlFor="detected-profile-select"
className="mb-2"
>
Select Profile:
</Label>
<Select
value={selectedDetectedProfile ?? undefined}
onValueChange={(value) => {
setSelectedDetectedProfile(value);
}}
>
<SelectTrigger id="detected-profile-select">
<SelectValue placeholder="Choose a detected profile" />
</SelectTrigger>
<SelectContent>
{detectedProfiles.map((profile) => {
const IconComponent = getBrowserIcon(
profile.browser,
);
return (
<SelectItem
key={profile.path}
value={profile.path}
>
<div className="flex gap-2 items-center">
{IconComponent && (
<IconComponent className="w-4 h-4" />
)}
<div className="flex flex-col">
<span className="font-medium">
{profile.name}
</span>
</div>
<span className="text-xs text-muted-foreground">
{" "}
{getBrowserDisplayName(
profile.mapped_browser,
)}
</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="auto-profile-name" className="mb-2">
New Profile Name:
</Label>
<Input
id="auto-profile-name"
value={autoDetectProfileName}
onChange={(e) => {
setAutoDetectProfileName(e.target.value);
}}
placeholder="Enter a name for the imported profile"
/>
{selectedProfile && (
<div className="p-3 rounded-lg bg-muted">
<p className="text-sm">
<span className="font-medium">Path:</span>{" "}
{selectedProfile.path}
</p>
<p className="text-sm">
<span className="font-medium">Browser:</span>{" "}
{getBrowserDisplayName(selectedProfile.browser)}
</p>
</div>
)}
<div>
<Label htmlFor="auto-profile-name" className="mb-2">
New Profile Name:
</Label>
<Input
id="auto-profile-name"
value={autoDetectProfileName}
onChange={(e) => {
setAutoDetectProfileName(e.target.value);
}}
placeholder="Enter a name for the imported profile"
/>
</div>
</div>
)}
</div>
)}
{importMode === "manual" && (
<div className="space-y-4">
<h3 className="text-lg font-medium">Manual Profile Import</h3>
<div className="space-y-4">
<div>
<Label htmlFor="manual-browser-select" className="mb-2">
Browser Type:
</Label>
<Select
value={manualBrowserType ?? undefined}
onValueChange={(value) => {
setManualBrowserType(value);
}}
disabled={isLoadingSupport}
>
<SelectTrigger id="manual-browser-select">
<SelectValue
placeholder={
isLoadingSupport
? "Loading browsers..."
: "Select browser type"
}
/>
</SelectTrigger>
<SelectContent>
{importableBrowsers.map((browser) => {
const IconComponent = getBrowserIcon(browser);
return (
<SelectItem key={browser} value={browser}>
<div className="flex gap-2 items-center">
{IconComponent && (
<IconComponent className="w-4 h-4" />
)}
<span>{getBrowserDisplayName(browser)}</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="manual-profile-path" className="mb-2">
Profile Folder Path:
</Label>
<div className="flex gap-2">
<Input
id="manual-profile-path"
value={manualProfilePath}
onChange={(e) => {
setManualProfilePath(e.target.value);
}}
placeholder="Enter the full path to the profile folder"
/>
<Button
variant="outline"
size="icon"
onClick={() => void handleBrowseFolder()}
title="Browse for folder"
>
<FaFolder className="w-4 h-4" />
</Button>
</div>
<p className="mt-2 text-xs text-muted-foreground">
Example paths:
<br />
macOS: ~/Library/Application
Support/Firefox/Profiles/xxx.default
<br />
Windows: %APPDATA%\Mozilla\Firefox\Profiles\xxx.default
<br />
Linux: ~/.mozilla/firefox/xxx.default
</p>
</div>
<div>
<Label htmlFor="manual-profile-name" className="mb-2">
New Profile Name:
</Label>
<Input
id="manual-profile-name"
value={manualProfileName}
onChange={(e) => {
setManualProfileName(e.target.value);
}}
placeholder="Enter a name for the imported profile"
/>
</div>
</div>
</div>
)}
</div>
</>
)}
{/* Manual Import Mode */}
{importMode === "manual" && (
{currentStep === "configure" && currentMappedBrowser && (
<div className="space-y-4">
<h3 className="text-lg font-medium">Manual Profile Import</h3>
<Alert>
<AlertDescription>
This profile will be imported as a{" "}
<strong>{getBrowserDisplayName(currentMappedBrowser)}</strong>{" "}
profile.
</AlertDescription>
</Alert>
<div className="space-y-4">
<div>
<Label htmlFor="manual-browser-select" className="mb-2">
Browser Type:
</Label>
<Select
value={manualBrowserType ?? undefined}
onValueChange={(value) => {
setManualBrowserType(value);
}}
disabled={isLoadingSupport}
>
<SelectTrigger id="manual-browser-select">
<SelectValue
placeholder={
isLoadingSupport
? "Loading browsers..."
: "Select browser type"
}
/>
</SelectTrigger>
<SelectContent>
{importableBrowsers.map((browser) => {
const IconComponent = getBrowserIcon(browser);
return (
<SelectItem key={browser} value={browser}>
<div className="flex gap-2 items-center">
{IconComponent && (
<IconComponent className="w-4 h-4" />
)}
<span>{getBrowserDisplayName(browser)}</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="manual-profile-path" className="mb-2">
Profile Folder Path:
</Label>
<div className="flex gap-2">
<Input
id="manual-profile-path"
value={manualProfilePath}
onChange={(e) => {
setManualProfilePath(e.target.value);
}}
placeholder="Enter the full path to the profile folder"
/>
<Button
variant="outline"
size="icon"
onClick={() => void handleBrowseFolder()}
title="Browse for folder"
>
<FaFolder className="w-4 h-4" />
</Button>
</div>
<p className="mt-2 text-xs text-muted-foreground">
Example paths:
<br />
macOS: ~/Library/Application
Support/Firefox/Profiles/xxx.default
<br />
Windows: %APPDATA%\Mozilla\Firefox\Profiles\xxx.default
<br />
Linux: ~/.mozilla/firefox/xxx.default
</p>
</div>
<div>
<Label htmlFor="manual-profile-name" className="mb-2">
New Profile Name:
</Label>
<Input
id="manual-profile-name"
value={manualProfileName}
onChange={(e) => {
setManualProfileName(e.target.value);
}}
placeholder="Enter a name for the imported profile"
/>
</div>
<div>
<Label className="mb-2">Proxy (Optional)</Label>
<Select
value={selectedProxyId ?? "none"}
onValueChange={(value) => {
setSelectedProxyId(value === "none" ? undefined : value);
}}
>
<SelectTrigger>
<SelectValue placeholder="No proxy" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No proxy</SelectItem>
{storedProxies.map((proxy) => (
<SelectItem key={proxy.id} value={proxy.id}>
{proxy.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{currentMappedBrowser === "camoufox" ? (
<SharedCamoufoxConfigForm
config={camoufoxConfig}
onConfigChange={(key, value) => {
setCamoufoxConfig((prev) => ({ ...prev, [key]: value }));
}}
isCreating={true}
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
/>
) : (
<WayfernConfigForm
config={wayfernConfig}
onConfigChange={(key, value) => {
setWayfernConfig((prev) => ({ ...prev, [key]: value }));
}}
isCreating={true}
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
/>
)}
</div>
)}
</div>
<DialogFooter className="flex-shrink-0">
<RippleButton variant="outline" onClick={handleClose}>
Cancel
</RippleButton>
{importMode === "auto-detect" ? (
<LoadingButton
isLoading={isImporting}
onClick={() => {
void handleAutoDetectImport();
}}
disabled={
!selectedDetectedProfile ||
!autoDetectProfileName.trim() ||
isLoading
}
>
Import
</LoadingButton>
{currentStep === "select" ? (
<>
<RippleButton variant="outline" onClick={handleClose}>
Cancel
</RippleButton>
<RippleButton
disabled={!canProceedToNext}
onClick={() => {
setCurrentStep("configure");
}}
>
Next
</RippleButton>
</>
) : (
<LoadingButton
isLoading={isImporting}
onClick={() => {
void handleManualImport();
}}
disabled={
!manualBrowserType ||
!manualProfilePath.trim() ||
!manualProfileName.trim()
}
>
Import
</LoadingButton>
<>
<RippleButton
variant="outline"
onClick={() => {
setCurrentStep("select");
}}
>
Back
</RippleButton>
<LoadingButton
isLoading={isImporting}
onClick={() => {
void handleImport();
}}
>
Import
</LoadingButton>
</>
)}
</DialogFooter>
</DialogContent>
+1 -1
View File
@@ -320,7 +320,7 @@ export function IntegrationsDialog({
<p className="text-xs text-muted-foreground">
Allow AI assistants like Claude Desktop to control browsers.
{!termsAccepted && (
<span className="ml-1 text-orange-600">
<span className="ml-1 text-warning">
(Accept Wayfern terms in Settings first)
</span>
)}
+5 -5
View File
@@ -116,7 +116,7 @@ export function PermissionDialog({
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader className="text-center">
<div className="flex justify-center items-center mx-auto mb-4 w-16 h-16 bg-blue-100 rounded-full dark:bg-blue-900">
<div className="flex justify-center items-center mx-auto mb-4 w-16 h-16 bg-primary/15 rounded-full">
{getPermissionIcon(permissionType)}
</div>
<DialogTitle className="text-xl">
@@ -129,8 +129,8 @@ export function PermissionDialog({
<div className="space-y-4">
{isCurrentPermissionGranted && (
<div className="p-3 bg-green-50 rounded-lg dark:bg-green-900/20">
<p className="text-sm text-green-800 dark:text-green-200">
<div className="p-3 bg-success/10 rounded-lg">
<p className="text-sm text-success">
Permission granted! Browsers launched from Donut Browser can
now access your {permissionType}.
</p>
@@ -138,8 +138,8 @@ export function PermissionDialog({
)}
{!isCurrentPermissionGranted && (
<div className="p-3 bg-amber-50 rounded-lg dark:bg-amber-900/20">
<p className="text-sm text-amber-800 dark:text-amber-200">
<div className="p-3 bg-warning/10 rounded-lg">
<p className="text-sm text-warning">
Permission not granted. Click the button below to request
access to your {permissionType}.
</p>
+5 -5
View File
@@ -234,21 +234,21 @@ function getProfileSyncStatusDot(
switch (status) {
case "syncing":
return {
color: "bg-yellow-500",
color: "bg-warning",
tooltip: "Syncing...",
animate: true,
encrypted,
};
case "waiting":
return {
color: "bg-yellow-500",
color: "bg-warning",
tooltip: "Waiting to sync",
animate: false,
encrypted,
};
case "synced":
return {
color: "bg-green-500",
color: "bg-success",
tooltip: profile.last_sync
? `Synced ${new Date(profile.last_sync * 1000).toLocaleString()}`
: "Synced",
@@ -257,7 +257,7 @@ function getProfileSyncStatusDot(
};
case "error":
return {
color: "bg-red-500",
color: "bg-destructive",
tooltip: errorMessage ? `Sync error: ${errorMessage}` : "Sync error",
animate: false,
encrypted,
@@ -265,7 +265,7 @@ function getProfileSyncStatusDot(
case "disabled":
if (profile.last_sync) {
return {
color: "bg-gray-400",
color: "bg-muted-foreground",
tooltip: `Sync disabled, last sync ${formatRelativeTime(profile.last_sync)}`,
animate: false,
encrypted: false,
+1 -1
View File
@@ -276,7 +276,7 @@ export function ProxyAssignmentDialog({
</div>
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
{error}
</div>
)}
+4 -4
View File
@@ -429,14 +429,14 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
<div className="p-4 bg-muted/30 rounded-lg space-y-2">
<div className="flex justify-between">
<span className="text-sm">Imported:</span>
<span className="text-sm font-medium text-green-600 dark:text-green-400">
<span className="text-sm font-medium text-success">
{importResult.imported_count}
</span>
</div>
{importResult.skipped_count > 0 && (
<div className="flex justify-between">
<span className="text-sm">Skipped (duplicates):</span>
<span className="text-sm font-medium text-yellow-600 dark:text-yellow-400">
<span className="text-sm font-medium text-warning">
{importResult.skipped_count}
</span>
</div>
@@ -444,7 +444,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
{importResult.errors.length > 0 && (
<div className="flex justify-between">
<span className="text-sm">Errors:</span>
<span className="text-sm font-medium text-red-600 dark:text-red-400">
<span className="text-sm font-medium text-destructive">
{importResult.errors.length}
</span>
</div>
@@ -459,7 +459,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
{importResult.errors.map((error, i) => (
<div
key={`error-${i}`}
className="text-xs text-red-600 dark:text-red-400"
className="text-xs text-destructive"
>
{error}
</div>
+9 -5
View File
@@ -59,10 +59,10 @@ function getSyncStatusDot(
switch (status) {
case "syncing":
return { color: "bg-yellow-500", tooltip: "Syncing...", animate: true };
return { color: "bg-warning", tooltip: "Syncing...", animate: true };
case "synced":
return {
color: "bg-green-500",
color: "bg-success",
tooltip: item.last_sync
? `Synced ${new Date(item.last_sync * 1000).toLocaleString()}`
: "Synced",
@@ -70,18 +70,22 @@ function getSyncStatusDot(
};
case "waiting":
return {
color: "bg-yellow-500",
color: "bg-warning",
tooltip: "Waiting to sync",
animate: false,
};
case "error":
return {
color: "bg-red-500",
color: "bg-destructive",
tooltip: errorMessage ? `Sync error: ${errorMessage}` : "Sync error",
animate: false,
};
default:
return { color: "bg-gray-400", tooltip: "Not synced", animate: false };
return {
color: "bg-muted-foreground",
tooltip: "Not synced",
animate: false,
};
}
}
+2 -2
View File
@@ -170,7 +170,7 @@ export function SettingsDialog({
const getStatusBadge = useCallback((isGranted: boolean) => {
if (isGranted) {
return (
<Badge variant="default" className="text-green-800 bg-green-100">
<Badge variant="default" className="text-success-foreground bg-success">
Granted
</Badge>
);
@@ -1018,7 +1018,7 @@ export function SettingsDialog({
</div>
) : (
<div className="space-y-1">
<p className="text-sm font-medium text-orange-600">
<p className="text-sm font-medium text-warning">
Trial expired
</p>
<p className="text-xs text-muted-foreground">
+3 -3
View File
@@ -269,7 +269,7 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
{isLoggedIn && user ? (
<div className="grid gap-4 py-4">
<div className="flex gap-2 items-center text-sm">
<div className="w-2 h-2 rounded-full bg-green-500" />
<div className="w-2 h-2 rounded-full bg-success" />
{t("sync.cloud.connected")}
</div>
@@ -530,13 +530,13 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
)}
{connectionStatus === "connected" && (
<div className="flex gap-2 items-center text-sm text-muted-foreground">
<div className="w-2 h-2 rounded-full bg-green-500" />
<div className="w-2 h-2 rounded-full bg-success" />
{t("sync.status.connected")}
</div>
)}
{connectionStatus === "error" && (
<div className="flex gap-2 items-center text-sm text-muted-foreground">
<div className="w-2 h-2 rounded-full bg-red-500" />
<div className="w-2 h-2 rounded-full bg-destructive" />
{t("sync.status.disconnected")}
</div>
)}
+1 -1
View File
@@ -75,7 +75,7 @@ export function VpnCheckButton({
{isCurrentlyChecking ? (
<div className="w-3 h-3 rounded-full border border-current animate-spin border-t-transparent" />
) : result?.is_valid ? (
<FiCheck className="w-3 h-3 text-green-500" />
<FiCheck className="w-3 h-3 text-success" />
) : result && !result.is_valid ? (
<span className="text-destructive text-sm"></span>
) : (
+5 -5
View File
@@ -295,13 +295,13 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
{step === "vpn-result" && vpnImportResult && (
<div className="space-y-4">
<div
className={`p-4 rounded-lg ${vpnImportResult.success ? "bg-green-500/10" : "bg-red-500/10"}`}
className={`p-4 rounded-lg ${vpnImportResult.success ? "bg-success/10" : "bg-destructive/10"}`}
>
{vpnImportResult.success ? (
<div className="flex items-center gap-3">
<LuShield className="w-8 h-8 text-green-600 dark:text-green-400" />
<LuShield className="w-8 h-8 text-success" />
<div>
<div className="font-medium text-green-600 dark:text-green-400">
<div className="font-medium text-success">
VPN Imported Successfully
</div>
<div className="text-sm text-muted-foreground">
@@ -311,10 +311,10 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
</div>
) : (
<div className="space-y-2">
<div className="font-medium text-red-600 dark:text-red-400">
<div className="font-medium text-destructive">
Import Failed
</div>
<div className="text-sm text-red-600 dark:text-red-400">
<div className="text-sm text-destructive">
{vpnImportResult.error}
</div>
</div>
-42
View File
@@ -3,11 +3,9 @@ import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useRef, useState } from "react";
import { getBrowserDisplayName } from "@/lib/browser-utils";
import {
dismissToast,
showAutoUpdateToast,
showErrorToast,
showSuccessToast,
showUnifiedVersionUpdateToast,
} from "@/lib/toast-utils";
interface VersionUpdateProgress {
@@ -76,53 +74,13 @@ export function useVersionUpdater() {
if (progress.status === "updating") {
setIsUpdating(true);
// Show unified progress toast
const currentBrowserName = progress.current_browser
? getBrowserDisplayName(progress.current_browser)
: undefined;
showUnifiedVersionUpdateToast("Checking for browser updates...", {
description: currentBrowserName
? `Fetching ${currentBrowserName} release information...`
: "Initializing version check...",
progress: {
current: progress.completed_browsers,
total: progress.total_browsers,
found: progress.new_versions_found,
current_browser: currentBrowserName,
},
onCancel: () => dismissToast("unified-version-update"),
});
} else if (progress.status === "completed") {
setIsUpdating(false);
setUpdateProgress(null);
dismissToast("unified-version-update");
if (progress.new_versions_found > 0) {
showSuccessToast("Browser versions updated successfully", {
duration: 5000,
description:
"Auto-downloads will start shortly for available updates.",
});
} else {
showSuccessToast("No new browser versions found", {
duration: 3000,
description: "All browser versions are up to date",
});
}
// Refresh status
void loadUpdateStatus();
} else if (progress.status === "error") {
setIsUpdating(false);
setUpdateProgress(null);
dismissToast("unified-version-update");
showErrorToast("Failed to update browser versions", {
duration: 6000,
description: "Check your internet connection and try again",
});
}
},
);
-5
View File
@@ -541,11 +541,6 @@
"unknownError": "An unknown error occurred. Please try again."
},
"browser": {
"firefox": "Firefox",
"firefoxDeveloper": "Firefox Developer Edition",
"chromium": "Chromium",
"brave": "Brave",
"zen": "Zen Browser",
"camoufox": "Camoufox",
"wayfern": "Wayfern"
},
-5
View File
@@ -541,11 +541,6 @@
"unknownError": "Ocurrió un error desconocido. Por favor intenta de nuevo."
},
"browser": {
"firefox": "Firefox",
"firefoxDeveloper": "Firefox Developer Edition",
"chromium": "Chromium",
"brave": "Brave",
"zen": "Zen Browser",
"camoufox": "Camoufox",
"wayfern": "Wayfern"
},
-5
View File
@@ -541,11 +541,6 @@
"unknownError": "Une erreur inconnue s'est produite. Veuillez réessayer."
},
"browser": {
"firefox": "Firefox",
"firefoxDeveloper": "Firefox Developer Edition",
"chromium": "Chromium",
"brave": "Brave",
"zen": "Zen Browser",
"camoufox": "Camoufox",
"wayfern": "Wayfern"
},
-5
View File
@@ -541,11 +541,6 @@
"unknownError": "不明なエラーが発生しました。もう一度お試しください。"
},
"browser": {
"firefox": "Firefox",
"firefoxDeveloper": "Firefox Developer Edition",
"chromium": "Chromium",
"brave": "Brave",
"zen": "Zen Browser",
"camoufox": "Camoufox",
"wayfern": "Wayfern"
},
-5
View File
@@ -541,11 +541,6 @@
"unknownError": "Ocorreu um erro desconhecido. Por favor, tente novamente."
},
"browser": {
"firefox": "Firefox",
"firefoxDeveloper": "Firefox Developer Edition",
"chromium": "Chromium",
"brave": "Brave",
"zen": "Zen Browser",
"camoufox": "Camoufox",
"wayfern": "Wayfern"
},
-5
View File
@@ -541,11 +541,6 @@
"unknownError": "Произошла неизвестная ошибка. Попробуйте снова."
},
"browser": {
"firefox": "Firefox",
"firefoxDeveloper": "Firefox Developer Edition",
"chromium": "Chromium",
"brave": "Brave",
"zen": "Zen Browser",
"camoufox": "Camoufox",
"wayfern": "Wayfern"
},
-5
View File
@@ -541,11 +541,6 @@
"unknownError": "发生未知错误。请重试。"
},
"browser": {
"firefox": "Firefox",
"firefoxDeveloper": "Firefox 开发者版",
"chromium": "Chromium",
"brave": "Brave",
"zen": "Zen Browser",
"camoufox": "Camoufox",
"wayfern": "Wayfern"
},
-5
View File
@@ -15,11 +15,6 @@ import {
*/
export function getBrowserDisplayName(browserType: string): string {
const browserNames: Record<string, string> = {
firefox: "Firefox",
"firefox-developer": "Firefox Developer Edition",
zen: "Zen Browser",
brave: "Brave",
chromium: "Chromium",
camoufox: "Camoufox",
wayfern: "Wayfern",
};
+48
View File
@@ -15,6 +15,10 @@ export interface ThemeColors extends Record<string, string> {
"--accent-foreground": string;
"--destructive": string;
"--destructive-foreground": string;
"--success": string;
"--success-foreground": string;
"--warning": string;
"--warning-foreground": string;
"--border": string;
"--chart-1": string;
"--chart-2": string;
@@ -50,6 +54,10 @@ export const THEMES: Theme[] = [
"--accent-foreground": "#1a1b26",
"--destructive": "#f7768e",
"--destructive-foreground": "#1a1b26",
"--success": "#9ece6a",
"--success-foreground": "#1a1b26",
"--warning": "#e0af68",
"--warning-foreground": "#1a1b26",
"--border": "#3b4261",
"--chart-1": "#7aa2f7",
"--chart-2": "#9ece6a",
@@ -78,6 +86,10 @@ export const THEMES: Theme[] = [
"--accent-foreground": "#282a36",
"--destructive": "#ff5555",
"--destructive-foreground": "#f8f8f2",
"--success": "#50fa7b",
"--success-foreground": "#282a36",
"--warning": "#ffb86c",
"--warning-foreground": "#282a36",
"--border": "#6272a4",
"--chart-1": "#bd93f9",
"--chart-2": "#50fa7b",
@@ -106,6 +118,10 @@ export const THEMES: Theme[] = [
"--accent-foreground": "#273136",
"--destructive": "#ff819f",
"--destructive-foreground": "#273136",
"--success": "#a8c97f",
"--success-foreground": "#273136",
"--warning": "#e6c07b",
"--warning-foreground": "#273136",
"--border": "#304e37",
"--chart-1": "#7eb08a",
"--chart-2": "#d2b48c",
@@ -134,6 +150,10 @@ export const THEMES: Theme[] = [
"--accent-foreground": "#f7f7f8",
"--destructive": "#ef4444",
"--destructive-foreground": "#f7f7f8",
"--success": "#22c55e",
"--success-foreground": "#17191e",
"--warning": "#f59e0b",
"--warning-foreground": "#17191e",
"--border": "#2a2e39",
"--chart-1": "#5755d9",
"--chart-2": "#0ea5e9",
@@ -162,6 +182,10 @@ export const THEMES: Theme[] = [
"--accent-foreground": "#0a0e14",
"--destructive": "#f07178",
"--destructive-foreground": "#b3b1ad",
"--success": "#c2d94c",
"--success-foreground": "#0a0e14",
"--warning": "#ffb454",
"--warning-foreground": "#0a0e14",
"--border": "#1f2430",
"--chart-1": "#39bae6",
"--chart-2": "#c2d94c",
@@ -190,6 +214,10 @@ export const THEMES: Theme[] = [
"--accent-foreground": "#fafafa",
"--destructive": "#f07178",
"--destructive-foreground": "#fafafa",
"--success": "#86b300",
"--success-foreground": "#fafafa",
"--warning": "#fa8d3e",
"--warning-foreground": "#fafafa",
"--border": "#e7eaed",
"--chart-1": "#399ee6",
"--chart-2": "#86b300",
@@ -218,6 +246,10 @@ export const THEMES: Theme[] = [
"--accent-foreground": "#eff1f5",
"--destructive": "#d20f39",
"--destructive-foreground": "#eff1f5",
"--success": "#40a02b",
"--success-foreground": "#eff1f5",
"--warning": "#df8e1d",
"--warning-foreground": "#eff1f5",
"--border": "#9ca0b0",
"--chart-1": "#1e66f5",
"--chart-2": "#40a02b",
@@ -246,6 +278,10 @@ export const THEMES: Theme[] = [
"--accent-foreground": "#303446",
"--destructive": "#e78284",
"--destructive-foreground": "#303446",
"--success": "#a6d189",
"--success-foreground": "#303446",
"--warning": "#e5c890",
"--warning-foreground": "#303446",
"--border": "#737994",
"--chart-1": "#8caaee",
"--chart-2": "#a6d189",
@@ -274,6 +310,10 @@ export const THEMES: Theme[] = [
"--accent-foreground": "#24273a",
"--destructive": "#ed8796",
"--destructive-foreground": "#24273a",
"--success": "#a6da95",
"--success-foreground": "#24273a",
"--warning": "#eed49f",
"--warning-foreground": "#24273a",
"--border": "#6e738d",
"--chart-1": "#8aadf4",
"--chart-2": "#a6da95",
@@ -302,6 +342,10 @@ export const THEMES: Theme[] = [
"--accent-foreground": "#1e1e2e",
"--destructive": "#f38ba8",
"--destructive-foreground": "#1e1e2e",
"--success": "#a6e3a1",
"--success-foreground": "#1e1e2e",
"--warning": "#f9e2af",
"--warning-foreground": "#1e1e2e",
"--border": "#585b70",
"--chart-1": "#89b4fa",
"--chart-2": "#a6e3a1",
@@ -330,6 +374,10 @@ export const THEME_VARIABLES: Array<{ key: keyof ThemeColors; label: string }> =
{ key: "--accent-foreground", label: "Accent FG" },
{ key: "--destructive", label: "Destructive" },
{ key: "--destructive-foreground", label: "Destructive FG" },
{ key: "--success", label: "Success" },
{ key: "--success-foreground", label: "Success FG" },
{ key: "--warning", label: "Warning" },
{ key: "--warning-foreground", label: "Warning FG" },
{ key: "--border", label: "Border" },
{ key: "--chart-1", label: "Chart 1" },
{ key: "--chart-2", label: "Chart 2" },
+2 -5
View File
@@ -82,13 +82,10 @@ export function showToast(props: ToastProps & { id?: string }) {
duration = 10000;
break;
case "download":
// Only keep infinite for active downloading, others get shorter durations
if ("stage" in props && props.stage === "downloading") {
duration = Number.POSITIVE_INFINITY;
} else if ("stage" in props && props.stage === "completed") {
if ("stage" in props && props.stage === "completed") {
duration = 3000;
} else {
duration = 20000;
duration = Number.POSITIVE_INFINITY;
}
break;
case "success":
+15
View File
@@ -28,6 +28,11 @@
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
@@ -63,6 +68,11 @@
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--success: oklch(0.6 0.2 145);
--success-foreground: oklch(0.985 0 0);
--warning: oklch(0.75 0.15 75);
--warning-foreground: oklch(0.141 0.005 285.823);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
@@ -102,6 +112,11 @@
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--success: oklch(0.7 0.2 145);
--success-foreground: oklch(0.141 0.005 285.823);
--warning: oklch(0.8 0.15 75);
--warning-foreground: oklch(0.141 0.005 285.823);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
+1
View File
@@ -161,6 +161,7 @@ export interface DetectedProfile {
name: string;
path: string;
description: string;
mapped_browser: string;
}
export interface BrowserReleaseTypes {