diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 56e9179..b56afce 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -34,6 +34,8 @@ "deep-link:allow-get-current", "dialog:default", "dialog:allow-open", + "dialog:allow-save", + "fs:allow-write-text-file", "macos-permissions:default", "macos-permissions:allow-request-microphone-permission", "macos-permissions:allow-request-camera-permission", diff --git a/src-tauri/src/app_dirs.rs b/src-tauri/src/app_dirs.rs index 9728bb8..3cdb70d 100644 --- a/src-tauri/src/app_dirs.rs +++ b/src-tauri/src/app_dirs.rs @@ -66,6 +66,10 @@ pub fn proxies_dir() -> PathBuf { data_dir().join("proxies") } +pub fn vpn_dir() -> PathBuf { + data_dir().join("vpn") +} + #[cfg(test)] thread_local! { static TEST_DATA_DIR: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; @@ -147,6 +151,7 @@ mod tests { assert!(data_subdir().ends_with("data")); assert!(settings_dir().ends_with("settings")); assert!(proxies_dir().ends_with("proxies")); + assert!(vpn_dir().ends_with("vpn")); } #[test] diff --git a/src-tauri/src/cookie_manager.rs b/src-tauri/src/cookie_manager.rs index 5225348..085e816 100644 --- a/src-tauri/src/cookie_manager.rs +++ b/src-tauri/src/cookie_manager.rs @@ -2,6 +2,7 @@ use crate::profile::manager::ProfileManager; use crate::profile::BrowserProfile; use rusqlite::{params, Connection}; use serde::{Deserialize, Serialize}; +use serde_json::Value; use std::collections::HashMap; use std::path::{Path, PathBuf}; use tauri::AppHandle; @@ -551,8 +552,150 @@ impl CookieManager { (cookies, errors) } - /// Public API: Import cookies from Netscape format content - pub async fn import_netscape_cookies( + /// Parse JSON format cookies (array of cookie objects, e.g. from browser extensions) + fn parse_json_cookies(content: &str) -> (Vec, Vec) { + let mut cookies = Vec::new(); + let mut errors = Vec::new(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + let arr: Vec = match serde_json::from_str(content) { + Ok(v) => v, + Err(e) => { + errors.push(format!("Failed to parse JSON: {e}")); + return (cookies, errors); + } + }; + + for (i, obj) in arr.iter().enumerate() { + let name = match obj.get("name").and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => { + errors.push(format!("Cookie {}: missing 'name' field", i + 1)); + continue; + } + }; + let value = obj + .get("value") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let domain = match obj.get("domain").and_then(|v| v.as_str()) { + Some(s) => s.to_string(), + None => { + errors.push(format!("Cookie {}: missing 'domain' field", i + 1)); + continue; + } + }; + let path = obj + .get("path") + .and_then(|v| v.as_str()) + .unwrap_or("/") + .to_string(); + let is_secure = obj.get("secure").and_then(|v| v.as_bool()).unwrap_or(false); + let is_http_only = obj + .get("httpOnly") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let is_session = obj + .get("session") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let expires = if is_session { + 0 + } else { + obj + .get("expirationDate") + .and_then(|v| v.as_f64()) + .map(|f| f as i64) + .unwrap_or(0) + }; + let same_site = obj + .get("sameSite") + .and_then(|v| v.as_str()) + .map(|s| match s { + "lax" => 1, + "strict" => 2, + _ => 0, // "no_restriction" or unrecognized + }) + .unwrap_or(0); + + cookies.push(UnifiedCookie { + name, + value, + domain, + path, + expires, + is_secure, + is_http_only, + same_site, + creation_time: now, + last_accessed: now, + }); + } + + (cookies, errors) + } + + /// Auto-detect cookie format and parse + fn parse_cookies(content: &str) -> (Vec, Vec) { + let trimmed = content.trim(); + if trimmed.starts_with('[') && serde_json::from_str::>(trimmed).is_ok() { + return Self::parse_json_cookies(trimmed); + } + Self::parse_netscape_cookies(content) + } + + /// Format cookies as Netscape TXT + pub fn format_netscape_cookies(cookies: &[UnifiedCookie]) -> String { + let mut lines = Vec::new(); + lines.push("# Netscape HTTP Cookie File".to_string()); + for cookie in cookies { + let flag = if cookie.domain.starts_with('.') { + "TRUE" + } else { + "FALSE" + }; + let secure = if cookie.is_secure { "TRUE" } else { "FALSE" }; + lines.push(format!( + "{}\t{}\t{}\t{}\t{}\t{}\t{}", + cookie.domain, flag, cookie.path, secure, cookie.expires, cookie.name, cookie.value + )); + } + lines.join("\n") + } + + /// Format cookies as JSON + pub fn format_json_cookies(cookies: &[UnifiedCookie]) -> String { + let arr: Vec = cookies + .iter() + .map(|c| { + let same_site_str = match c.same_site { + 1 => "lax", + 2 => "strict", + _ => "no_restriction", + }; + serde_json::json!({ + "name": c.name, + "value": c.value, + "domain": c.domain, + "path": c.path, + "secure": c.is_secure, + "httpOnly": c.is_http_only, + "sameSite": same_site_str, + "expirationDate": c.expires, + "session": c.expires == 0, + "hostOnly": !c.domain.starts_with('.'), + }) + }) + .collect(); + serde_json::to_string_pretty(&arr).unwrap_or_else(|_| "[]".to_string()) + } + + /// Public API: Import cookies with auto-format detection + pub async fn import_cookies( app_handle: &AppHandle, profile_id: &str, content: &str, @@ -580,7 +723,7 @@ impl CookieManager { )); } - let (cookies, parse_errors) = Self::parse_netscape_cookies(content); + let (cookies, parse_errors) = Self::parse_cookies(content); if cookies.is_empty() { return Err("No valid cookies found in the file".to_string()); @@ -603,4 +746,255 @@ impl CookieManager { Err(e) => Err(format!("Failed to write cookies: {e}")), } } + + /// Public API: Export cookies from a profile in the specified format + pub fn export_cookies(profile_id: &str, format: &str) -> Result { + let result = Self::read_cookies(profile_id)?; + let all_cookies: Vec = + result.domains.into_iter().flat_map(|d| d.cookies).collect(); + + match format { + "json" => Ok(Self::format_json_cookies(&all_cookies)), + "netscape" => Ok(Self::format_netscape_cookies(&all_cookies)), + _ => Err(format!("Unsupported export format: {format}")), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_netscape_cookies_valid() { + let content = "# Netscape HTTP Cookie File\n\ + .example.com\tTRUE\t/\tTRUE\t1700000000\tsession_id\tabc123\n\ + example.com\tFALSE\t/path\tFALSE\t0\ttoken\txyz"; + let (cookies, errors) = CookieManager::parse_netscape_cookies(content); + assert_eq!(cookies.len(), 2); + assert!(errors.is_empty()); + + assert_eq!(cookies[0].domain, ".example.com"); + assert_eq!(cookies[0].name, "session_id"); + assert_eq!(cookies[0].value, "abc123"); + assert_eq!(cookies[0].path, "/"); + assert!(cookies[0].is_secure); + assert_eq!(cookies[0].expires, 1700000000); + + assert_eq!(cookies[1].domain, "example.com"); + assert!(!cookies[1].is_secure); + assert_eq!(cookies[1].expires, 0); + } + + #[test] + fn test_parse_netscape_cookies_skips_comments_and_blanks() { + let content = "# Comment line\n\n \n# Another comment\n\ + .test.com\tTRUE\t/\tFALSE\t0\tname\tvalue\n"; + let (cookies, errors) = CookieManager::parse_netscape_cookies(content); + assert_eq!(cookies.len(), 1); + assert!(errors.is_empty()); + } + + #[test] + fn test_parse_netscape_cookies_malformed_lines() { + let content = "not\tenough\tfields\n\ + .ok.com\tTRUE\t/\tFALSE\t0\tname\tvalue\n"; + let (cookies, errors) = CookieManager::parse_netscape_cookies(content); + assert_eq!(cookies.len(), 1); + assert_eq!(errors.len(), 1); + assert!(errors[0].contains("expected 7 tab-separated fields")); + } + + #[test] + fn test_parse_json_cookies_valid() { + let content = r#"[ + { + "name": "sid", + "value": "abc", + "domain": ".example.com", + "path": "/", + "secure": true, + "httpOnly": true, + "sameSite": "lax", + "expirationDate": 1700000000, + "session": false + } + ]"#; + let (cookies, errors) = CookieManager::parse_json_cookies(content); + assert_eq!(cookies.len(), 1); + assert!(errors.is_empty()); + assert_eq!(cookies[0].name, "sid"); + assert_eq!(cookies[0].domain, ".example.com"); + assert!(cookies[0].is_secure); + assert!(cookies[0].is_http_only); + assert_eq!(cookies[0].same_site, 1); + assert_eq!(cookies[0].expires, 1700000000); + } + + #[test] + fn test_parse_json_cookies_session() { + let content = r#"[{"name": "s", "value": "v", "domain": ".d.com", "session": true, "expirationDate": 9999}]"#; + let (cookies, errors) = CookieManager::parse_json_cookies(content); + assert_eq!(cookies.len(), 1); + assert!(errors.is_empty()); + assert_eq!(cookies[0].expires, 0); + } + + #[test] + fn test_parse_json_cookies_same_site_mapping() { + let content = r#"[ + {"name": "a", "value": "", "domain": ".d.com", "sameSite": "no_restriction"}, + {"name": "b", "value": "", "domain": ".d.com", "sameSite": "lax"}, + {"name": "c", "value": "", "domain": ".d.com", "sameSite": "strict"} + ]"#; + let (cookies, _) = CookieManager::parse_json_cookies(content); + assert_eq!(cookies[0].same_site, 0); + assert_eq!(cookies[1].same_site, 1); + assert_eq!(cookies[2].same_site, 2); + } + + #[test] + fn test_parse_cookies_auto_detect_json() { + let content = r#"[{"name": "x", "value": "y", "domain": ".test.com"}]"#; + let (cookies, _) = CookieManager::parse_cookies(content); + assert_eq!(cookies.len(), 1); + assert_eq!(cookies[0].name, "x"); + } + + #[test] + fn test_parse_cookies_auto_detect_netscape() { + let content = ".test.com\tTRUE\t/\tFALSE\t0\tname\tvalue"; + let (cookies, _) = CookieManager::parse_cookies(content); + assert_eq!(cookies.len(), 1); + assert_eq!(cookies[0].name, "name"); + } + + #[test] + fn test_format_netscape_cookies() { + let cookies = vec![UnifiedCookie { + name: "sid".to_string(), + value: "abc".to_string(), + domain: ".example.com".to_string(), + path: "/".to_string(), + expires: 1700000000, + is_secure: true, + is_http_only: false, + same_site: 0, + creation_time: 0, + last_accessed: 0, + }]; + let output = CookieManager::format_netscape_cookies(&cookies); + assert!(output.contains("# Netscape HTTP Cookie File")); + assert!(output.contains(".example.com\tTRUE\t/\tTRUE\t1700000000\tsid\tabc")); + } + + #[test] + fn test_format_json_cookies() { + let cookies = vec![UnifiedCookie { + name: "sid".to_string(), + value: "abc".to_string(), + domain: ".example.com".to_string(), + path: "/".to_string(), + expires: 1700000000, + is_secure: true, + is_http_only: true, + same_site: 1, + creation_time: 0, + last_accessed: 0, + }]; + let output = CookieManager::format_json_cookies(&cookies); + let parsed: Vec = serde_json::from_str(&output).unwrap(); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0]["name"], "sid"); + assert_eq!(parsed[0]["sameSite"], "lax"); + assert_eq!(parsed[0]["session"], false); + assert_eq!(parsed[0]["hostOnly"], false); + } + + #[test] + fn test_netscape_roundtrip() { + let cookies = vec![ + UnifiedCookie { + name: "a".to_string(), + value: "1".to_string(), + domain: ".d.com".to_string(), + path: "/".to_string(), + expires: 1700000000, + is_secure: true, + is_http_only: false, + same_site: 0, + creation_time: 0, + last_accessed: 0, + }, + UnifiedCookie { + name: "b".to_string(), + value: "2".to_string(), + domain: "d.com".to_string(), + path: "/p".to_string(), + expires: 0, + is_secure: false, + is_http_only: false, + same_site: 0, + creation_time: 0, + last_accessed: 0, + }, + ]; + let formatted = CookieManager::format_netscape_cookies(&cookies); + let (parsed, errors) = CookieManager::parse_netscape_cookies(&formatted); + assert!(errors.is_empty()); + assert_eq!(parsed.len(), 2); + assert_eq!(parsed[0].name, "a"); + assert_eq!(parsed[0].domain, ".d.com"); + assert!(parsed[0].is_secure); + assert_eq!(parsed[1].name, "b"); + assert_eq!(parsed[1].domain, "d.com"); + } + + #[test] + fn test_json_roundtrip() { + let cookies = vec![UnifiedCookie { + name: "tok".to_string(), + value: "xyz".to_string(), + domain: ".site.org".to_string(), + path: "/app".to_string(), + expires: 1700000000, + is_secure: false, + is_http_only: true, + same_site: 2, + creation_time: 0, + last_accessed: 0, + }]; + let formatted = CookieManager::format_json_cookies(&cookies); + let (parsed, errors) = CookieManager::parse_json_cookies(&formatted); + assert!(errors.is_empty()); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].name, "tok"); + assert_eq!(parsed[0].domain, ".site.org"); + assert_eq!(parsed[0].path, "/app"); + assert!(!parsed[0].is_secure); + assert!(parsed[0].is_http_only); + assert_eq!(parsed[0].same_site, 2); + assert_eq!(parsed[0].expires, 1700000000); + } + + #[test] + fn test_chrome_time_to_unix() { + assert_eq!(CookieManager::chrome_time_to_unix(0), 0); + let chrome_time: i64 = (1700000000 + CookieManager::WINDOWS_EPOCH_DIFF) * 1_000_000; + assert_eq!(CookieManager::chrome_time_to_unix(chrome_time), 1700000000); + } + + #[test] + fn test_unix_to_chrome_time() { + assert_eq!(CookieManager::unix_to_chrome_time(0), 0); + let expected = (1700000000 + CookieManager::WINDOWS_EPOCH_DIFF) * 1_000_000; + assert_eq!(CookieManager::unix_to_chrome_time(1700000000), expected); + } + + #[test] + fn test_chrome_time_roundtrip() { + let unix = 1700000000_i64; + let chrome = CookieManager::unix_to_chrome_time(unix); + assert_eq!(CookieManager::chrome_time_to_unix(chrome), unix); + } } diff --git a/src-tauri/src/geoip_downloader.rs b/src-tauri/src/geoip_downloader.rs index 28e1963..35634b3 100644 --- a/src-tauri/src/geoip_downloader.rs +++ b/src-tauri/src/geoip_downloader.rs @@ -76,21 +76,39 @@ impl GeoIPDownloader { false } } - /// Check if GeoIP database is missing for Camoufox profiles + + fn get_timestamp_path() -> PathBuf { + crate::app_dirs::cache_dir().join("geoip_last_download") + } + + fn is_geoip_stale() -> bool { + let timestamp_path = Self::get_timestamp_path(); + let Ok(content) = std::fs::read_to_string(×tamp_path) else { + return true; + }; + let Ok(timestamp) = content.trim().parse::() else { + return true; + }; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + const SEVEN_DAYS: u64 = 7 * 24 * 60 * 60; + now.saturating_sub(timestamp) > SEVEN_DAYS + } + + /// Check if GeoIP database is missing or stale for Camoufox profiles pub fn check_missing_geoip_database( &self, ) -> Result> { - // Get all profiles let profiles = ProfileManager::instance() .list_profiles() .map_err(|e| format!("Failed to list profiles: {e}"))?; - // Check if there are any Camoufox profiles let has_camoufox_profiles = profiles.iter().any(|profile| profile.browser == "camoufox"); if has_camoufox_profiles { - // Check if GeoIP database is available - return Ok(!Self::is_geoip_database_available()); + return Ok(!Self::is_geoip_database_available() || Self::is_geoip_stale()); } Ok(false) @@ -201,6 +219,17 @@ impl GeoIPDownloader { file.flush().await?; + // Write download timestamp + let timestamp_path = Self::get_timestamp_path(); + if let Some(parent) = timestamp_path.parent() { + let _ = fs::create_dir_all(parent).await; + } + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let _ = fs::write(×tamp_path, now.to_string()).await; + // Emit completion let _ = events::emit( "geoip-download-progress", @@ -362,6 +391,31 @@ mod tests { assert!(path.to_string_lossy().ends_with("GeoLite2-City.mmdb")); } + #[test] + fn test_is_geoip_stale() { + let tmp = tempfile::TempDir::new().unwrap(); + let _guard = crate::app_dirs::set_test_cache_dir(tmp.path().to_path_buf()); + + // No timestamp file → stale + assert!(GeoIPDownloader::is_geoip_stale()); + + let timestamp_path = GeoIPDownloader::get_timestamp_path(); + std::fs::create_dir_all(timestamp_path.parent().unwrap()).unwrap(); + + // Recent timestamp → not stale + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + std::fs::write(×tamp_path, now.to_string()).unwrap(); + assert!(!GeoIPDownloader::is_geoip_stale()); + + // 8 days ago → stale + let eight_days_ago = now - 8 * 24 * 60 * 60; + std::fs::write(×tamp_path, eight_days_ago.to_string()).unwrap(); + assert!(GeoIPDownloader::is_geoip_stale()); + } + #[test] fn test_is_geoip_database_available() { // Test that the function works correctly regardless of file system state. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8a682c1..6c8c74f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -305,7 +305,18 @@ async fn import_cookies_from_file( { return Err("Cookie import requires an active Pro subscription".to_string()); } - cookie_manager::CookieManager::import_netscape_cookies(&app_handle, &profile_id, &content).await + cookie_manager::CookieManager::import_cookies(&app_handle, &profile_id, &content).await +} + +#[tauri::command] +async fn export_profile_cookies(profile_id: String, format: String) -> Result { + if !crate::cloud_auth::CLOUD_AUTH + .has_active_paid_subscription() + .await + { + return Err("Cookie export requires an active Pro subscription".to_string()); + } + cookie_manager::CookieManager::export_cookies(&profile_id, &format) } #[tauri::command] @@ -1331,6 +1342,7 @@ pub fn run() { read_profile_cookies, copy_profile_cookies, import_cookies_from_file, + export_profile_cookies, check_wayfern_terms_accepted, check_wayfern_downloaded, accept_wayfern_terms, diff --git a/src-tauri/src/vpn/storage.rs b/src-tauri/src/vpn/storage.rs index edc35f9..c578070 100644 --- a/src-tauri/src/vpn/storage.rs +++ b/src-tauri/src/vpn/storage.rs @@ -97,22 +97,17 @@ impl VpnStorage { /// Get the storage file path fn get_storage_path() -> PathBuf { - let data_dir = directories::ProjectDirs::from("com", "donut", "donutbrowser") - .map(|dirs| dirs.data_local_dir().to_path_buf()) - .unwrap_or_else(|| PathBuf::from(".")); - - if !data_dir.exists() { - let _ = fs::create_dir_all(&data_dir); + let vpn_dir = crate::app_dirs::vpn_dir(); + if !vpn_dir.exists() { + let _ = fs::create_dir_all(&vpn_dir); } - - data_dir.join("vpn_configs.json") + Self::migrate_from_old_location(&vpn_dir); + vpn_dir.join("vpn_configs.json") } /// Get or create the encryption key fn get_or_create_key() -> [u8; 32] { - let key_path = directories::ProjectDirs::from("com", "donut", "donutbrowser") - .map(|dirs| dirs.data_local_dir().join(".vpn_key")) - .unwrap_or_else(|| PathBuf::from(".vpn_key")); + let key_path = crate::app_dirs::vpn_dir().join(".vpn_key"); if key_path.exists() { if let Ok(key_data) = fs::read(&key_path) { @@ -138,6 +133,22 @@ impl VpnStorage { key } + /// Migrate VPN configs from the old ProjectDirs location to the new app_dirs location. + fn migrate_from_old_location(new_dir: &std::path::Path) { + let old_dir = match directories::ProjectDirs::from("com", "donut", "donutbrowser") { + Some(dirs) => dirs.data_local_dir().to_path_buf(), + None => return, + }; + + for filename in &["vpn_configs.json", ".vpn_key"] { + let old_path = old_dir.join(filename); + let new_path = new_dir.join(filename); + if old_path.exists() && !new_path.exists() { + let _ = fs::copy(&old_path, &new_path); + } + } + } + /// Load storage data from disk fn load_storage(&self) -> Result { if !self.storage_path.exists() { @@ -423,8 +434,7 @@ mod tests { fn create_test_storage() -> (VpnStorage, TempDir) { let temp_dir = TempDir::new().unwrap(); - let mut storage = VpnStorage::new(); - storage.storage_path = temp_dir.path().join("test_vpn_configs.json"); + let storage = VpnStorage::with_dir(temp_dir.path()); (storage, temp_dir) } diff --git a/src/app/page.tsx b/src/app/page.tsx index 38b2d1f..7687964 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog"; import { CommercialTrialModal } from "@/components/commercial-trial-modal"; import { CookieCopyDialog } from "@/components/cookie-copy-dialog"; +import { CookieExportDialog } from "@/components/cookie-export-dialog"; import { CookieImportDialog } from "@/components/cookie-import-dialog"; import { CreateProfileDialog } from "@/components/create-profile-dialog"; import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"; @@ -146,6 +147,9 @@ export default function Home() { const [cookieImportDialogOpen, setCookieImportDialogOpen] = useState(false); const [currentProfileForCookieImport, setCurrentProfileForCookieImport] = useState(null); + const [cookieExportDialogOpen, setCookieExportDialogOpen] = useState(false); + const [currentProfileForCookieExport, setCurrentProfileForCookieExport] = + useState(null); const [selectedProfilesForCookies, setSelectedProfilesForCookies] = useState< string[] >([]); @@ -697,6 +701,11 @@ export default function Home() { setCookieImportDialogOpen(true); }, []); + const handleExportCookies = useCallback((profile: BrowserProfile) => { + setCurrentProfileForCookieExport(profile); + setCookieExportDialogOpen(true); + }, []); + const handleGroupAssignmentComplete = useCallback(async () => { // No need to manually reload - useProfileEvents will handle the update setGroupAssignmentDialogOpen(false); @@ -1004,6 +1013,7 @@ export default function Home() { onConfigureCamoufox={handleConfigureCamoufox} onCopyCookiesToProfile={handleCopyCookiesToProfile} onImportCookies={handleImportCookies} + onExportCookies={handleExportCookies} runningProfiles={runningProfiles} isUpdating={isUpdating} onDeleteSelectedProfiles={handleDeleteSelectedProfiles} @@ -1156,6 +1166,15 @@ export default function Home() { profile={currentProfileForCookieImport} /> + { + setCookieExportDialogOpen(false); + setCurrentProfileForCookieExport(null); + }} + profile={currentProfileForCookieExport} + /> + setShowBulkDeleteConfirmation(false)} diff --git a/src/components/cookie-export-dialog.tsx b/src/components/cookie-export-dialog.tsx new file mode 100644 index 0000000..e343e5c --- /dev/null +++ b/src/components/cookie-export-dialog.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { invoke } from "@tauri-apps/api/core"; +import { save } from "@tauri-apps/plugin-dialog"; +import { writeTextFile } from "@tauri-apps/plugin-fs"; +import { useCallback, useState } from "react"; +import { LuDownload } from "react-icons/lu"; +import { toast } from "sonner"; +import { LoadingButton } from "@/components/loading-button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { RippleButton } from "@/components/ui/ripple"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { BrowserProfile } from "@/types"; + +interface CookieExportDialogProps { + isOpen: boolean; + onClose: () => void; + profile: BrowserProfile | null; +} + +export function CookieExportDialog({ + isOpen, + onClose, + profile, +}: CookieExportDialogProps) { + const [format, setFormat] = useState<"netscape" | "json">("json"); + const [isExporting, setIsExporting] = useState(false); + + const handleClose = useCallback(() => { + setFormat("json"); + setIsExporting(false); + onClose(); + }, [onClose]); + + const handleExport = useCallback(async () => { + if (!profile) return; + setIsExporting(true); + try { + const content = await invoke("export_profile_cookies", { + profileId: profile.id, + format, + }); + + const ext = format === "json" ? "json" : "txt"; + const defaultName = `${profile.name}_cookies.${ext}`; + + const filePath = await save({ + defaultPath: defaultName, + filters: [ + { + name: format === "json" ? "JSON" : "Text", + extensions: [ext], + }, + ], + }); + + if (!filePath) { + setIsExporting(false); + return; + } + + await writeTextFile(filePath, content); + toast.success("Cookies exported successfully"); + handleClose(); + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)); + } finally { + setIsExporting(false); + } + }, [profile, format, handleClose]); + + return ( + + + + Export Cookies + + Export cookies from this profile. + + + +
+
+ + +
+
+ + + + Cancel + + void handleExport()} + > + + Export + + +
+
+ ); +} diff --git a/src/components/cookie-import-dialog.tsx b/src/components/cookie-import-dialog.tsx index 8c4d99b..cc5e5e2 100644 --- a/src/components/cookie-import-dialog.tsx +++ b/src/components/cookie-import-dialog.tsx @@ -29,9 +29,18 @@ interface CookieImportDialogProps { } const countCookies = (content: string): number => { + const trimmed = content.trim(); + if (trimmed.startsWith("[")) { + try { + const arr = JSON.parse(trimmed); + if (Array.isArray(arr)) return arr.length; + } catch { + // Fall through to Netscape counting + } + } return content.split("\n").filter((line) => { - const trimmed = line.trim(); - return trimmed && !trimmed.startsWith("#"); + const l = line.trim(); + return l && !l.startsWith("#"); }).length; }; @@ -98,7 +107,8 @@ export function CookieImportDialog({ Import Cookies - {!fileContent && "Import cookies from a Netscape format file."} + {!fileContent && + "Import cookies from a Netscape or JSON format file."} {fileContent && !result && `${cookieCount} cookies found in ${fileName}`} @@ -124,14 +134,14 @@ export function CookieImportDialog({ >

- Click to choose a Netscape cookie file + Click to choose a cookie file
- (.txt or .cookies) + (.txt, .cookies, or .json)

{ const file = e.target.files?.[0]; diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index 2f47ff3..cfb83d3 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -177,6 +177,7 @@ type TableMeta = { onCloneProfile?: (profile: BrowserProfile) => void; onCopyCookiesToProfile?: (profile: BrowserProfile) => void; onImportCookies?: (profile: BrowserProfile) => void; + onExportCookies?: (profile: BrowserProfile) => void; // Traffic snapshots (lightweight real-time data) trafficSnapshots: Record; @@ -758,6 +759,7 @@ interface ProfilesDataTableProps { onConfigureCamoufox: (profile: BrowserProfile) => void; onCopyCookiesToProfile?: (profile: BrowserProfile) => void; onImportCookies?: (profile: BrowserProfile) => void; + onExportCookies?: (profile: BrowserProfile) => void; runningProfiles: Set; isUpdating: (browser: string) => boolean; onDeleteSelectedProfiles: (profileIds: string[]) => Promise; @@ -785,6 +787,7 @@ export function ProfilesDataTable({ onConfigureCamoufox, onCopyCookiesToProfile, onImportCookies, + onExportCookies, runningProfiles, isUpdating, onAssignProfilesToGroup, @@ -1475,6 +1478,7 @@ export function ProfilesDataTable({ onConfigureCamoufox, onCopyCookiesToProfile, onImportCookies, + onExportCookies, // Traffic snapshots (lightweight real-time data) trafficSnapshots, @@ -1537,6 +1541,7 @@ export function ProfilesDataTable({ onConfigureCamoufox, onCopyCookiesToProfile, onImportCookies, + onExportCookies, syncStatuses, onOpenProfileSyncDialog, onToggleProfileSync, @@ -2424,6 +2429,23 @@ export function ProfilesDataTable({ )} + {(profile.browser === "camoufox" || + profile.browser === "wayfern") && + meta.onExportCookies && ( + { + if (meta.crossOsUnlocked) { + meta.onExportCookies?.(profile); + } + }} + disabled={isDisabled || !meta.crossOsUnlocked} + > + + Export Cookies + {!meta.crossOsUnlocked && } + + + )} { meta.onCloneProfile?.(profile); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index c51e16b..42f76c0 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -507,18 +507,28 @@ "cookies": { "import": { "title": "Import Cookies", - "description": "Import cookies from a Netscape format file.", + "description": "Import cookies from a Netscape or JSON format file.", "selectFile": "Choose File", "preview": "{{count}} cookies found", "success": "Successfully imported {{imported}} cookies ({{replaced}} replaced)", "error": "Failed to import cookies", "proFeature": "Cookie import is a Pro feature" + }, + "export": { + "title": "Export Cookies", + "description": "Export cookies from this profile.", + "formatLabel": "Format", + "netscape": "Netscape TXT", + "json": "JSON", + "success": "Cookies exported successfully", + "error": "Failed to export cookies" } }, "pro": { "badge": "PRO", "fingerprintLocked": "Fingerprint editing is a Pro feature", "cookieCopyLocked": "Cookie copying is a Pro feature", - "cookieImportLocked": "Cookie import is a Pro feature" + "cookieImportLocked": "Cookie import is a Pro feature", + "cookieExportLocked": "Cookie export is a Pro feature" } } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index f1959c0..3f9e6ed 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -507,18 +507,28 @@ "cookies": { "import": { "title": "Importar Cookies", - "description": "Importar cookies desde un archivo en formato Netscape.", + "description": "Importar cookies desde un archivo en formato Netscape o JSON.", "selectFile": "Elegir Archivo", "preview": "{{count}} cookies encontradas", "success": "Se importaron {{imported}} cookies exitosamente ({{replaced}} reemplazadas)", "error": "Error al importar cookies", "proFeature": "La importación de cookies es una función Pro" + }, + "export": { + "title": "Exportar Cookies", + "description": "Exportar cookies de este perfil.", + "formatLabel": "Formato", + "netscape": "Netscape TXT", + "json": "JSON", + "success": "Cookies exportadas exitosamente", + "error": "Error al exportar cookies" } }, "pro": { "badge": "PRO", "fingerprintLocked": "La edición de huellas digitales es una función Pro", "cookieCopyLocked": "La copia de cookies es una función Pro", - "cookieImportLocked": "La importación de cookies es una función Pro" + "cookieImportLocked": "La importación de cookies es una función Pro", + "cookieExportLocked": "La exportación de cookies es una función Pro" } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 0096c4f..34034f2 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -507,18 +507,28 @@ "cookies": { "import": { "title": "Importer des Cookies", - "description": "Importer des cookies depuis un fichier au format Netscape.", + "description": "Importer des cookies depuis un fichier au format Netscape ou JSON.", "selectFile": "Choisir un Fichier", "preview": "{{count}} cookies trouvés", "success": "{{imported}} cookies importés avec succès ({{replaced}} remplacés)", "error": "Échec de l'importation des cookies", "proFeature": "L'importation de cookies est une fonctionnalité Pro" + }, + "export": { + "title": "Exporter les Cookies", + "description": "Exporter les cookies de ce profil.", + "formatLabel": "Format", + "netscape": "Netscape TXT", + "json": "JSON", + "success": "Cookies exportés avec succès", + "error": "Échec de l'exportation des cookies" } }, "pro": { "badge": "PRO", "fingerprintLocked": "La modification d'empreinte est une fonctionnalité Pro", "cookieCopyLocked": "La copie de cookies est une fonctionnalité Pro", - "cookieImportLocked": "L'importation de cookies est une fonctionnalité Pro" + "cookieImportLocked": "L'importation de cookies est une fonctionnalité Pro", + "cookieExportLocked": "L'exportation de cookies est une fonctionnalité Pro" } } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index c5795e4..9793742 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -507,18 +507,28 @@ "cookies": { "import": { "title": "Cookieのインポート", - "description": "Netscape形式のファイルからCookieをインポートします。", + "description": "NetscapeまたはJSON形式のファイルからCookieをインポートします。", "selectFile": "ファイルを選択", "preview": "{{count}}件のCookieが見つかりました", "success": "{{imported}}件のCookieをインポートしました({{replaced}}件を置換)", "error": "Cookieのインポートに失敗しました", "proFeature": "Cookieのインポートはプロ機能です" + }, + "export": { + "title": "Cookieのエクスポート", + "description": "このプロファイルからCookieをエクスポートします。", + "formatLabel": "形式", + "netscape": "Netscape TXT", + "json": "JSON", + "success": "Cookieのエクスポートに成功しました", + "error": "Cookieのエクスポートに失敗しました" } }, "pro": { "badge": "PRO", "fingerprintLocked": "フィンガープリント編集はプロ機能です", "cookieCopyLocked": "Cookieのコピーはプロ機能です", - "cookieImportLocked": "Cookieのインポートはプロ機能です" + "cookieImportLocked": "Cookieのインポートはプロ機能です", + "cookieExportLocked": "Cookieのエクスポートはプロ機能です" } } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 2b903f4..b37b6de 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -507,18 +507,28 @@ "cookies": { "import": { "title": "Importar Cookies", - "description": "Importar cookies de um arquivo no formato Netscape.", + "description": "Importar cookies de um arquivo no formato Netscape ou JSON.", "selectFile": "Escolher Arquivo", "preview": "{{count}} cookies encontrados", "success": "{{imported}} cookies importados com sucesso ({{replaced}} substituídos)", "error": "Falha ao importar cookies", "proFeature": "A importação de cookies é um recurso Pro" + }, + "export": { + "title": "Exportar Cookies", + "description": "Exportar cookies deste perfil.", + "formatLabel": "Formato", + "netscape": "Netscape TXT", + "json": "JSON", + "success": "Cookies exportados com sucesso", + "error": "Falha ao exportar cookies" } }, "pro": { "badge": "PRO", "fingerprintLocked": "A edição de impressão digital é um recurso Pro", "cookieCopyLocked": "A cópia de cookies é um recurso Pro", - "cookieImportLocked": "A importação de cookies é um recurso Pro" + "cookieImportLocked": "A importação de cookies é um recurso Pro", + "cookieExportLocked": "A exportação de cookies é um recurso Pro" } } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index a8b81ec..300f4c1 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -507,18 +507,28 @@ "cookies": { "import": { "title": "Импорт Cookies", - "description": "Импорт cookies из файла в формате Netscape.", + "description": "Импорт cookies из файла в формате Netscape или JSON.", "selectFile": "Выбрать файл", "preview": "Найдено {{count}} cookies", "success": "Успешно импортировано {{imported}} cookies ({{replaced}} заменено)", "error": "Ошибка импорта cookies", "proFeature": "Импорт cookies — функция Pro" + }, + "export": { + "title": "Экспорт Cookies", + "description": "Экспорт cookies из этого профиля.", + "formatLabel": "Формат", + "netscape": "Netscape TXT", + "json": "JSON", + "success": "Cookies успешно экспортированы", + "error": "Ошибка экспорта cookies" } }, "pro": { "badge": "PRO", "fingerprintLocked": "Редактирование отпечатка — функция Pro", "cookieCopyLocked": "Копирование cookies — функция Pro", - "cookieImportLocked": "Импорт cookies — функция Pro" + "cookieImportLocked": "Импорт cookies — функция Pro", + "cookieExportLocked": "Экспорт cookies — функция Pro" } } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index ab3dd81..a8fcdbc 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -507,18 +507,28 @@ "cookies": { "import": { "title": "导入 Cookies", - "description": "从 Netscape 格式文件导入 Cookies。", + "description": "从 Netscape 或 JSON 格式文件导入 Cookies。", "selectFile": "选择文件", "preview": "找到 {{count}} 个 Cookies", "success": "成功导入 {{imported}} 个 Cookies(替换了 {{replaced}} 个)", "error": "导入 Cookies 失败", "proFeature": "导入 Cookies 是 Pro 功能" + }, + "export": { + "title": "导出 Cookies", + "description": "从此配置文件导出 Cookies。", + "formatLabel": "格式", + "netscape": "Netscape TXT", + "json": "JSON", + "success": "Cookies 导出成功", + "error": "导出 Cookies 失败" } }, "pro": { "badge": "PRO", "fingerprintLocked": "指纹编辑是 Pro 功能", "cookieCopyLocked": "Cookie 复制是 Pro 功能", - "cookieImportLocked": "Cookie 导入是 Pro 功能" + "cookieImportLocked": "Cookie 导入是 Pro 功能", + "cookieExportLocked": "Cookie 导出是 Pro 功能" } }