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
+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>
);