mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-05-01 16:17:55 +02:00
feat: netscape cookie import
This commit is contained in:
@@ -62,6 +62,14 @@ pub struct CookieCopyResult {
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// Result of a cookie import operation
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CookieImportResult {
|
||||
pub cookies_imported: usize,
|
||||
pub cookies_replaced: usize,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct CookieManager;
|
||||
|
||||
impl CookieManager {
|
||||
@@ -493,4 +501,106 @@ impl CookieManager {
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Parse Netscape format cookies from text content
|
||||
fn parse_netscape_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;
|
||||
|
||||
for (i, line) in content.lines().enumerate() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let fields: Vec<&str> = line.split('\t').collect();
|
||||
if fields.len() < 7 {
|
||||
errors.push(format!(
|
||||
"Line {}: expected 7 tab-separated fields, got {}",
|
||||
i + 1,
|
||||
fields.len()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
let domain = fields[0].to_string();
|
||||
let path = fields[2].to_string();
|
||||
let is_secure = fields[3].eq_ignore_ascii_case("TRUE");
|
||||
let expires = fields[4].parse::<i64>().unwrap_or(0);
|
||||
let name = fields[5].to_string();
|
||||
let value = fields[6].to_string();
|
||||
|
||||
cookies.push(UnifiedCookie {
|
||||
name,
|
||||
value,
|
||||
domain,
|
||||
path,
|
||||
expires,
|
||||
is_secure,
|
||||
is_http_only: false,
|
||||
same_site: 0,
|
||||
creation_time: now,
|
||||
last_accessed: now,
|
||||
});
|
||||
}
|
||||
|
||||
(cookies, errors)
|
||||
}
|
||||
|
||||
/// Public API: Import cookies from Netscape format content
|
||||
pub async fn import_netscape_cookies(
|
||||
app_handle: &AppHandle,
|
||||
profile_id: &str,
|
||||
content: &str,
|
||||
) -> Result<CookieImportResult, String> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profiles_dir = profile_manager.get_profiles_dir();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
||||
|
||||
let profile = profiles
|
||||
.iter()
|
||||
.find(|p| p.id.to_string() == profile_id)
|
||||
.ok_or_else(|| format!("Profile not found: {profile_id}"))?;
|
||||
|
||||
let is_running = profile_manager
|
||||
.check_browser_status(app_handle.clone(), profile)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_running {
|
||||
return Err(format!(
|
||||
"Cannot import cookies while browser is running for profile: {}",
|
||||
profile.name
|
||||
));
|
||||
}
|
||||
|
||||
let (cookies, parse_errors) = Self::parse_netscape_cookies(content);
|
||||
|
||||
if cookies.is_empty() {
|
||||
return Err("No valid cookies found in the file".to_string());
|
||||
}
|
||||
|
||||
let db_path = Self::get_cookie_db_path(profile, &profiles_dir)?;
|
||||
|
||||
let write_result = match profile.browser.as_str() {
|
||||
"camoufox" => Self::write_firefox_cookies(&db_path, &cookies),
|
||||
"wayfern" => Self::write_chrome_cookies(&db_path, &cookies),
|
||||
_ => return Err(format!("Unsupported browser type: {}", profile.browser)),
|
||||
};
|
||||
|
||||
match write_result {
|
||||
Ok((imported, replaced)) => Ok(CookieImportResult {
|
||||
cookies_imported: imported,
|
||||
cookies_replaced: replaced,
|
||||
errors: parse_errors,
|
||||
}),
|
||||
Err(e) => Err(format!("Failed to write cookies: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,9 +283,30 @@ async fn copy_profile_cookies(
|
||||
app_handle: tauri::AppHandle,
|
||||
request: cookie_manager::CookieCopyRequest,
|
||||
) -> Result<Vec<cookie_manager::CookieCopyResult>, String> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await
|
||||
{
|
||||
return Err("Cookie copying requires an active Pro subscription".to_string());
|
||||
}
|
||||
cookie_manager::CookieManager::copy_cookies(&app_handle, request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn import_cookies_from_file(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_id: String,
|
||||
content: String,
|
||||
) -> Result<cookie_manager::CookieImportResult, String> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await
|
||||
{
|
||||
return Err("Cookie import requires an active Pro subscription".to_string());
|
||||
}
|
||||
cookie_manager::CookieManager::import_netscape_cookies(&app_handle, &profile_id, &content).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn check_wayfern_terms_accepted() -> bool {
|
||||
wayfern_terms::WayfernTermsManager::instance().is_terms_accepted()
|
||||
@@ -1313,6 +1334,7 @@ pub fn run() {
|
||||
enable_sync_for_all_entities,
|
||||
read_profile_cookies,
|
||||
copy_profile_cookies,
|
||||
import_cookies_from_file,
|
||||
check_wayfern_terms_accepted,
|
||||
check_wayfern_downloaded,
|
||||
accept_wayfern_terms,
|
||||
|
||||
@@ -2008,6 +2008,14 @@ pub async fn update_camoufox_config(
|
||||
profile_id: String,
|
||||
config: CamoufoxConfig,
|
||||
) -> Result<(), String> {
|
||||
if config.fingerprint.is_some()
|
||||
&& !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await
|
||||
{
|
||||
return Err("Fingerprint editing requires an active Pro subscription".to_string());
|
||||
}
|
||||
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.is_fingerprint_os_allowed(config.os.as_deref())
|
||||
.await
|
||||
@@ -2028,6 +2036,14 @@ pub async fn update_wayfern_config(
|
||||
profile_id: String,
|
||||
config: WayfernConfig,
|
||||
) -> Result<(), String> {
|
||||
if config.fingerprint.is_some()
|
||||
&& !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await
|
||||
{
|
||||
return Err("Fingerprint editing requires an active Pro subscription".to_string());
|
||||
}
|
||||
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.is_fingerprint_os_allowed(config.os.as_deref())
|
||||
.await
|
||||
|
||||
+48
-26
@@ -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 { CookieImportDialog } from "@/components/cookie-import-dialog";
|
||||
import { CreateProfileDialog } from "@/components/create-profile-dialog";
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
||||
import { GroupAssignmentDialog } from "@/components/group-assignment-dialog";
|
||||
@@ -142,6 +143,9 @@ export default function Home() {
|
||||
const [proxyAssignmentDialogOpen, setProxyAssignmentDialogOpen] =
|
||||
useState(false);
|
||||
const [cookieCopyDialogOpen, setCookieCopyDialogOpen] = useState(false);
|
||||
const [cookieImportDialogOpen, setCookieImportDialogOpen] = useState(false);
|
||||
const [currentProfileForCookieImport, setCurrentProfileForCookieImport] =
|
||||
useState<BrowserProfile | null>(null);
|
||||
const [selectedProfilesForCookies, setSelectedProfilesForCookies] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
@@ -688,6 +692,11 @@ export default function Home() {
|
||||
setCookieCopyDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleImportCookies = useCallback((profile: BrowserProfile) => {
|
||||
setCurrentProfileForCookieImport(profile);
|
||||
setCookieImportDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleGroupAssignmentComplete = useCallback(async () => {
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
setGroupAssignmentDialogOpen(false);
|
||||
@@ -737,34 +746,37 @@ export default function Home() {
|
||||
let unlisten: (() => void) | undefined;
|
||||
(async () => {
|
||||
try {
|
||||
unlisten = await listen<{ profile_id: string; status: string }>(
|
||||
"profile-sync-status",
|
||||
(event) => {
|
||||
const { profile_id, status } = event.payload;
|
||||
if (!userInitiatedSyncIds.current.has(profile_id)) return;
|
||||
unlisten = await listen<{
|
||||
profile_id: string;
|
||||
status: string;
|
||||
error?: string;
|
||||
}>("profile-sync-status", (event) => {
|
||||
const { profile_id, status, error } = event.payload;
|
||||
if (!userInitiatedSyncIds.current.has(profile_id)) return;
|
||||
|
||||
const toastId = `sync-${profile_id}`;
|
||||
const profile = profiles.find((p) => p.id === profile_id);
|
||||
const name = profile?.name ?? "Unknown";
|
||||
const toastId = `sync-${profile_id}`;
|
||||
const profile = profiles.find((p) => p.id === profile_id);
|
||||
const name = profile?.name ?? "Unknown";
|
||||
|
||||
if (status === "syncing") {
|
||||
showToast({
|
||||
type: "loading",
|
||||
title: `Syncing profile '${name}'...`,
|
||||
id: toastId,
|
||||
duration: 30000,
|
||||
});
|
||||
} else if (status === "synced") {
|
||||
dismissToast(toastId);
|
||||
showSuccessToast(`Profile '${name}' synced successfully`);
|
||||
userInitiatedSyncIds.current.delete(profile_id);
|
||||
} else if (status === "error") {
|
||||
dismissToast(toastId);
|
||||
showErrorToast(`Failed to sync profile '${name}'`);
|
||||
userInitiatedSyncIds.current.delete(profile_id);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (status === "syncing") {
|
||||
showToast({
|
||||
type: "loading",
|
||||
title: `Syncing profile '${name}'...`,
|
||||
id: toastId,
|
||||
duration: 30000,
|
||||
});
|
||||
} else if (status === "synced") {
|
||||
dismissToast(toastId);
|
||||
showSuccessToast(`Profile '${name}' synced successfully`);
|
||||
userInitiatedSyncIds.current.delete(profile_id);
|
||||
} else if (status === "error") {
|
||||
dismissToast(toastId);
|
||||
showErrorToast(
|
||||
`Failed to sync profile '${name}'${error ? `: ${error}` : ""}`,
|
||||
);
|
||||
userInitiatedSyncIds.current.delete(profile_id);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to listen for sync status events:", error);
|
||||
}
|
||||
@@ -991,6 +1003,7 @@ export default function Home() {
|
||||
onRenameProfile={handleRenameProfile}
|
||||
onConfigureCamoufox={handleConfigureCamoufox}
|
||||
onCopyCookiesToProfile={handleCopyCookiesToProfile}
|
||||
onImportCookies={handleImportCookies}
|
||||
runningProfiles={runningProfiles}
|
||||
isUpdating={isUpdating}
|
||||
onDeleteSelectedProfiles={handleDeleteSelectedProfiles}
|
||||
@@ -1134,6 +1147,15 @@ export default function Home() {
|
||||
onCopyComplete={() => setSelectedProfilesForCookies([])}
|
||||
/>
|
||||
|
||||
<CookieImportDialog
|
||||
isOpen={cookieImportDialogOpen}
|
||||
onClose={() => {
|
||||
setCookieImportDialogOpen(false);
|
||||
setCurrentProfileForCookieImport(null);
|
||||
}}
|
||||
profile={currentProfileForCookieImport}
|
||||
/>
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={showBulkDeleteConfirmation}
|
||||
onClose={() => setShowBulkDeleteConfirmation(false)}
|
||||
|
||||
@@ -26,7 +26,9 @@ const getCurrentOS = (): CamoufoxOS => {
|
||||
return "linux";
|
||||
};
|
||||
|
||||
import { LuLock } from "react-icons/lu";
|
||||
import { LoadingButton } from "./loading-button";
|
||||
import { ProBadge } from "./ui/pro-badge";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface CamoufoxConfigDialogProps {
|
||||
@@ -155,13 +157,13 @@ export function CamoufoxConfigDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="flex-1 h-[300px]">
|
||||
<div className="py-4">
|
||||
<div className="py-4 relative">
|
||||
{profile.browser === "wayfern" ? (
|
||||
<WayfernConfigForm
|
||||
config={config as WayfernConfig}
|
||||
onConfigChange={updateConfig}
|
||||
forceAdvanced={true}
|
||||
readOnly={isRunning}
|
||||
readOnly={isRunning || !crossOsUnlocked}
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
/>
|
||||
) : (
|
||||
@@ -169,11 +171,20 @@ export function CamoufoxConfigDialog({
|
||||
config={config as CamoufoxConfig}
|
||||
onConfigChange={updateConfig}
|
||||
forceAdvanced={true}
|
||||
readOnly={isRunning}
|
||||
readOnly={isRunning || !crossOsUnlocked}
|
||||
browserType="camoufox"
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
/>
|
||||
)}
|
||||
{!crossOsUnlocked && (
|
||||
<div className="absolute inset-0 backdrop-blur-sm bg-background/60 z-10 flex flex-col items-center justify-center gap-2">
|
||||
<LuLock className="w-6 h-6 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground font-medium">
|
||||
Fingerprint editing is a Pro feature
|
||||
</p>
|
||||
<ProBadge />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -181,7 +192,7 @@ export function CamoufoxConfigDialog({
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
{isRunning ? "Close" : "Cancel"}
|
||||
</RippleButton>
|
||||
{!isRunning && (
|
||||
{!isRunning && crossOsUnlocked && (
|
||||
<LoadingButton
|
||||
isLoading={isSaving}
|
||||
onClick={handleSave}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useState } from "react";
|
||||
import { LuUpload } 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 { RippleButton } from "@/components/ui/ripple";
|
||||
import type { BrowserProfile } from "@/types";
|
||||
|
||||
interface CookieImportResult {
|
||||
cookies_imported: number;
|
||||
cookies_replaced: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
interface CookieImportDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
profile: BrowserProfile | null;
|
||||
}
|
||||
|
||||
const countCookies = (content: string): number => {
|
||||
return content.split("\n").filter((line) => {
|
||||
const trimmed = line.trim();
|
||||
return trimmed && !trimmed.startsWith("#");
|
||||
}).length;
|
||||
};
|
||||
|
||||
export function CookieImportDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
profile,
|
||||
}: CookieImportDialogProps) {
|
||||
const [fileContent, setFileContent] = useState<string | null>(null);
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
const [cookieCount, setCookieCount] = useState(0);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [result, setResult] = useState<CookieImportResult | null>(null);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setFileContent(null);
|
||||
setFileName(null);
|
||||
setCookieCount(0);
|
||||
setIsImporting(false);
|
||||
setResult(null);
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
resetState();
|
||||
onClose();
|
||||
}, [resetState, onClose]);
|
||||
|
||||
const handleFileRead = useCallback((file: File) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
setFileContent(content);
|
||||
setFileName(file.name);
|
||||
setCookieCount(countCookies(content));
|
||||
};
|
||||
reader.onerror = () => {
|
||||
toast.error("Failed to read file");
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}, []);
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
if (!fileContent || !profile) return;
|
||||
setIsImporting(true);
|
||||
try {
|
||||
const importResult = await invoke<CookieImportResult>(
|
||||
"import_cookies_from_file",
|
||||
{
|
||||
profileId: profile.id,
|
||||
content: fileContent,
|
||||
},
|
||||
);
|
||||
setResult(importResult);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
}, [fileContent, profile]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import Cookies</DialogTitle>
|
||||
<DialogDescription>
|
||||
{!fileContent && "Import cookies from a Netscape format file."}
|
||||
{fileContent &&
|
||||
!result &&
|
||||
`${cookieCount} cookies found in ${fileName}`}
|
||||
{result && "Cookie import completed"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!fileContent && (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-8 transition-colors cursor-pointer border-muted-foreground/25 hover:border-muted-foreground/50"
|
||||
onClick={() =>
|
||||
document.getElementById("cookie-file-input")?.click()
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
document.getElementById("cookie-file-input")?.click();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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
|
||||
<br />
|
||||
<span className="text-xs">(.txt or .cookies)</span>
|
||||
</p>
|
||||
<input
|
||||
id="cookie-file-input"
|
||||
type="file"
|
||||
accept=".txt,.cookies"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFileRead(file);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileContent && !result && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 p-4 bg-muted/30 rounded-lg">
|
||||
<div>
|
||||
<div className="font-medium">{fileName}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{cookieCount} cookies found
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 rounded-lg bg-green-500/10">
|
||||
<div className="font-medium text-green-600 dark:text-green-400">
|
||||
Successfully imported {result.cookies_imported} cookies (
|
||||
{result.cookies_replaced} replaced)
|
||||
</div>
|
||||
{result.errors.length > 0 && (
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
{result.errors.length} line(s) skipped
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{!fileContent && (
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</RippleButton>
|
||||
)}
|
||||
|
||||
{fileContent && !result && (
|
||||
<>
|
||||
<RippleButton variant="outline" onClick={resetState}>
|
||||
Back
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isImporting}
|
||||
onClick={() => void handleImport()}
|
||||
disabled={cookieCount === 0}
|
||||
>
|
||||
Import
|
||||
</LoadingButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
{result && <RippleButton onClick={handleClose}>Done</RippleButton>}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { GoPlus } from "react-icons/go";
|
||||
import { LuLock } from "react-icons/lu";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
|
||||
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ProBadge } from "@/components/ui/pro-badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
@@ -748,12 +750,23 @@ export function CreateProfileDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<WayfernConfigForm
|
||||
config={wayfernConfig}
|
||||
onConfigChange={updateWayfernConfig}
|
||||
isCreating
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
/>
|
||||
<div className="relative">
|
||||
<WayfernConfigForm
|
||||
config={wayfernConfig}
|
||||
onConfigChange={updateWayfernConfig}
|
||||
isCreating
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
/>
|
||||
{!crossOsUnlocked && (
|
||||
<div className="absolute inset-0 backdrop-blur-sm bg-background/60 z-10 flex flex-col items-center justify-center gap-2">
|
||||
<LuLock className="w-6 h-6 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground font-medium">
|
||||
Fingerprint editing is a Pro feature
|
||||
</p>
|
||||
<ProBadge />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : selectedBrowser === "camoufox" ? (
|
||||
// Camoufox Configuration
|
||||
@@ -845,13 +858,24 @@ export function CreateProfileDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SharedCamoufoxConfigForm
|
||||
config={camoufoxConfig}
|
||||
onConfigChange={updateCamoufoxConfig}
|
||||
isCreating
|
||||
browserType="camoufox"
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
/>
|
||||
<div className="relative">
|
||||
<SharedCamoufoxConfigForm
|
||||
config={camoufoxConfig}
|
||||
onConfigChange={updateCamoufoxConfig}
|
||||
isCreating
|
||||
browserType="camoufox"
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
/>
|
||||
{!crossOsUnlocked && (
|
||||
<div className="absolute inset-0 backdrop-blur-sm bg-background/60 z-10 flex flex-col items-center justify-center gap-2">
|
||||
<LuLock className="w-6 h-6 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground font-medium">
|
||||
Fingerprint editing is a Pro feature
|
||||
</p>
|
||||
<ProBadge />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Regular Browser Configuration (should not happen in anti-detect tab)
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
LuChevronDown,
|
||||
LuChevronUp,
|
||||
LuCookie,
|
||||
LuLock,
|
||||
LuTrash2,
|
||||
LuUsers,
|
||||
} from "react-icons/lu";
|
||||
@@ -48,6 +47,7 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ProBadge } from "@/components/ui/pro-badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Table,
|
||||
@@ -176,13 +176,14 @@ type TableMeta = {
|
||||
onConfigureCamoufox?: (profile: BrowserProfile) => void;
|
||||
onCloneProfile?: (profile: BrowserProfile) => void;
|
||||
onCopyCookiesToProfile?: (profile: BrowserProfile) => void;
|
||||
onImportCookies?: (profile: BrowserProfile) => void;
|
||||
|
||||
// Traffic snapshots (lightweight real-time data)
|
||||
trafficSnapshots: Record<string, TrafficSnapshot>;
|
||||
onOpenTrafficDialog?: (profileId: string) => void;
|
||||
|
||||
// Sync
|
||||
syncStatuses: Record<string, string>;
|
||||
syncStatuses: Record<string, { status: string; error?: string }>;
|
||||
onOpenProfileSyncDialog?: (profile: BrowserProfile) => void;
|
||||
onToggleProfileSync?: (profile: BrowserProfile) => void;
|
||||
crossOsUnlocked?: boolean;
|
||||
@@ -209,6 +210,7 @@ function getProfileSyncStatusDot(
|
||||
| "error"
|
||||
| "disabled"
|
||||
| undefined,
|
||||
errorMessage?: string,
|
||||
): SyncStatusDot | null {
|
||||
const status = liveStatus ?? (profile.sync_enabled ? "synced" : "disabled");
|
||||
|
||||
@@ -230,7 +232,11 @@ function getProfileSyncStatusDot(
|
||||
animate: false,
|
||||
};
|
||||
case "error":
|
||||
return { color: "bg-red-500", tooltip: "Sync error", animate: false };
|
||||
return {
|
||||
color: "bg-red-500",
|
||||
tooltip: errorMessage ? `Sync error: ${errorMessage}` : "Sync error",
|
||||
animate: false,
|
||||
};
|
||||
case "disabled":
|
||||
if (profile.last_sync) {
|
||||
return {
|
||||
@@ -751,6 +757,7 @@ interface ProfilesDataTableProps {
|
||||
onRenameProfile: (profileId: string, newName: string) => Promise<void>;
|
||||
onConfigureCamoufox: (profile: BrowserProfile) => void;
|
||||
onCopyCookiesToProfile?: (profile: BrowserProfile) => void;
|
||||
onImportCookies?: (profile: BrowserProfile) => void;
|
||||
runningProfiles: Set<string>;
|
||||
isUpdating: (browser: string) => boolean;
|
||||
onDeleteSelectedProfiles: (profileIds: string[]) => Promise<void>;
|
||||
@@ -777,6 +784,7 @@ export function ProfilesDataTable({
|
||||
onRenameProfile,
|
||||
onConfigureCamoufox,
|
||||
onCopyCookiesToProfile,
|
||||
onImportCookies,
|
||||
runningProfiles,
|
||||
isUpdating,
|
||||
onAssignProfilesToGroup,
|
||||
@@ -900,7 +908,7 @@ export function ProfilesDataTable({
|
||||
name?: string;
|
||||
} | null>(null);
|
||||
const [syncStatuses, setSyncStatuses] = React.useState<
|
||||
Record<string, string>
|
||||
Record<string, { status: string; error?: string }>
|
||||
>({});
|
||||
|
||||
// Country proxy creation state (for inline proxy creation in dropdown)
|
||||
@@ -1041,13 +1049,17 @@ export function ProfilesDataTable({
|
||||
let unlisten: (() => void) | undefined;
|
||||
(async () => {
|
||||
try {
|
||||
unlisten = await listen<{ profile_id: string; status: string }>(
|
||||
"profile-sync-status",
|
||||
(event) => {
|
||||
const { profile_id, status } = event.payload;
|
||||
setSyncStatuses((prev) => ({ ...prev, [profile_id]: status }));
|
||||
},
|
||||
);
|
||||
unlisten = await listen<{
|
||||
profile_id: string;
|
||||
status: string;
|
||||
error?: string;
|
||||
}>("profile-sync-status", (event) => {
|
||||
const { profile_id, status, error } = event.payload;
|
||||
setSyncStatuses((prev) => ({
|
||||
...prev,
|
||||
[profile_id]: { status, error },
|
||||
}));
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to listen for sync status events:", error);
|
||||
}
|
||||
@@ -1462,6 +1474,7 @@ export function ProfilesDataTable({
|
||||
onCloneProfile,
|
||||
onConfigureCamoufox,
|
||||
onCopyCookiesToProfile,
|
||||
onImportCookies,
|
||||
|
||||
// Traffic snapshots (lightweight real-time data)
|
||||
trafficSnapshots,
|
||||
@@ -1523,6 +1536,7 @@ export function ProfilesDataTable({
|
||||
onCloneProfile,
|
||||
onConfigureCamoufox,
|
||||
onCopyCookiesToProfile,
|
||||
onImportCookies,
|
||||
syncStatuses,
|
||||
onOpenProfileSyncDialog,
|
||||
onToggleProfileSync,
|
||||
@@ -2267,7 +2281,8 @@ export function ProfilesDataTable({
|
||||
cell: ({ row, table }) => {
|
||||
const profile = row.original;
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const liveStatus = meta.syncStatuses[profile.id] as
|
||||
const syncEntry = meta.syncStatuses[profile.id];
|
||||
const liveStatus = syncEntry?.status as
|
||||
| "syncing"
|
||||
| "waiting"
|
||||
| "synced"
|
||||
@@ -2275,7 +2290,11 @@ export function ProfilesDataTable({
|
||||
| "disabled"
|
||||
| undefined;
|
||||
|
||||
const dot = getProfileSyncStatusDot(profile, liveStatus);
|
||||
const dot = getProfileSyncStatusDot(
|
||||
profile,
|
||||
liveStatus,
|
||||
syncEntry?.error,
|
||||
);
|
||||
if (!dot) return null;
|
||||
|
||||
return (
|
||||
@@ -2345,9 +2364,7 @@ export function ProfilesDataTable({
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{profile.sync_enabled ? "Disable Sync" : "Enable Sync"}
|
||||
{!meta.syncUnlocked && (
|
||||
<LuLock className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
{!meta.syncUnlocked && <ProBadge />}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
@@ -2367,7 +2384,10 @@ export function ProfilesDataTable({
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
Change Fingerprint
|
||||
<span className="flex items-center gap-2">
|
||||
Change Fingerprint
|
||||
{!meta.crossOsUnlocked && <ProBadge />}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(profile.browser === "camoufox" ||
|
||||
@@ -2375,11 +2395,33 @@ export function ProfilesDataTable({
|
||||
meta.onCopyCookiesToProfile && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
meta.onCopyCookiesToProfile?.(profile);
|
||||
if (meta.crossOsUnlocked) {
|
||||
meta.onCopyCookiesToProfile?.(profile);
|
||||
}
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
disabled={isDisabled || !meta.crossOsUnlocked}
|
||||
>
|
||||
Copy Cookies to Profile
|
||||
<span className="flex items-center gap-2">
|
||||
Copy Cookies to Profile
|
||||
{!meta.crossOsUnlocked && <ProBadge />}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(profile.browser === "camoufox" ||
|
||||
profile.browser === "wayfern") &&
|
||||
meta.onImportCookies && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (meta.crossOsUnlocked) {
|
||||
meta.onImportCookies?.(profile);
|
||||
}
|
||||
}}
|
||||
disabled={isDisabled || !meta.crossOsUnlocked}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
Import Cookies
|
||||
{!meta.crossOsUnlocked && <ProBadge />}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
@@ -2526,9 +2568,10 @@ export function ProfilesDataTable({
|
||||
)}
|
||||
{onBulkCopyCookies && (
|
||||
<DataTableActionBarAction
|
||||
tooltip="Copy Cookies"
|
||||
tooltip={crossOsUnlocked ? "Copy Cookies" : "Copy Cookies (Pro)"}
|
||||
onClick={onBulkCopyCookies}
|
||||
size="icon"
|
||||
disabled={!crossOsUnlocked}
|
||||
>
|
||||
<LuCookie />
|
||||
</DataTableActionBarAction>
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuLock } from "react-icons/lu";
|
||||
import MultipleSelector, { type Option } from "@/components/multiple-selector";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ProBadge } from "@/components/ui/pro-badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -237,9 +237,7 @@ export function SharedCamoufoxConfigForm({
|
||||
<SelectItem key={os} value={os} disabled={isDisabled}>
|
||||
<span className="flex items-center gap-2">
|
||||
{osLabels[os]}
|
||||
{isDisabled && (
|
||||
<LuLock className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
{isDisabled && <ProBadge />}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
@@ -1011,9 +1009,7 @@ export function SharedCamoufoxConfigForm({
|
||||
<SelectItem key={os} value={os} disabled={isDisabled}>
|
||||
<span className="flex items-center gap-2">
|
||||
{osLabels[os]}
|
||||
{isDisabled && (
|
||||
<LuLock className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
{isDisabled && <ProBadge />}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuEye, LuEyeOff, LuLock } from "react-icons/lu";
|
||||
import { LuEye, LuEyeOff } from "react-icons/lu";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ProBadge } from "@/components/ui/pro-badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -294,9 +295,7 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{t("sync.cloud.tabLabel")}
|
||||
{cloudBlocked && (
|
||||
<LuLock className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
{cloudBlocked && <ProBadge />}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
@@ -306,9 +305,7 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{t("sync.cloud.selfHostedTabLabel")}
|
||||
{selfHostedBlocked && (
|
||||
<LuLock className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
{selfHostedBlocked && <ProBadge />}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ProBadge({ className }: { className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-semibold px-1 py-0.5 rounded bg-primary text-primary-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
PRO
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuLock } from "react-icons/lu";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ProBadge } from "@/components/ui/pro-badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -166,9 +166,7 @@ export function WayfernConfigForm({
|
||||
<SelectItem key={os} value={os} disabled={isDisabled}>
|
||||
<span className="flex items-center gap-2">
|
||||
{osLabels[os]}
|
||||
{isDisabled && (
|
||||
<LuLock className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
{isDisabled && <ProBadge />}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
@@ -959,9 +957,7 @@ export function WayfernConfigForm({
|
||||
<SelectItem key={os} value={os} disabled={isDisabled}>
|
||||
<span className="flex items-center gap-2">
|
||||
{osLabels[os]}
|
||||
{isDisabled && (
|
||||
<LuLock className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
{isDisabled && <ProBadge />}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
|
||||
@@ -503,5 +503,22 @@
|
||||
"viewOnly": "This profile was created on {{os}} and is not supported on this system",
|
||||
"cannotLaunch": "This profile was created on {{os}} and is not supported on this system",
|
||||
"cannotModify": "Cannot modify sync settings for a cross-OS profile"
|
||||
},
|
||||
"cookies": {
|
||||
"import": {
|
||||
"title": "Import Cookies",
|
||||
"description": "Import cookies from a Netscape 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"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
"badge": "PRO",
|
||||
"fingerprintLocked": "Fingerprint editing is a Pro feature",
|
||||
"cookieCopyLocked": "Cookie copying is a Pro feature",
|
||||
"cookieImportLocked": "Cookie import is a Pro feature"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,5 +503,22 @@
|
||||
"viewOnly": "Este perfil fue creado en {{os}} y no es compatible con este sistema",
|
||||
"cannotLaunch": "Este perfil fue creado en {{os}} y no es compatible con este sistema",
|
||||
"cannotModify": "No se pueden modificar los ajustes de sincronización de un perfil de otro sistema operativo"
|
||||
},
|
||||
"cookies": {
|
||||
"import": {
|
||||
"title": "Importar Cookies",
|
||||
"description": "Importar cookies desde un archivo en formato Netscape.",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,5 +503,22 @@
|
||||
"viewOnly": "Ce profil a été créé sur {{os}} et n'est pas pris en charge sur ce système",
|
||||
"cannotLaunch": "Ce profil a été créé sur {{os}} et n'est pas pris en charge sur ce système",
|
||||
"cannotModify": "Impossible de modifier les paramètres de synchronisation d'un profil d'un autre système d'exploitation"
|
||||
},
|
||||
"cookies": {
|
||||
"import": {
|
||||
"title": "Importer des Cookies",
|
||||
"description": "Importer des cookies depuis un fichier au format Netscape.",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,5 +503,22 @@
|
||||
"viewOnly": "このプロファイルは{{os}}で作成されたもので、このシステムではサポートされていません",
|
||||
"cannotLaunch": "このプロファイルは{{os}}で作成されたもので、このシステムではサポートされていません",
|
||||
"cannotModify": "他のOSのプロファイルの同期設定は変更できません"
|
||||
},
|
||||
"cookies": {
|
||||
"import": {
|
||||
"title": "Cookieのインポート",
|
||||
"description": "Netscape形式のファイルからCookieをインポートします。",
|
||||
"selectFile": "ファイルを選択",
|
||||
"preview": "{{count}}件のCookieが見つかりました",
|
||||
"success": "{{imported}}件のCookieをインポートしました({{replaced}}件を置換)",
|
||||
"error": "Cookieのインポートに失敗しました",
|
||||
"proFeature": "Cookieのインポートはプロ機能です"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
"badge": "PRO",
|
||||
"fingerprintLocked": "フィンガープリント編集はプロ機能です",
|
||||
"cookieCopyLocked": "Cookieのコピーはプロ機能です",
|
||||
"cookieImportLocked": "Cookieのインポートはプロ機能です"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,5 +503,22 @@
|
||||
"viewOnly": "Este perfil foi criado em {{os}} e não é compatível com este sistema",
|
||||
"cannotLaunch": "Este perfil foi criado em {{os}} e não é compatível com este sistema",
|
||||
"cannotModify": "Não é possível modificar as configurações de sincronização de um perfil de outro sistema operacional"
|
||||
},
|
||||
"cookies": {
|
||||
"import": {
|
||||
"title": "Importar Cookies",
|
||||
"description": "Importar cookies de um arquivo no formato Netscape.",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,5 +503,22 @@
|
||||
"viewOnly": "Этот профиль был создан на {{os}} и не поддерживается в этой системе",
|
||||
"cannotLaunch": "Этот профиль был создан на {{os}} и не поддерживается в этой системе",
|
||||
"cannotModify": "Невозможно изменить настройки синхронизации профиля другой ОС"
|
||||
},
|
||||
"cookies": {
|
||||
"import": {
|
||||
"title": "Импорт Cookies",
|
||||
"description": "Импорт cookies из файла в формате Netscape.",
|
||||
"selectFile": "Выбрать файл",
|
||||
"preview": "Найдено {{count}} cookies",
|
||||
"success": "Успешно импортировано {{imported}} cookies ({{replaced}} заменено)",
|
||||
"error": "Ошибка импорта cookies",
|
||||
"proFeature": "Импорт cookies — функция Pro"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
"badge": "PRO",
|
||||
"fingerprintLocked": "Редактирование отпечатка — функция Pro",
|
||||
"cookieCopyLocked": "Копирование cookies — функция Pro",
|
||||
"cookieImportLocked": "Импорт cookies — функция Pro"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,5 +503,22 @@
|
||||
"viewOnly": "此配置文件在 {{os}} 上创建,不受此系统支持",
|
||||
"cannotLaunch": "此配置文件在 {{os}} 上创建,不受此系统支持",
|
||||
"cannotModify": "无法修改跨操作系统配置文件的同步设置"
|
||||
},
|
||||
"cookies": {
|
||||
"import": {
|
||||
"title": "导入 Cookies",
|
||||
"description": "从 Netscape 格式文件导入 Cookies。",
|
||||
"selectFile": "选择文件",
|
||||
"preview": "找到 {{count}} 个 Cookies",
|
||||
"success": "成功导入 {{imported}} 个 Cookies(替换了 {{replaced}} 个)",
|
||||
"error": "导入 Cookies 失败",
|
||||
"proFeature": "导入 Cookies 是 Pro 功能"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
"badge": "PRO",
|
||||
"fingerprintLocked": "指纹编辑是 Pro 功能",
|
||||
"cookieCopyLocked": "Cookie 复制是 Pro 功能",
|
||||
"cookieImportLocked": "Cookie 导入是 Pro 功能"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user