mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-04-25 05:16:18 +02:00
feat: add cookies copying functionality
This commit is contained in:
@@ -765,9 +765,31 @@ impl BrowserRunner {
|
||||
profile.id
|
||||
);
|
||||
|
||||
// For Camoufox, we need to launch a new instance with the URL since it doesn't support remote commands
|
||||
// This is a limitation of Camoufox's architecture
|
||||
return Err("Camoufox doesn't support opening URLs in existing instances. Please close the browser and launch again with the URL.".into());
|
||||
// Get Camoufox executable path and use Firefox-like remote mechanism
|
||||
let executable_path = self
|
||||
.get_browser_executable_path(profile)
|
||||
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?;
|
||||
|
||||
// Launch Camoufox with -profile and -new-tab to open URL in existing instance
|
||||
// This works because we no longer use -no-remote flag
|
||||
let output = std::process::Command::new(&executable_path)
|
||||
.arg("-profile")
|
||||
.arg(&*profile_path_str)
|
||||
.arg("-new-tab")
|
||||
.arg(url)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute Camoufox: {e}"))?;
|
||||
|
||||
if output.status.success() {
|
||||
log::info!("Successfully opened URL in existing Camoufox instance");
|
||||
return Ok(());
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
log::warn!("Camoufox -new-tab command failed: {stderr}");
|
||||
return Err(
|
||||
format!("Failed to open URL in existing Camoufox instance: {stderr}").into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
return Err("Camoufox browser is not running".into());
|
||||
@@ -797,8 +819,12 @@ impl BrowserRunner {
|
||||
profile.id
|
||||
);
|
||||
|
||||
// For Wayfern, we can use CDP to navigate to the URL
|
||||
return Err("Wayfern doesn't currently support opening URLs in existing instances. Please close the browser and launch again with the URL.".into());
|
||||
// Use CDP to open URL in a new tab
|
||||
self
|
||||
.wayfern_manager
|
||||
.open_url_in_tab(&profile_path_str, url)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
None => {
|
||||
return Err("Wayfern browser is not running".into());
|
||||
|
||||
@@ -221,6 +221,9 @@ impl CamoufoxManager {
|
||||
.map_err(|e| format!("Failed to convert config to env vars: {e}"))?;
|
||||
|
||||
// Build command arguments
|
||||
// Note: We intentionally do NOT use -no-remote to allow opening URLs in existing instances
|
||||
// via Firefox's remote messaging mechanism. This enables "open in new tab" functionality
|
||||
// when Donut is set as the default browser.
|
||||
let mut args = vec![
|
||||
"-profile".to_string(),
|
||||
std::path::Path::new(profile_path)
|
||||
@@ -228,7 +231,6 @@ impl CamoufoxManager {
|
||||
.unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf())
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
"-no-remote".to_string(),
|
||||
];
|
||||
|
||||
// Add URL if provided
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
use crate::profile::manager::ProfileManager;
|
||||
use crate::profile::BrowserProfile;
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::AppHandle;
|
||||
|
||||
/// Unified cookie representation that works across both browser types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UnifiedCookie {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
pub domain: String,
|
||||
pub path: String,
|
||||
pub expires: i64,
|
||||
pub is_secure: bool,
|
||||
pub is_http_only: bool,
|
||||
pub same_site: i32,
|
||||
pub creation_time: i64,
|
||||
pub last_accessed: i64,
|
||||
}
|
||||
|
||||
/// Cookies grouped by domain for UI display
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DomainCookies {
|
||||
pub domain: String,
|
||||
pub cookies: Vec<UnifiedCookie>,
|
||||
pub cookie_count: usize,
|
||||
}
|
||||
|
||||
/// Result of reading cookies from a profile
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CookieReadResult {
|
||||
pub profile_id: String,
|
||||
pub browser_type: String,
|
||||
pub domains: Vec<DomainCookies>,
|
||||
pub total_count: usize,
|
||||
}
|
||||
|
||||
/// Request to copy specific cookies
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CookieCopyRequest {
|
||||
pub source_profile_id: String,
|
||||
pub target_profile_ids: Vec<String>,
|
||||
pub selected_cookies: Vec<SelectedCookie>,
|
||||
}
|
||||
|
||||
/// Identifies a specific cookie to copy
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SelectedCookie {
|
||||
pub domain: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Result of a copy operation
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CookieCopyResult {
|
||||
pub target_profile_id: String,
|
||||
pub cookies_copied: usize,
|
||||
pub cookies_replaced: usize,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct CookieManager;
|
||||
|
||||
impl CookieManager {
|
||||
/// Windows epoch offset: seconds between 1601-01-01 and 1970-01-01
|
||||
const WINDOWS_EPOCH_DIFF: i64 = 11644473600;
|
||||
|
||||
/// Get the cookie database path for a profile
|
||||
fn get_cookie_db_path(profile: &BrowserProfile, profiles_dir: &Path) -> Result<PathBuf, String> {
|
||||
let profile_data_path = profile.get_profile_data_path(profiles_dir);
|
||||
|
||||
match profile.browser.as_str() {
|
||||
"wayfern" => {
|
||||
let path = profile_data_path.join("Default").join("Cookies");
|
||||
if path.exists() {
|
||||
Ok(path)
|
||||
} else {
|
||||
Err(format!("Cookie database not found at: {}", path.display()))
|
||||
}
|
||||
}
|
||||
"camoufox" => {
|
||||
let path = profile_data_path.join("cookies.sqlite");
|
||||
if path.exists() {
|
||||
Ok(path)
|
||||
} else {
|
||||
Err(format!("Cookie database not found at: {}", path.display()))
|
||||
}
|
||||
}
|
||||
_ => Err(format!(
|
||||
"Unsupported browser type for cookie operations: {}",
|
||||
profile.browser
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert Chrome timestamp (Windows epoch, microseconds) to Unix timestamp (seconds)
|
||||
fn chrome_time_to_unix(chrome_time: i64) -> i64 {
|
||||
if chrome_time == 0 {
|
||||
return 0;
|
||||
}
|
||||
(chrome_time / 1_000_000) - Self::WINDOWS_EPOCH_DIFF
|
||||
}
|
||||
|
||||
/// Convert Unix timestamp (seconds) to Chrome timestamp (Windows epoch, microseconds)
|
||||
fn unix_to_chrome_time(unix_time: i64) -> i64 {
|
||||
if unix_time == 0 {
|
||||
return 0;
|
||||
}
|
||||
(unix_time + Self::WINDOWS_EPOCH_DIFF) * 1_000_000
|
||||
}
|
||||
|
||||
/// Read cookies from a Firefox/Camoufox profile
|
||||
fn read_firefox_cookies(db_path: &Path) -> Result<Vec<UnifiedCookie>, String> {
|
||||
let conn = Connection::open(db_path).map_err(|e| format!("Failed to open database: {e}"))?;
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT name, value, host, path, expiry, isSecure, isHttpOnly,
|
||||
sameSite, creationTime, lastAccessed
|
||||
FROM moz_cookies",
|
||||
)
|
||||
.map_err(|e| format!("Failed to prepare statement: {e}"))?;
|
||||
|
||||
let cookies = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(UnifiedCookie {
|
||||
name: row.get(0)?,
|
||||
value: row.get(1)?,
|
||||
domain: row.get(2)?,
|
||||
path: row.get(3)?,
|
||||
expires: row.get(4)?,
|
||||
is_secure: row.get::<_, i32>(5)? != 0,
|
||||
is_http_only: row.get::<_, i32>(6)? != 0,
|
||||
same_site: row.get(7)?,
|
||||
creation_time: row.get::<_, i64>(8)? / 1_000_000,
|
||||
last_accessed: row.get::<_, i64>(9)? / 1_000_000,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Failed to query cookies: {e}"))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| format!("Failed to collect cookies: {e}"))?;
|
||||
|
||||
Ok(cookies)
|
||||
}
|
||||
|
||||
/// Read cookies from a Chrome/Wayfern profile
|
||||
fn read_chrome_cookies(db_path: &Path) -> Result<Vec<UnifiedCookie>, String> {
|
||||
let conn = Connection::open(db_path).map_err(|e| format!("Failed to open database: {e}"))?;
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT name, value, host_key, path, expires_utc, is_secure,
|
||||
is_httponly, samesite, creation_utc, last_access_utc
|
||||
FROM cookies",
|
||||
)
|
||||
.map_err(|e| format!("Failed to prepare statement: {e}"))?;
|
||||
|
||||
let cookies = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(UnifiedCookie {
|
||||
name: row.get(0)?,
|
||||
value: row.get(1)?,
|
||||
domain: row.get(2)?,
|
||||
path: row.get(3)?,
|
||||
expires: Self::chrome_time_to_unix(row.get(4)?),
|
||||
is_secure: row.get::<_, i32>(5)? != 0,
|
||||
is_http_only: row.get::<_, i32>(6)? != 0,
|
||||
same_site: row.get(7)?,
|
||||
creation_time: Self::chrome_time_to_unix(row.get(8)?),
|
||||
last_accessed: Self::chrome_time_to_unix(row.get(9)?),
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Failed to query cookies: {e}"))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| format!("Failed to collect cookies: {e}"))?;
|
||||
|
||||
Ok(cookies)
|
||||
}
|
||||
|
||||
/// Write cookies to a Firefox/Camoufox profile
|
||||
fn write_firefox_cookies(
|
||||
db_path: &Path,
|
||||
cookies: &[UnifiedCookie],
|
||||
) -> Result<(usize, usize), String> {
|
||||
let conn = Connection::open(db_path).map_err(|e| format!("Failed to open database: {e}"))?;
|
||||
|
||||
let mut copied = 0;
|
||||
let mut replaced = 0;
|
||||
|
||||
for cookie in cookies {
|
||||
let existing: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT id FROM moz_cookies WHERE host = ?1 AND name = ?2 AND path = ?3",
|
||||
params![&cookie.domain, &cookie.name, &cookie.path],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.ok();
|
||||
|
||||
if existing.is_some() {
|
||||
conn
|
||||
.execute(
|
||||
"UPDATE moz_cookies SET value = ?1, expiry = ?2, isSecure = ?3,
|
||||
isHttpOnly = ?4, sameSite = ?5, lastAccessed = ?6
|
||||
WHERE host = ?7 AND name = ?8 AND path = ?9",
|
||||
params![
|
||||
&cookie.value,
|
||||
cookie.expires,
|
||||
cookie.is_secure as i32,
|
||||
cookie.is_http_only as i32,
|
||||
cookie.same_site,
|
||||
cookie.last_accessed * 1_000_000,
|
||||
&cookie.domain,
|
||||
&cookie.name,
|
||||
&cookie.path,
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update cookie: {e}"))?;
|
||||
replaced += 1;
|
||||
} else {
|
||||
conn
|
||||
.execute(
|
||||
"INSERT INTO moz_cookies
|
||||
(originAttributes, name, value, host, path, expiry, lastAccessed,
|
||||
creationTime, isSecure, isHttpOnly, sameSite, rawSameSite, schemeMap)
|
||||
VALUES ('', ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?10, 2)",
|
||||
params![
|
||||
&cookie.name,
|
||||
&cookie.value,
|
||||
&cookie.domain,
|
||||
&cookie.path,
|
||||
cookie.expires,
|
||||
cookie.last_accessed * 1_000_000,
|
||||
cookie.creation_time * 1_000_000,
|
||||
cookie.is_secure as i32,
|
||||
cookie.is_http_only as i32,
|
||||
cookie.same_site,
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Failed to insert cookie: {e}"))?;
|
||||
copied += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok((copied, replaced))
|
||||
}
|
||||
|
||||
/// Write cookies to a Chrome/Wayfern profile
|
||||
fn write_chrome_cookies(
|
||||
db_path: &Path,
|
||||
cookies: &[UnifiedCookie],
|
||||
) -> Result<(usize, usize), String> {
|
||||
let conn = Connection::open(db_path).map_err(|e| format!("Failed to open database: {e}"))?;
|
||||
|
||||
let mut copied = 0;
|
||||
let mut replaced = 0;
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
|
||||
for cookie in cookies {
|
||||
let existing: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT rowid FROM cookies WHERE host_key = ?1 AND name = ?2 AND path = ?3",
|
||||
params![&cookie.domain, &cookie.name, &cookie.path],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.ok();
|
||||
|
||||
if existing.is_some() {
|
||||
conn
|
||||
.execute(
|
||||
"UPDATE cookies SET value = ?1, expires_utc = ?2, is_secure = ?3,
|
||||
is_httponly = ?4, samesite = ?5, last_access_utc = ?6, last_update_utc = ?7
|
||||
WHERE host_key = ?8 AND name = ?9 AND path = ?10",
|
||||
params![
|
||||
&cookie.value,
|
||||
Self::unix_to_chrome_time(cookie.expires),
|
||||
cookie.is_secure as i32,
|
||||
cookie.is_http_only as i32,
|
||||
cookie.same_site,
|
||||
Self::unix_to_chrome_time(cookie.last_accessed),
|
||||
Self::unix_to_chrome_time(now),
|
||||
&cookie.domain,
|
||||
&cookie.name,
|
||||
&cookie.path,
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update cookie: {e}"))?;
|
||||
replaced += 1;
|
||||
} else {
|
||||
conn.execute(
|
||||
"INSERT INTO cookies
|
||||
(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value,
|
||||
path, expires_utc, is_secure, is_httponly, last_access_utc, has_expires,
|
||||
is_persistent, priority, samesite, source_scheme, source_port, source_type,
|
||||
has_cross_site_ancestor, last_update_utc)
|
||||
VALUES (?1, ?2, '', ?3, ?4, X'', ?5, ?6, ?7, ?8, ?9, 1, 1, 1, ?10, 2, -1, 0, 0, ?11)",
|
||||
params![
|
||||
Self::unix_to_chrome_time(cookie.creation_time),
|
||||
&cookie.domain,
|
||||
&cookie.name,
|
||||
&cookie.value,
|
||||
&cookie.path,
|
||||
Self::unix_to_chrome_time(cookie.expires),
|
||||
cookie.is_secure as i32,
|
||||
cookie.is_http_only as i32,
|
||||
Self::unix_to_chrome_time(cookie.last_accessed),
|
||||
cookie.same_site,
|
||||
Self::unix_to_chrome_time(now),
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Failed to insert cookie: {e}"))?;
|
||||
copied += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok((copied, replaced))
|
||||
}
|
||||
|
||||
/// Public API: Read cookies from a profile
|
||||
pub fn read_cookies(profile_id: &str) -> Result<CookieReadResult, String> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profiles_dir = profile_manager.get_profiles_dir();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
||||
|
||||
let profile = profiles
|
||||
.iter()
|
||||
.find(|p| p.id.to_string() == profile_id)
|
||||
.ok_or_else(|| format!("Profile not found: {profile_id}"))?;
|
||||
|
||||
let db_path = Self::get_cookie_db_path(profile, &profiles_dir)?;
|
||||
|
||||
let cookies = match profile.browser.as_str() {
|
||||
"camoufox" => Self::read_firefox_cookies(&db_path)?,
|
||||
"wayfern" => Self::read_chrome_cookies(&db_path)?,
|
||||
_ => return Err(format!("Unsupported browser type: {}", profile.browser)),
|
||||
};
|
||||
|
||||
let mut domain_map: HashMap<String, Vec<UnifiedCookie>> = HashMap::new();
|
||||
|
||||
for cookie in cookies {
|
||||
domain_map
|
||||
.entry(cookie.domain.clone())
|
||||
.or_default()
|
||||
.push(cookie);
|
||||
}
|
||||
|
||||
let mut domains: Vec<DomainCookies> = domain_map
|
||||
.into_iter()
|
||||
.map(|(domain, cookies)| DomainCookies {
|
||||
domain,
|
||||
cookie_count: cookies.len(),
|
||||
cookies,
|
||||
})
|
||||
.collect();
|
||||
|
||||
domains.sort_by(|a, b| a.domain.cmp(&b.domain));
|
||||
|
||||
let total_count = domains.iter().map(|d| d.cookie_count).sum();
|
||||
|
||||
Ok(CookieReadResult {
|
||||
profile_id: profile_id.to_string(),
|
||||
browser_type: profile.browser.clone(),
|
||||
domains,
|
||||
total_count,
|
||||
})
|
||||
}
|
||||
|
||||
/// Public API: Copy cookies between profiles
|
||||
pub async fn copy_cookies(
|
||||
app_handle: &AppHandle,
|
||||
request: CookieCopyRequest,
|
||||
) -> Result<Vec<CookieCopyResult>, String> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profiles_dir = profile_manager.get_profiles_dir();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
||||
|
||||
let source = profiles
|
||||
.iter()
|
||||
.find(|p| p.id.to_string() == request.source_profile_id)
|
||||
.ok_or_else(|| format!("Source profile not found: {}", request.source_profile_id))?;
|
||||
|
||||
let source_db_path = Self::get_cookie_db_path(source, &profiles_dir)?;
|
||||
let all_cookies = match source.browser.as_str() {
|
||||
"camoufox" => Self::read_firefox_cookies(&source_db_path)?,
|
||||
"wayfern" => Self::read_chrome_cookies(&source_db_path)?,
|
||||
_ => return Err(format!("Unsupported browser type: {}", source.browser)),
|
||||
};
|
||||
|
||||
let cookies_to_copy: Vec<UnifiedCookie> = if request.selected_cookies.is_empty() {
|
||||
all_cookies
|
||||
} else {
|
||||
all_cookies
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
request.selected_cookies.iter().any(|s| {
|
||||
if s.name.is_empty() {
|
||||
c.domain == s.domain
|
||||
} else {
|
||||
c.domain == s.domain && c.name == s.name
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
for target_id in &request.target_profile_ids {
|
||||
let target = match profiles.iter().find(|p| p.id.to_string() == *target_id) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
results.push(CookieCopyResult {
|
||||
target_profile_id: target_id.clone(),
|
||||
cookies_copied: 0,
|
||||
cookies_replaced: 0,
|
||||
errors: vec![format!("Profile not found: {target_id}")],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let is_running = profile_manager
|
||||
.check_browser_status(app_handle.clone(), target)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_running {
|
||||
results.push(CookieCopyResult {
|
||||
target_profile_id: target_id.clone(),
|
||||
cookies_copied: 0,
|
||||
cookies_replaced: 0,
|
||||
errors: vec![format!("Browser is running for profile: {}", target.name)],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let target_db_path = match Self::get_cookie_db_path(target, &profiles_dir) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
results.push(CookieCopyResult {
|
||||
target_profile_id: target_id.clone(),
|
||||
cookies_copied: 0,
|
||||
cookies_replaced: 0,
|
||||
errors: vec![e],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let write_result = match target.browser.as_str() {
|
||||
"camoufox" => Self::write_firefox_cookies(&target_db_path, &cookies_to_copy),
|
||||
"wayfern" => Self::write_chrome_cookies(&target_db_path, &cookies_to_copy),
|
||||
_ => {
|
||||
results.push(CookieCopyResult {
|
||||
target_profile_id: target_id.clone(),
|
||||
cookies_copied: 0,
|
||||
cookies_replaced: 0,
|
||||
errors: vec![format!("Unsupported browser: {}", target.browser)],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match write_result {
|
||||
Ok((copied, replaced)) => {
|
||||
results.push(CookieCopyResult {
|
||||
target_profile_id: target_id.clone(),
|
||||
cookies_copied: copied,
|
||||
cookies_replaced: replaced,
|
||||
errors: vec![],
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
results.push(CookieCopyResult {
|
||||
target_profile_id: target_id.clone(),
|
||||
cookies_copied: 0,
|
||||
cookies_replaced: 0,
|
||||
errors: vec![e],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
+17
-1
@@ -35,6 +35,7 @@ pub mod sync;
|
||||
pub mod traffic_stats;
|
||||
mod wayfern_manager;
|
||||
// mod theme_detector; // removed: theme detection handled in webview via CSS prefers-color-scheme
|
||||
mod cookie_manager;
|
||||
mod tag_manager;
|
||||
mod version_updater;
|
||||
|
||||
@@ -221,6 +222,19 @@ fn get_cached_proxy_check(proxy_id: String) -> Option<crate::proxy_manager::Prox
|
||||
crate::proxy_manager::PROXY_MANAGER.get_cached_proxy_check(&proxy_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn read_profile_cookies(profile_id: String) -> Result<cookie_manager::CookieReadResult, String> {
|
||||
cookie_manager::CookieManager::read_cookies(&profile_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn copy_profile_cookies(
|
||||
app_handle: tauri::AppHandle,
|
||||
request: cookie_manager::CookieCopyRequest,
|
||||
) -> Result<Vec<cookie_manager::CookieCopyResult>, String> {
|
||||
cookie_manager::CookieManager::copy_cookies(&app_handle, request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn is_geoip_database_available() -> Result<bool, String> {
|
||||
Ok(GeoIPDownloader::is_geoip_database_available())
|
||||
@@ -825,7 +839,9 @@ pub fn run() {
|
||||
set_proxy_sync_enabled,
|
||||
set_group_sync_enabled,
|
||||
is_proxy_in_use_by_synced_profile,
|
||||
is_group_in_use_by_synced_profile
|
||||
is_group_in_use_by_synced_profile,
|
||||
read_profile_cookies,
|
||||
copy_profile_cookies
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -500,6 +500,45 @@ impl WayfernManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Opens a URL in a new tab for an existing Wayfern instance using CDP.
|
||||
/// Returns Ok(()) if successful, or an error if the instance is not found or CDP fails.
|
||||
pub async fn open_url_in_tab(
|
||||
&self,
|
||||
profile_path: &str,
|
||||
url: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let instance = self
|
||||
.find_wayfern_by_profile(profile_path)
|
||||
.await
|
||||
.ok_or("Wayfern instance not found for profile")?;
|
||||
|
||||
let cdp_port = instance
|
||||
.cdp_port
|
||||
.ok_or("No CDP port available for Wayfern instance")?;
|
||||
|
||||
// Get the browser target to create a new tab
|
||||
let targets = self.get_cdp_targets(cdp_port).await?;
|
||||
|
||||
// Find a page target to get the WebSocket URL (we need any target to send commands)
|
||||
let page_target = targets
|
||||
.iter()
|
||||
.find(|t| t.target_type == "page" && t.websocket_debugger_url.is_some())
|
||||
.ok_or("No page target found for CDP")?;
|
||||
|
||||
let ws_url = page_target
|
||||
.websocket_debugger_url
|
||||
.as_ref()
|
||||
.ok_or("No WebSocket URL available")?;
|
||||
|
||||
// Use Target.createTarget to open a new tab with the URL
|
||||
self
|
||||
.send_cdp_command(ws_url, "Target.createTarget", json!({ "url": url }))
|
||||
.await?;
|
||||
|
||||
log::info!("Opened URL in new tab via CDP: {}", url);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn find_wayfern_by_profile(&self, profile_path: &str) -> Option<WayfernLaunchResult> {
|
||||
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrent } from "@tauri-apps/plugin-deep-link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
|
||||
import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
|
||||
import { CreateProfileDialog } from "@/components/create-profile-dialog";
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
||||
import { GroupAssignmentDialog } from "@/components/group-assignment-dialog";
|
||||
@@ -82,6 +83,10 @@ export default function Home() {
|
||||
useState(false);
|
||||
const [proxyAssignmentDialogOpen, setProxyAssignmentDialogOpen] =
|
||||
useState(false);
|
||||
const [cookieCopyDialogOpen, setCookieCopyDialogOpen] = useState(false);
|
||||
const [selectedProfilesForCookies, setSelectedProfilesForCookies] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string>("default");
|
||||
const [selectedProfilesForGroup, setSelectedProfilesForGroup] = useState<
|
||||
string[]
|
||||
@@ -585,6 +590,28 @@ export default function Home() {
|
||||
setSelectedProfiles([]);
|
||||
}, [selectedProfiles, handleAssignProfilesToProxy]);
|
||||
|
||||
const handleBulkCopyCookies = useCallback(() => {
|
||||
if (selectedProfiles.length === 0) return;
|
||||
const eligibleProfiles = profiles.filter(
|
||||
(p) =>
|
||||
selectedProfiles.includes(p.id) &&
|
||||
(p.browser === "wayfern" || p.browser === "camoufox"),
|
||||
);
|
||||
if (eligibleProfiles.length === 0) {
|
||||
showErrorToast(
|
||||
"Cookie copy only works with Wayfern and Camoufox profiles",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSelectedProfilesForCookies(eligibleProfiles.map((p) => p.id));
|
||||
setCookieCopyDialogOpen(true);
|
||||
}, [selectedProfiles, profiles]);
|
||||
|
||||
const handleCopyCookiesToProfile = useCallback((profile: BrowserProfile) => {
|
||||
setSelectedProfilesForCookies([profile.id]);
|
||||
setCookieCopyDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleGroupAssignmentComplete = useCallback(async () => {
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
setGroupAssignmentDialogOpen(false);
|
||||
@@ -780,6 +807,7 @@ export default function Home() {
|
||||
onDeleteProfile={handleDeleteProfile}
|
||||
onRenameProfile={handleRenameProfile}
|
||||
onConfigureCamoufox={handleConfigureCamoufox}
|
||||
onCopyCookiesToProfile={handleCopyCookiesToProfile}
|
||||
runningProfiles={runningProfiles}
|
||||
isUpdating={isUpdating}
|
||||
onDeleteSelectedProfiles={handleDeleteSelectedProfiles}
|
||||
@@ -790,6 +818,7 @@ export default function Home() {
|
||||
onBulkDelete={handleBulkDelete}
|
||||
onBulkGroupAssignment={handleBulkGroupAssignment}
|
||||
onBulkProxyAssignment={handleBulkProxyAssignment}
|
||||
onBulkCopyCookies={handleBulkCopyCookies}
|
||||
onOpenProfileSyncDialog={handleOpenProfileSyncDialog}
|
||||
onToggleProfileSync={handleToggleProfileSync}
|
||||
/>
|
||||
@@ -894,6 +923,18 @@ export default function Home() {
|
||||
storedProxies={storedProxies}
|
||||
/>
|
||||
|
||||
<CookieCopyDialog
|
||||
isOpen={cookieCopyDialogOpen}
|
||||
onClose={() => {
|
||||
setCookieCopyDialogOpen(false);
|
||||
setSelectedProfilesForCookies([]);
|
||||
}}
|
||||
selectedProfiles={selectedProfilesForCookies}
|
||||
profiles={profiles}
|
||||
runningProfiles={runningProfiles}
|
||||
onCopyComplete={() => setSelectedProfilesForCookies([])}
|
||||
/>
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={showBulkDeleteConfirmation}
|
||||
onClose={() => setShowBulkDeleteConfirmation(false)}
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
LuChevronDown,
|
||||
LuChevronRight,
|
||||
LuCookie,
|
||||
LuSearch,
|
||||
} from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { getBrowserIcon } from "@/lib/browser-utils";
|
||||
import type {
|
||||
BrowserProfile,
|
||||
CookieCopyRequest,
|
||||
CookieCopyResult,
|
||||
CookieReadResult,
|
||||
DomainCookies,
|
||||
SelectedCookie,
|
||||
UnifiedCookie,
|
||||
} from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface CookieCopyDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
selectedProfiles: string[];
|
||||
profiles: BrowserProfile[];
|
||||
runningProfiles: Set<string>;
|
||||
onCopyComplete?: () => void;
|
||||
}
|
||||
|
||||
type SelectionState = {
|
||||
[domain: string]: {
|
||||
allSelected: boolean;
|
||||
cookies: Set<string>;
|
||||
};
|
||||
};
|
||||
|
||||
export function CookieCopyDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
selectedProfiles,
|
||||
profiles,
|
||||
runningProfiles,
|
||||
onCopyComplete,
|
||||
}: CookieCopyDialogProps) {
|
||||
const [sourceProfileId, setSourceProfileId] = useState<string | null>(null);
|
||||
const [cookieData, setCookieData] = useState<CookieReadResult | null>(null);
|
||||
const [isLoadingCookies, setIsLoadingCookies] = useState(false);
|
||||
const [isCopying, setIsCopying] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selection, setSelection] = useState<SelectionState>({});
|
||||
const [expandedDomains, setExpandedDomains] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const eligibleSourceProfiles = useMemo(() => {
|
||||
return profiles.filter(
|
||||
(p) => p.browser === "wayfern" || p.browser === "camoufox",
|
||||
);
|
||||
}, [profiles]);
|
||||
|
||||
const targetProfiles = useMemo(() => {
|
||||
return profiles.filter(
|
||||
(p) =>
|
||||
selectedProfiles.includes(p.id) &&
|
||||
p.id !== sourceProfileId &&
|
||||
(p.browser === "wayfern" || p.browser === "camoufox"),
|
||||
);
|
||||
}, [profiles, selectedProfiles, sourceProfileId]);
|
||||
|
||||
const filteredDomains = useMemo(() => {
|
||||
if (!cookieData) return [];
|
||||
if (!searchQuery.trim()) return cookieData.domains;
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
return cookieData.domains.filter(
|
||||
(d) =>
|
||||
d.domain.toLowerCase().includes(query) ||
|
||||
d.cookies.some((c) => c.name.toLowerCase().includes(query)),
|
||||
);
|
||||
}, [cookieData, searchQuery]);
|
||||
|
||||
const selectedCookieCount = useMemo(() => {
|
||||
let count = 0;
|
||||
for (const domain of Object.keys(selection)) {
|
||||
const domainSelection = selection[domain];
|
||||
if (domainSelection.allSelected) {
|
||||
const domainData = cookieData?.domains.find((d) => d.domain === domain);
|
||||
count += domainData?.cookie_count || 0;
|
||||
} else {
|
||||
count += domainSelection.cookies.size;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}, [selection, cookieData]);
|
||||
|
||||
const loadCookies = useCallback(async (profileId: string) => {
|
||||
setIsLoadingCookies(true);
|
||||
setError(null);
|
||||
setCookieData(null);
|
||||
setSelection({});
|
||||
|
||||
try {
|
||||
const result = await invoke<CookieReadResult>("read_profile_cookies", {
|
||||
profileId,
|
||||
});
|
||||
setCookieData(result);
|
||||
} catch (err) {
|
||||
console.error("Failed to load cookies:", err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setIsLoadingCookies(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSourceChange = useCallback(
|
||||
(profileId: string) => {
|
||||
setSourceProfileId(profileId);
|
||||
void loadCookies(profileId);
|
||||
},
|
||||
[loadCookies],
|
||||
);
|
||||
|
||||
const toggleDomain = useCallback(
|
||||
(domain: string, cookies: UnifiedCookie[]) => {
|
||||
setSelection((prev) => {
|
||||
const current = prev[domain];
|
||||
const allSelected = current?.allSelected || false;
|
||||
|
||||
if (allSelected) {
|
||||
const newSelection = { ...prev };
|
||||
delete newSelection[domain];
|
||||
return newSelection;
|
||||
} else {
|
||||
return {
|
||||
...prev,
|
||||
[domain]: {
|
||||
allSelected: true,
|
||||
cookies: new Set(cookies.map((c) => c.name)),
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const toggleCookie = useCallback(
|
||||
(domain: string, cookieName: string, totalCookies: number) => {
|
||||
setSelection((prev) => {
|
||||
const current = prev[domain] || {
|
||||
allSelected: false,
|
||||
cookies: new Set<string>(),
|
||||
};
|
||||
const newCookies = new Set(current.cookies);
|
||||
|
||||
if (newCookies.has(cookieName)) {
|
||||
newCookies.delete(cookieName);
|
||||
} else {
|
||||
newCookies.add(cookieName);
|
||||
}
|
||||
|
||||
const allSelected = newCookies.size === totalCookies;
|
||||
|
||||
if (newCookies.size === 0) {
|
||||
const newSelection = { ...prev };
|
||||
delete newSelection[domain];
|
||||
return newSelection;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[domain]: {
|
||||
allSelected,
|
||||
cookies: newCookies,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const toggleExpand = useCallback((domain: string) => {
|
||||
setExpandedDomains((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(domain)) {
|
||||
next.delete(domain);
|
||||
} else {
|
||||
next.add(domain);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const buildSelectedCookies = useCallback((): SelectedCookie[] => {
|
||||
const result: SelectedCookie[] = [];
|
||||
|
||||
for (const [domain, domainSelection] of Object.entries(selection)) {
|
||||
if (domainSelection.allSelected) {
|
||||
result.push({ domain, name: "" });
|
||||
} else {
|
||||
for (const cookieName of domainSelection.cookies) {
|
||||
result.push({ domain, name: cookieName });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [selection]);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (!sourceProfileId || targetProfiles.length === 0) return;
|
||||
|
||||
const runningTargets = targetProfiles.filter((p) =>
|
||||
runningProfiles.has(p.id),
|
||||
);
|
||||
if (runningTargets.length > 0) {
|
||||
toast.error(
|
||||
`Cannot copy cookies: ${runningTargets.map((p) => p.name).join(", ")} ${
|
||||
runningTargets.length === 1 ? "is" : "are"
|
||||
} still running`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCopying(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const selectedCookies = buildSelectedCookies();
|
||||
const request: CookieCopyRequest = {
|
||||
source_profile_id: sourceProfileId,
|
||||
target_profile_ids: targetProfiles.map((p) => p.id),
|
||||
selected_cookies: selectedCookies,
|
||||
};
|
||||
|
||||
const results = await invoke<CookieCopyResult[]>("copy_profile_cookies", {
|
||||
request,
|
||||
});
|
||||
|
||||
let totalCopied = 0;
|
||||
let totalReplaced = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const result of results) {
|
||||
totalCopied += result.cookies_copied;
|
||||
totalReplaced += result.cookies_replaced;
|
||||
errors.push(...result.errors);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
toast.error(`Some errors occurred: ${errors.join(", ")}`);
|
||||
} else {
|
||||
toast.success(
|
||||
`Successfully copied ${totalCopied + totalReplaced} cookies (${totalReplaced} replaced)`,
|
||||
);
|
||||
onCopyComplete?.();
|
||||
onClose();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to copy cookies:", err);
|
||||
toast.error(
|
||||
`Failed to copy cookies: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
} finally {
|
||||
setIsCopying(false);
|
||||
}
|
||||
}, [
|
||||
sourceProfileId,
|
||||
targetProfiles,
|
||||
runningProfiles,
|
||||
buildSelectedCookies,
|
||||
onCopyComplete,
|
||||
onClose,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSourceProfileId(null);
|
||||
setCookieData(null);
|
||||
setSelection({});
|
||||
setSearchQuery("");
|
||||
setExpandedDomains(new Set());
|
||||
setError(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const canCopy =
|
||||
sourceProfileId &&
|
||||
targetProfiles.length > 0 &&
|
||||
selectedCookieCount > 0 &&
|
||||
!isCopying;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<LuCookie className="w-5 h-5" />
|
||||
Copy Cookies
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Copy cookies from a source profile to {selectedProfiles.length}{" "}
|
||||
selected profile{selectedProfiles.length !== 1 ? "s" : ""}.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Source Profile</Label>
|
||||
<Select
|
||||
value={sourceProfileId ?? undefined}
|
||||
onValueChange={handleSourceChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a profile to copy cookies from" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{eligibleSourceProfiles.map((profile) => {
|
||||
const IconComponent = getBrowserIcon(profile.browser);
|
||||
const isRunning = runningProfiles.has(profile.id);
|
||||
return (
|
||||
<SelectItem
|
||||
key={profile.id}
|
||||
value={profile.id}
|
||||
disabled={isRunning}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{IconComponent && <IconComponent className="w-4 h-4" />}
|
||||
<span>{profile.name}</span>
|
||||
{isRunning && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
(running)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Target Profiles ({targetProfiles.length})</Label>
|
||||
<div className="p-2 bg-muted rounded-md max-h-20 overflow-y-auto">
|
||||
{targetProfiles.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{sourceProfileId
|
||||
? "No other Wayfern/Camoufox profiles selected"
|
||||
: "Select a source profile first"}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{targetProfiles.map((p) => (
|
||||
<span
|
||||
key={p.id}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 bg-background rounded text-sm"
|
||||
>
|
||||
{p.name}
|
||||
{runningProfiles.has(p.id) && (
|
||||
<span className="text-xs text-destructive">
|
||||
(running)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sourceProfileId && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>
|
||||
Select Cookies{" "}
|
||||
{cookieData && (
|
||||
<span className="text-muted-foreground">
|
||||
({selectedCookieCount} of {cookieData.total_count}{" "}
|
||||
selected)
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<LuSearch className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search domains or cookies..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoadingCookies ? (
|
||||
<div className="flex items-center justify-center h-40">
|
||||
<div className="animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-4 text-center text-destructive bg-destructive/10 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
) : filteredDomains.length === 0 ? (
|
||||
<div className="p-4 text-center text-muted-foreground">
|
||||
{searchQuery
|
||||
? "No matching cookies found"
|
||||
: "No cookies found"}
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[250px] border rounded-md">
|
||||
<div className="p-2 space-y-1">
|
||||
{filteredDomains.map((domain) => (
|
||||
<DomainRow
|
||||
key={domain.domain}
|
||||
domain={domain}
|
||||
selection={selection}
|
||||
isExpanded={expandedDomains.has(domain.domain)}
|
||||
onToggleDomain={toggleDomain}
|
||||
onToggleCookie={toggleCookie}
|
||||
onToggleExpand={toggleExpand}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Existing cookies with the same name and domain will be replaced.
|
||||
Other cookies will be kept.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isCopying}
|
||||
>
|
||||
Cancel
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isCopying}
|
||||
onClick={() => void handleCopy()}
|
||||
disabled={!canCopy}
|
||||
>
|
||||
Copy {selectedCookieCount > 0 ? `${selectedCookieCount} ` : ""}
|
||||
Cookie{selectedCookieCount !== 1 ? "s" : ""}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface DomainRowProps {
|
||||
domain: DomainCookies;
|
||||
selection: SelectionState;
|
||||
isExpanded: boolean;
|
||||
onToggleDomain: (domain: string, cookies: UnifiedCookie[]) => void;
|
||||
onToggleCookie: (
|
||||
domain: string,
|
||||
cookieName: string,
|
||||
totalCookies: number,
|
||||
) => void;
|
||||
onToggleExpand: (domain: string) => void;
|
||||
}
|
||||
|
||||
function DomainRow({
|
||||
domain,
|
||||
selection,
|
||||
isExpanded,
|
||||
onToggleDomain,
|
||||
onToggleCookie,
|
||||
onToggleExpand,
|
||||
}: DomainRowProps) {
|
||||
const domainSelection = selection[domain.domain];
|
||||
const isAllSelected = domainSelection?.allSelected || false;
|
||||
const selectedCount = domainSelection?.cookies.size || 0;
|
||||
const isPartial =
|
||||
selectedCount > 0 && selectedCount < domain.cookie_count && !isAllSelected;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 p-2 hover:bg-accent/50 rounded">
|
||||
<Checkbox
|
||||
checked={isAllSelected || isPartial}
|
||||
onCheckedChange={() => onToggleDomain(domain.domain, domain.cookies)}
|
||||
className={isPartial ? "opacity-70" : ""}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 flex-1 text-left bg-transparent border-none cursor-pointer"
|
||||
onClick={() => onToggleExpand(domain.domain)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<LuChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<LuChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
<span className="font-medium">{domain.domain}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({domain.cookie_count})
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="ml-8 pl-2 border-l space-y-1">
|
||||
{domain.cookies.map((cookie) => {
|
||||
const isSelected =
|
||||
domainSelection?.cookies.has(cookie.name) || false;
|
||||
return (
|
||||
<div
|
||||
key={`${domain.domain}-${cookie.name}`}
|
||||
className="flex items-center gap-2 p-1 text-sm hover:bg-accent/30 rounded"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected || isAllSelected}
|
||||
onCheckedChange={() =>
|
||||
onToggleCookie(
|
||||
domain.domain,
|
||||
cookie.name,
|
||||
domain.cookie_count,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="truncate">{cookie.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -634,6 +634,15 @@ export function CreateProfileDialog({
|
||||
id="profile-name"
|
||||
value={profileName}
|
||||
onChange={(e) => setProfileName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
!isCreateDisabled &&
|
||||
!isCreating
|
||||
) {
|
||||
handleCreate();
|
||||
}
|
||||
}}
|
||||
placeholder="Enter profile name"
|
||||
/>
|
||||
</div>
|
||||
@@ -967,6 +976,15 @@ export function CreateProfileDialog({
|
||||
id="profile-name"
|
||||
value={profileName}
|
||||
onChange={(e) => setProfileName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
!isCreateDisabled &&
|
||||
!isCreating
|
||||
) {
|
||||
handleCreate();
|
||||
}
|
||||
}}
|
||||
placeholder="Enter profile name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
LuDownload,
|
||||
LuRefreshCw,
|
||||
LuTriangleAlert,
|
||||
LuX,
|
||||
} from "react-icons/lu";
|
||||
import type { ExternalToast } from "sonner";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
@@ -64,6 +65,7 @@ interface BaseToastProps {
|
||||
description?: string;
|
||||
duration?: number;
|
||||
action?: ExternalToast["action"];
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
interface LoadingToastProps extends BaseToastProps {
|
||||
@@ -163,7 +165,7 @@ function getToastIcon(type: ToastProps["type"], stage?: string) {
|
||||
}
|
||||
|
||||
export function UnifiedToast(props: ToastProps) {
|
||||
const { title, description, type, action } = props;
|
||||
const { title, description, type, action, onCancel } = props;
|
||||
const stage = "stage" in props ? props.stage : undefined;
|
||||
const progress = "progress" in props ? props.progress : undefined;
|
||||
|
||||
@@ -171,9 +173,21 @@ export function UnifiedToast(props: ToastProps) {
|
||||
<div className="flex items-start p-3 w-96 rounded-lg border shadow-lg bg-card border-border text-card-foreground">
|
||||
<div className="mr-3 mt-0.5">{getToastIcon(type, stage)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold leading-tight text-foreground">
|
||||
{title}
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold leading-tight text-foreground">
|
||||
{title}
|
||||
</p>
|
||||
{onCancel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="ml-2 p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
|
||||
aria-label="Cancel"
|
||||
>
|
||||
<LuX className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Download progress */}
|
||||
{type === "download" &&
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { LuLoaderCircle } from "react-icons/lu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type RippleButtonProps as ButtonProps,
|
||||
RippleButton as UIButton,
|
||||
@@ -8,10 +9,10 @@ type Props = ButtonProps & {
|
||||
isLoading: boolean;
|
||||
"aria-label"?: string;
|
||||
};
|
||||
export const LoadingButton = ({ isLoading, ...props }: Props) => {
|
||||
export const LoadingButton = ({ isLoading, className, ...props }: Props) => {
|
||||
return (
|
||||
<UIButton
|
||||
className="grid place-items-center"
|
||||
className={cn("grid place-items-center", className)}
|
||||
{...props}
|
||||
disabled={props.disabled || isLoading}
|
||||
>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
LuCheck,
|
||||
LuChevronDown,
|
||||
LuChevronUp,
|
||||
LuCookie,
|
||||
LuTrash2,
|
||||
LuUsers,
|
||||
} from "react-icons/lu";
|
||||
@@ -157,6 +158,7 @@ type TableMeta = {
|
||||
// Overflow actions
|
||||
onAssignProfilesToGroup?: (profileIds: string[]) => void;
|
||||
onConfigureCamoufox?: (profile: BrowserProfile) => void;
|
||||
onCopyCookiesToProfile?: (profile: BrowserProfile) => void;
|
||||
|
||||
// Traffic snapshots (lightweight real-time data)
|
||||
trafficSnapshots: Record<string, TrafficSnapshot>;
|
||||
@@ -672,6 +674,7 @@ interface ProfilesDataTableProps {
|
||||
onDeleteProfile: (profile: BrowserProfile) => void | Promise<void>;
|
||||
onRenameProfile: (profileId: string, newName: string) => Promise<void>;
|
||||
onConfigureCamoufox: (profile: BrowserProfile) => void;
|
||||
onCopyCookiesToProfile?: (profile: BrowserProfile) => void;
|
||||
runningProfiles: Set<string>;
|
||||
isUpdating: (browser: string) => boolean;
|
||||
onDeleteSelectedProfiles: (profileIds: string[]) => Promise<void>;
|
||||
@@ -682,6 +685,7 @@ interface ProfilesDataTableProps {
|
||||
onBulkDelete?: () => void;
|
||||
onBulkGroupAssignment?: () => void;
|
||||
onBulkProxyAssignment?: () => void;
|
||||
onBulkCopyCookies?: () => void;
|
||||
onOpenProfileSyncDialog?: (profile: BrowserProfile) => void;
|
||||
onToggleProfileSync?: (profile: BrowserProfile) => void;
|
||||
}
|
||||
@@ -693,6 +697,7 @@ export function ProfilesDataTable({
|
||||
onDeleteProfile,
|
||||
onRenameProfile,
|
||||
onConfigureCamoufox,
|
||||
onCopyCookiesToProfile,
|
||||
runningProfiles,
|
||||
isUpdating,
|
||||
onAssignProfilesToGroup,
|
||||
@@ -701,6 +706,7 @@ export function ProfilesDataTable({
|
||||
onBulkDelete,
|
||||
onBulkGroupAssignment,
|
||||
onBulkProxyAssignment,
|
||||
onBulkCopyCookies,
|
||||
onOpenProfileSyncDialog,
|
||||
onToggleProfileSync,
|
||||
}: ProfilesDataTableProps) {
|
||||
@@ -1115,8 +1121,10 @@ export function ProfilesDataTable({
|
||||
if (!profileToDelete) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
// Minimum loading time for visual feedback
|
||||
const minLoadingTime = new Promise((r) => setTimeout(r, 300));
|
||||
try {
|
||||
await onDeleteProfile(profileToDelete);
|
||||
await Promise.all([onDeleteProfile(profileToDelete), minLoadingTime]);
|
||||
setProfileToDelete(null);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete profile:", error);
|
||||
@@ -1302,6 +1310,7 @@ export function ProfilesDataTable({
|
||||
// Overflow actions
|
||||
onAssignProfilesToGroup,
|
||||
onConfigureCamoufox,
|
||||
onCopyCookiesToProfile,
|
||||
|
||||
// Traffic snapshots (lightweight real-time data)
|
||||
trafficSnapshots,
|
||||
@@ -1350,6 +1359,7 @@ export function ProfilesDataTable({
|
||||
onLaunchProfile,
|
||||
onAssignProfilesToGroup,
|
||||
onConfigureCamoufox,
|
||||
onCopyCookiesToProfile,
|
||||
syncStatuses,
|
||||
onOpenProfileSyncDialog,
|
||||
onToggleProfileSync,
|
||||
@@ -2010,6 +2020,17 @@ export function ProfilesDataTable({
|
||||
Change Fingerprint
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(profile.browser === "camoufox" ||
|
||||
profile.browser === "wayfern") &&
|
||||
meta.onCopyCookiesToProfile && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
meta.onCopyCookiesToProfile?.(profile);
|
||||
}}
|
||||
>
|
||||
Copy Cookies to Profile
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{meta.onOpenProfileSyncDialog && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -2160,6 +2181,15 @@ export function ProfilesDataTable({
|
||||
<FiWifi />
|
||||
</DataTableActionBarAction>
|
||||
)}
|
||||
{onBulkCopyCookies && (
|
||||
<DataTableActionBarAction
|
||||
tooltip="Copy Cookies"
|
||||
onClick={onBulkCopyCookies}
|
||||
size="icon"
|
||||
>
|
||||
<LuCookie />
|
||||
</DataTableActionBarAction>
|
||||
)}
|
||||
{onBulkDelete && (
|
||||
<DataTableActionBarAction
|
||||
tooltip="Delete"
|
||||
|
||||
@@ -56,7 +56,7 @@ export function CopyToClipboard({
|
||||
}`}
|
||||
/>
|
||||
<LuCheck
|
||||
className={`absolute inset-0 m-auto h-4 w-4 transition-all duration-300 ${
|
||||
className={`absolute inset-0 m-auto h-4 w-4 text-foreground transition-all duration-300 ${
|
||||
copied ? "scale-100" : "scale-0"
|
||||
}`}
|
||||
/>
|
||||
|
||||
@@ -284,11 +284,20 @@ export function useBrowserDownload() {
|
||||
? formatTime(progress.eta_seconds)
|
||||
: "calculating...";
|
||||
|
||||
showDownloadToast(browserName, progress.version, "downloading", {
|
||||
percentage: progress.percentage,
|
||||
speed: speedMBps,
|
||||
eta: etaText,
|
||||
});
|
||||
const toastId = `download-${browserName.toLowerCase()}-${progress.version}`;
|
||||
showDownloadToast(
|
||||
browserName,
|
||||
progress.version,
|
||||
"downloading",
|
||||
{
|
||||
percentage: progress.percentage,
|
||||
speed: speedMBps,
|
||||
eta: etaText,
|
||||
},
|
||||
{
|
||||
onCancel: () => dismissToast(toastId),
|
||||
},
|
||||
);
|
||||
} else if (progress.stage === "extracting") {
|
||||
showDownloadToast(browserName, progress.version, "extracting");
|
||||
} else if (progress.stage === "verifying") {
|
||||
|
||||
@@ -92,6 +92,7 @@ export function useVersionUpdater() {
|
||||
found: progress.new_versions_found,
|
||||
current_browser: currentBrowserName,
|
||||
},
|
||||
onCancel: () => dismissToast("unified-version-update"),
|
||||
});
|
||||
} else if (progress.status === "completed") {
|
||||
setIsUpdating(false);
|
||||
|
||||
@@ -8,6 +8,7 @@ interface BaseToastProps {
|
||||
description?: string;
|
||||
duration?: number;
|
||||
action?: ExternalToast["action"];
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
interface LoadingToastProps extends BaseToastProps {
|
||||
@@ -143,7 +144,7 @@ export function showDownloadToast(
|
||||
| "completed"
|
||||
| "downloading (twilight rolling release)",
|
||||
progress?: { percentage: number; speed?: string; eta?: string },
|
||||
options?: { suppressCompletionToast?: boolean },
|
||||
options?: { suppressCompletionToast?: boolean; onCancel?: () => void },
|
||||
) {
|
||||
const title =
|
||||
stage === "completed"
|
||||
@@ -162,12 +163,18 @@ export function showDownloadToast(
|
||||
return;
|
||||
}
|
||||
|
||||
// Only show cancel button during active downloading, not for completed/extracting/verifying
|
||||
const showCancel =
|
||||
stage === "downloading" ||
|
||||
stage === "downloading (twilight rolling release)";
|
||||
|
||||
return showToast({
|
||||
type: "download",
|
||||
title,
|
||||
stage,
|
||||
progress,
|
||||
id: `download-${browserName.toLowerCase()}-${version}`,
|
||||
onCancel: showCancel ? options?.onCancel : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -237,6 +244,7 @@ export function showUnifiedVersionUpdateToast(
|
||||
current_browser?: string;
|
||||
};
|
||||
duration?: number;
|
||||
onCancel?: () => void;
|
||||
},
|
||||
) {
|
||||
return showToast({
|
||||
|
||||
@@ -371,3 +371,48 @@ export interface FilteredTrafficStats {
|
||||
domains: Record<string, DomainAccess>;
|
||||
unique_ips: string[];
|
||||
}
|
||||
|
||||
// Cookie copy types
|
||||
export interface UnifiedCookie {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
path: string;
|
||||
expires: number;
|
||||
is_secure: boolean;
|
||||
is_http_only: boolean;
|
||||
same_site: number;
|
||||
creation_time: number;
|
||||
last_accessed: number;
|
||||
}
|
||||
|
||||
export interface DomainCookies {
|
||||
domain: string;
|
||||
cookies: UnifiedCookie[];
|
||||
cookie_count: number;
|
||||
}
|
||||
|
||||
export interface CookieReadResult {
|
||||
profile_id: string;
|
||||
browser_type: string;
|
||||
domains: DomainCookies[];
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
export interface SelectedCookie {
|
||||
domain: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface CookieCopyRequest {
|
||||
source_profile_id: string;
|
||||
target_profile_ids: string[];
|
||||
selected_cookies: SelectedCookie[];
|
||||
}
|
||||
|
||||
export interface CookieCopyResult {
|
||||
target_profile_id: string;
|
||||
cookies_copied: number;
|
||||
cookies_replaced: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user