feat: add more import/export formats for cookies

This commit is contained in:
zhom
2026-02-21 22:13:02 +04:00
parent 206be3ff12
commit 8b9ad44ebc
17 changed files with 769 additions and 42 deletions
+2
View File
@@ -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",
+5
View File
@@ -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<Option<PathBuf>> = 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]
+397 -3
View File
@@ -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<UnifiedCookie>, Vec<String>) {
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<Value> = 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<UnifiedCookie>, Vec<String>) {
let trimmed = content.trim();
if trimmed.starts_with('[') && serde_json::from_str::<Vec<Value>>(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<Value> = 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<String, String> {
let result = Self::read_cookies(profile_id)?;
let all_cookies: Vec<UnifiedCookie> =
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<Value> = 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);
}
}
+59 -5
View File
@@ -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(&timestamp_path) else {
return true;
};
let Ok(timestamp) = content.trim().parse::<u64>() 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<bool, Box<dyn std::error::Error + Send + Sync>> {
// 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(&timestamp_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(&timestamp_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(&timestamp_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.
+13 -1
View File
@@ -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<String, String> {
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,
+23 -13
View File
@@ -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<VpnStorageData, VpnError> {
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)
}