feat: add cookies copying functionality

This commit is contained in:
zhom
2026-01-11 01:35:05 +04:00
parent e9c084d6a4
commit cddc4544b0
16 changed files with 1328 additions and 21 deletions
+31 -5
View File
@@ -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());
+3 -1
View File
@@ -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
+496
View File
@@ -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
View File
@@ -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");
+39
View File
@@ -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};
+41
View File
@@ -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)}
+561
View File
@@ -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>
);
}
+18
View File
@@ -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>
+18 -4
View File
@@ -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" &&
+3 -2
View File
@@ -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}
>
+31 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
}`}
/>
+14 -5
View File
@@ -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") {
+1
View File
@@ -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);
+9 -1
View File
@@ -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({
+45
View File
@@ -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[];
}