From cddc4544b0948fb4aa0ac4f53018926a973914cd Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Sun, 11 Jan 2026 01:35:05 +0400 Subject: [PATCH] feat: add cookies copying functionality --- src-tauri/src/browser_runner.rs | 36 +- src-tauri/src/camoufox_manager.rs | 4 +- src-tauri/src/cookie_manager.rs | 496 ++++++++++++++++++++ src-tauri/src/lib.rs | 18 +- src-tauri/src/wayfern_manager.rs | 39 ++ src/app/page.tsx | 41 ++ src/components/cookie-copy-dialog.tsx | 561 +++++++++++++++++++++++ src/components/create-profile-dialog.tsx | 18 + src/components/custom-toast.tsx | 22 +- src/components/loading-button.tsx | 5 +- src/components/profile-data-table.tsx | 32 +- src/components/ui/copy-to-clipboard.tsx | 2 +- src/hooks/use-browser-download.ts | 19 +- src/hooks/use-version-updater.ts | 1 + src/lib/toast-utils.ts | 10 +- src/types.ts | 45 ++ 16 files changed, 1328 insertions(+), 21 deletions(-) create mode 100644 src-tauri/src/cookie_manager.rs create mode 100644 src/components/cookie-copy-dialog.tsx diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index ee8d6b1..e5e46ee 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -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()); diff --git a/src-tauri/src/camoufox_manager.rs b/src-tauri/src/camoufox_manager.rs index e05910e..f4b73f4 100644 --- a/src-tauri/src/camoufox_manager.rs +++ b/src-tauri/src/camoufox_manager.rs @@ -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 diff --git a/src-tauri/src/cookie_manager.rs b/src-tauri/src/cookie_manager.rs new file mode 100644 index 0000000..dd19b51 --- /dev/null +++ b/src-tauri/src/cookie_manager.rs @@ -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, + 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, + 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, + pub selected_cookies: Vec, +} + +/// 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, +} + +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 { + 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, 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::, _>>() + .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, 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::, _>>() + .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 = 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 = 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 { + 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> = HashMap::new(); + + for cookie in cookies { + domain_map + .entry(cookie.domain.clone()) + .or_default() + .push(cookie); + } + + let mut domains: Vec = 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, 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 = 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) + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0f36eb6..cabb836 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 Result { + cookie_manager::CookieManager::read_cookies(&profile_id) +} + +#[tauri::command] +async fn copy_profile_cookies( + app_handle: tauri::AppHandle, + request: cookie_manager::CookieCopyRequest, +) -> Result, String> { + cookie_manager::CookieManager::copy_cookies(&app_handle, request).await +} + #[tauri::command] async fn is_geoip_database_available() -> Result { 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"); diff --git a/src-tauri/src/wayfern_manager.rs b/src-tauri/src/wayfern_manager.rs index 5073a8d..370c1ec 100644 --- a/src-tauri/src/wayfern_manager.rs +++ b/src-tauri/src/wayfern_manager.rs @@ -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> { + 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 { use sysinfo::{ProcessRefreshKind, RefreshKind, System}; diff --git a/src/app/page.tsx b/src/app/page.tsx index e173a8a..f8bf47d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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("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} /> + { + setCookieCopyDialogOpen(false); + setSelectedProfilesForCookies([]); + }} + selectedProfiles={selectedProfilesForCookies} + profiles={profiles} + runningProfiles={runningProfiles} + onCopyComplete={() => setSelectedProfilesForCookies([])} + /> + setShowBulkDeleteConfirmation(false)} diff --git a/src/components/cookie-copy-dialog.tsx b/src/components/cookie-copy-dialog.tsx new file mode 100644 index 0000000..89ac8c8 --- /dev/null +++ b/src/components/cookie-copy-dialog.tsx @@ -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; + onCopyComplete?: () => void; +} + +type SelectionState = { + [domain: string]: { + allSelected: boolean; + cookies: Set; + }; +}; + +export function CookieCopyDialog({ + isOpen, + onClose, + selectedProfiles, + profiles, + runningProfiles, + onCopyComplete, +}: CookieCopyDialogProps) { + const [sourceProfileId, setSourceProfileId] = useState(null); + const [cookieData, setCookieData] = useState(null); + const [isLoadingCookies, setIsLoadingCookies] = useState(false); + const [isCopying, setIsCopying] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [selection, setSelection] = useState({}); + const [expandedDomains, setExpandedDomains] = useState>( + new Set(), + ); + const [error, setError] = useState(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("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(), + }; + 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("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 ( + + + + + + Copy Cookies + + + Copy cookies from a source profile to {selectedProfiles.length}{" "} + selected profile{selectedProfiles.length !== 1 ? "s" : ""}. + + + +
+
+ + +
+ +
+ +
+ {targetProfiles.length === 0 ? ( +

+ {sourceProfileId + ? "No other Wayfern/Camoufox profiles selected" + : "Select a source profile first"} +

+ ) : ( +
+ {targetProfiles.map((p) => ( + + {p.name} + {runningProfiles.has(p.id) && ( + + (running) + + )} + + ))} +
+ )} +
+
+ + {sourceProfileId && ( +
+
+ +
+ +
+ + setSearchQuery(e.target.value)} + className="pl-8" + /> +
+ + {isLoadingCookies ? ( +
+
+
+ ) : error ? ( +
+ {error} +
+ ) : filteredDomains.length === 0 ? ( +
+ {searchQuery + ? "No matching cookies found" + : "No cookies found"} +
+ ) : ( + +
+ {filteredDomains.map((domain) => ( + + ))} +
+
+ )} + +

+ Existing cookies with the same name and domain will be replaced. + Other cookies will be kept. +

+
+ )} +
+ + + + Cancel + + void handleCopy()} + disabled={!canCopy} + > + Copy {selectedCookieCount > 0 ? `${selectedCookieCount} ` : ""} + Cookie{selectedCookieCount !== 1 ? "s" : ""} + + + +
+ ); +} + +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 ( +
+
+ onToggleDomain(domain.domain, domain.cookies)} + className={isPartial ? "opacity-70" : ""} + /> + +
+ {isExpanded && ( +
+ {domain.cookies.map((cookie) => { + const isSelected = + domainSelection?.cookies.has(cookie.name) || false; + return ( +
+ + onToggleCookie( + domain.domain, + cookie.name, + domain.cookie_count, + ) + } + /> + {cookie.name} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/components/create-profile-dialog.tsx b/src/components/create-profile-dialog.tsx index 51ef832..7cf1382 100644 --- a/src/components/create-profile-dialog.tsx +++ b/src/components/create-profile-dialog.tsx @@ -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" /> @@ -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" /> diff --git a/src/components/custom-toast.tsx b/src/components/custom-toast.tsx index c32c1a8..17d434e 100644 --- a/src/components/custom-toast.tsx +++ b/src/components/custom-toast.tsx @@ -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) {
{getToastIcon(type, stage)}
-

- {title} -

+
+

+ {title} +

+ {onCancel && ( + + )} +
{/* Download progress */} {type === "download" && diff --git a/src/components/loading-button.tsx b/src/components/loading-button.tsx index 4a563ae..b4e0025 100644 --- a/src/components/loading-button.tsx +++ b/src/components/loading-button.tsx @@ -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 ( diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index 43c2482..ce18e94 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -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; @@ -672,6 +674,7 @@ interface ProfilesDataTableProps { onDeleteProfile: (profile: BrowserProfile) => void | Promise; onRenameProfile: (profileId: string, newName: string) => Promise; onConfigureCamoufox: (profile: BrowserProfile) => void; + onCopyCookiesToProfile?: (profile: BrowserProfile) => void; runningProfiles: Set; isUpdating: (browser: string) => boolean; onDeleteSelectedProfiles: (profileIds: string[]) => Promise; @@ -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 )} + {(profile.browser === "camoufox" || + profile.browser === "wayfern") && + meta.onCopyCookiesToProfile && ( + { + meta.onCopyCookiesToProfile?.(profile); + }} + > + Copy Cookies to Profile + + )} {meta.onOpenProfileSyncDialog && ( { @@ -2160,6 +2181,15 @@ export function ProfilesDataTable({ )} + {onBulkCopyCookies && ( + + + + )} {onBulkDelete && ( diff --git a/src/hooks/use-browser-download.ts b/src/hooks/use-browser-download.ts index 7cd0720..c0e1120 100644 --- a/src/hooks/use-browser-download.ts +++ b/src/hooks/use-browser-download.ts @@ -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") { diff --git a/src/hooks/use-version-updater.ts b/src/hooks/use-version-updater.ts index e895ce6..1b01439 100644 --- a/src/hooks/use-version-updater.ts +++ b/src/hooks/use-version-updater.ts @@ -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); diff --git a/src/lib/toast-utils.ts b/src/lib/toast-utils.ts index 8d838c5..8cd78c8 100644 --- a/src/lib/toast-utils.ts +++ b/src/lib/toast-utils.ts @@ -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({ diff --git a/src/types.ts b/src/types.ts index 202c0f4..2970282 100644 --- a/src/types.ts +++ b/src/types.ts @@ -371,3 +371,48 @@ export interface FilteredTrafficStats { domains: Record; 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[]; +}