mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-04-22 03:46:43 +02:00
feat: add more import/export formats for cookies
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<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(×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.
|
||||
|
||||
+13
-1
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<BrowserProfile | null>(null);
|
||||
const [cookieExportDialogOpen, setCookieExportDialogOpen] = useState(false);
|
||||
const [currentProfileForCookieExport, setCurrentProfileForCookieExport] =
|
||||
useState<BrowserProfile | null>(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}
|
||||
/>
|
||||
|
||||
<CookieExportDialog
|
||||
isOpen={cookieExportDialogOpen}
|
||||
onClose={() => {
|
||||
setCookieExportDialogOpen(false);
|
||||
setCurrentProfileForCookieExport(null);
|
||||
}}
|
||||
profile={currentProfileForCookieExport}
|
||||
/>
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={showBulkDeleteConfirmation}
|
||||
onClose={() => setShowBulkDeleteConfirmation(false)}
|
||||
|
||||
@@ -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<string>("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 (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export Cookies</DialogTitle>
|
||||
<DialogDescription>
|
||||
Export cookies from this profile.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Format</Label>
|
||||
<Select
|
||||
value={format}
|
||||
onValueChange={(v) => setFormat(v as "netscape" | "json")}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="json">JSON</SelectItem>
|
||||
<SelectItem value="netscape">Netscape TXT</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isExporting}
|
||||
onClick={() => void handleExport()}
|
||||
>
|
||||
<LuDownload className="w-4 h-4 mr-2" />
|
||||
Export
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import Cookies</DialogTitle>
|
||||
<DialogDescription>
|
||||
{!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({
|
||||
>
|
||||
<LuUpload className="w-10 h-10 text-muted-foreground mb-4" />
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Click to choose a Netscape cookie file
|
||||
Click to choose a cookie file
|
||||
<br />
|
||||
<span className="text-xs">(.txt or .cookies)</span>
|
||||
<span className="text-xs">(.txt, .cookies, or .json)</span>
|
||||
</p>
|
||||
<input
|
||||
id="cookie-file-input"
|
||||
type="file"
|
||||
accept=".txt,.cookies"
|
||||
accept=".txt,.cookies,.json"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
@@ -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<string, TrafficSnapshot>;
|
||||
@@ -758,6 +759,7 @@ interface ProfilesDataTableProps {
|
||||
onConfigureCamoufox: (profile: BrowserProfile) => void;
|
||||
onCopyCookiesToProfile?: (profile: BrowserProfile) => void;
|
||||
onImportCookies?: (profile: BrowserProfile) => void;
|
||||
onExportCookies?: (profile: BrowserProfile) => void;
|
||||
runningProfiles: Set<string>;
|
||||
isUpdating: (browser: string) => boolean;
|
||||
onDeleteSelectedProfiles: (profileIds: string[]) => Promise<void>;
|
||||
@@ -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({
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(profile.browser === "camoufox" ||
|
||||
profile.browser === "wayfern") &&
|
||||
meta.onExportCookies && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (meta.crossOsUnlocked) {
|
||||
meta.onExportCookies?.(profile);
|
||||
}
|
||||
}}
|
||||
disabled={isDisabled || !meta.crossOsUnlocked}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
Export Cookies
|
||||
{!meta.crossOsUnlocked && <ProBadge />}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
meta.onCloneProfile?.(profile);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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のエクスポートはプロ機能です"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 功能"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user