feat: netscape cookie import

This commit is contained in:
zhom
2026-02-21 15:50:23 +04:00
parent 97da1ca288
commit c61b3d3188
19 changed files with 657 additions and 85 deletions
+110
View File
@@ -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}")),
}
}
}
+22
View File
@@ -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,
+16
View File
@@ -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
View File
@@ -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)}
+15 -4
View File
@@ -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}
+202
View File
@@ -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>
);
}
+37 -13
View File
@@ -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)
+64 -21
View File
@@ -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>
);
+4 -7
View File
@@ -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>
+14
View File
@@ -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>
);
}
+3 -7
View File
@@ -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>
);
+17
View File
@@ -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"
}
}
+17
View File
@@ -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"
}
}
+17
View File
@@ -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"
}
}
+17
View File
@@ -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のインポートはプロ機能です"
}
}
+17
View File
@@ -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"
}
}
+17
View File
@@ -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"
}
}
+17
View File
@@ -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 功能"
}
}