Files
donutbrowser/src/components/create-profile-dialog.tsx
T
2026-05-15 15:44:20 +04:00

1734 lines
77 KiB
TypeScript

"use client";
import { invoke } from "@tauri-apps/api/core";
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { GoPlus } from "react-icons/go";
import { LuCheck, LuChevronsUpDown } 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";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import { WayfernConfigForm } from "@/components/wayfern-config-form";
import { useBrowserDownload } from "@/hooks/use-browser-download";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import { getBrowserIcon } from "@/lib/browser-utils";
import { cn } from "@/lib/utils";
import type {
BrowserReleaseTypes,
CamoufoxConfig,
CamoufoxOS,
WayfernConfig,
WayfernOS,
} from "@/types";
const getCurrentOS = (): CamoufoxOS => {
if (typeof navigator === "undefined") return "linux";
const platform = navigator.platform.toLowerCase();
if (platform.includes("win")) return "windows";
if (platform.includes("mac")) return "macos";
return "linux";
};
import { RippleButton } from "./ui/ripple";
type BrowserTypeString = "camoufox" | "wayfern";
interface CreateProfileDialogProps {
isOpen: boolean;
onClose: () => void;
onCreateProfile: (profileData: {
name: string;
browserStr: BrowserTypeString;
version: string;
releaseType: string;
proxyId?: string;
vpnId?: string;
camoufoxConfig?: CamoufoxConfig;
wayfernConfig?: WayfernConfig;
groupId?: string;
extensionGroupId?: string;
ephemeral?: boolean;
dnsBlocklist?: string;
launchHook?: string;
password?: string;
}) => Promise<void>;
selectedGroupId?: string;
crossOsUnlocked?: boolean;
}
interface BrowserOption {
value: BrowserTypeString;
label: string;
}
const browserOptions: BrowserOption[] = [
{
value: "camoufox",
label: "Camoufox",
},
{
value: "wayfern",
label: "Wayfern",
},
];
export function CreateProfileDialog({
isOpen,
onClose,
onCreateProfile,
selectedGroupId,
crossOsUnlocked = false,
}: CreateProfileDialogProps) {
const { t } = useTranslation();
const proxyListboxIdAntiDetect = useId();
const proxyListboxIdRegular = useId();
const [profileName, setProfileName] = useState("");
const [currentStep, setCurrentStep] = useState<
"browser-selection" | "browser-config"
>("browser-selection");
const [activeTab, setActiveTab] = useState("anti-detect");
// Browser selection states
const [selectedBrowser, setSelectedBrowser] =
useState<BrowserTypeString | null>(null);
const [selectedProxyId, setSelectedProxyId] = useState<string>();
const [proxyPopoverOpen, setProxyPopoverOpen] = useState(false);
const [dnsBlocklist, setDnsBlocklist] = useState<string>("");
const [launchHook, setLaunchHook] = useState("");
// Camoufox anti-detect states
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>(() => ({
geoip: true, // Default to automatic geoip
os: getCurrentOS(), // Default to current OS
}));
// Wayfern anti-detect states
const [wayfernConfig, setWayfernConfig] = useState<WayfernConfig>(() => ({
os: getCurrentOS() as WayfernOS, // Default to current OS
}));
// Handle browser selection from the initial screen
const handleBrowserSelect = (browser: BrowserTypeString) => {
setSelectedBrowser(browser);
setCurrentStep("browser-config");
};
// Handle back button
const handleBack = () => {
setCurrentStep("browser-selection");
setSelectedBrowser(null);
setProfileName("");
setSelectedProxyId(undefined);
setLaunchHook("");
};
const handleTabChange = (value: string) => {
setActiveTab(value);
setCurrentStep("browser-selection");
setSelectedBrowser(null);
setProfileName("");
setSelectedProxyId(undefined);
setLaunchHook("");
};
const [supportedBrowsers, setSupportedBrowsers] = useState<string[]>([]);
const { storedProxies } = useProxyEvents();
const { vpnConfigs } = useVpnEvents();
const [showProxyForm, setShowProxyForm] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [ephemeral, setEphemeral] = useState(false);
const [enablePassword, setEnablePassword] = useState(false);
const [password, setPassword] = useState("");
const [passwordConfirm, setPasswordConfirm] = useState("");
const [passwordError, setPasswordError] = useState<string | null>(null);
const PASSWORD_MIN_LEN = 8;
const [selectedExtensionGroupId, setSelectedExtensionGroupId] =
useState<string>();
const [extensionGroups, setExtensionGroups] = useState<
{ id: string; name: string; extension_ids: string[] }[]
>([]);
useEffect(() => {
if (isOpen) {
void invoke<{ id: string; name: string; extension_ids: string[] }[]>(
"list_extension_groups",
)
.then(setExtensionGroups)
.catch(() => {
setExtensionGroups([]);
});
}
}, [isOpen]);
const [releaseTypes, setReleaseTypes] = useState<BrowserReleaseTypes>();
const [isLoadingReleaseTypes, setIsLoadingReleaseTypes] = useState(false);
const [releaseTypesError, setReleaseTypesError] = useState<string | null>(
null,
);
const loadingBrowserRef = useRef<string | null>(null);
// Use the browser download hook
const {
isBrowserDownloading,
downloadBrowser,
loadDownloadedVersions,
isVersionDownloaded,
downloadedVersionsMap,
} = useBrowserDownload();
const loadSupportedBrowsers = useCallback(async () => {
try {
const browsers = await invoke<string[]>("get_supported_browsers");
setSupportedBrowsers(browsers);
} catch (error) {
console.error("Failed to load supported browsers:", error);
}
}, []);
const checkAndDownloadGeoIPDatabase = useCallback(async () => {
try {
const isAvailable = await invoke<boolean>("is_geoip_database_available");
if (!isAvailable) {
console.log("GeoIP database not available, downloading...");
await invoke("download_geoip_database");
console.log("GeoIP database downloaded successfully");
}
} catch (error) {
console.error("Failed to check/download GeoIP database:", error);
// Don't show error to user as this is not critical for profile creation
}
}, []);
const loadReleaseTypes = useCallback(
async (browser: string) => {
// Set loading state
loadingBrowserRef.current = browser;
setIsLoadingReleaseTypes(true);
setReleaseTypesError(null);
try {
const rawReleaseTypes = await invoke<BrowserReleaseTypes>(
"get_browser_release_types",
{ browserStr: browser },
);
await loadDownloadedVersions(browser);
// Only update state if this browser is still the one we're loading
if (loadingBrowserRef.current === browser) {
const filtered: BrowserReleaseTypes = {};
if (rawReleaseTypes.stable) filtered.stable = rawReleaseTypes.stable;
setReleaseTypes(filtered);
setReleaseTypesError(null);
}
} catch (error) {
console.error(`Failed to load release types for ${browser}:`, error);
// Fallback: still load downloaded versions and derive release type from them if possible
try {
const downloaded = await loadDownloadedVersions(browser);
if (loadingBrowserRef.current === browser && downloaded.length > 0) {
const latest = downloaded[0];
const fallback: BrowserReleaseTypes = {};
fallback.stable = latest;
setReleaseTypes(fallback);
setReleaseTypesError(null);
} else if (loadingBrowserRef.current === browser) {
// No downloaded versions and API failed - show error
setReleaseTypesError(
"Failed to fetch browser versions. Please check your internet connection and try again.",
);
}
} catch (e) {
console.error(
`Failed to load downloaded versions for ${browser}:`,
e,
);
if (loadingBrowserRef.current === browser) {
setReleaseTypesError(
"Failed to fetch browser versions. Please check your internet connection and try again.",
);
}
}
} finally {
// Clear loading state only if we're still loading this browser
if (loadingBrowserRef.current === browser) {
loadingBrowserRef.current = null;
setIsLoadingReleaseTypes(false);
}
}
},
[loadDownloadedVersions],
);
// Load data when dialog opens
useEffect(() => {
if (isOpen) {
void loadSupportedBrowsers();
// Load release types when a browser is selected
if (selectedBrowser) {
void loadReleaseTypes(selectedBrowser);
}
// Check and download GeoIP database if needed for Camoufox or Wayfern
if (selectedBrowser === "camoufox" || selectedBrowser === "wayfern") {
void checkAndDownloadGeoIPDatabase();
}
}
}, [
isOpen,
loadSupportedBrowsers,
loadReleaseTypes,
checkAndDownloadGeoIPDatabase,
selectedBrowser,
]);
// Load release types when browser selection changes
useEffect(() => {
if (selectedBrowser) {
// Cancel any previous loading
loadingBrowserRef.current = null;
// Clear previous release types immediately to prevent showing stale data
setReleaseTypes({});
void loadReleaseTypes(selectedBrowser);
}
}, [selectedBrowser, loadReleaseTypes]);
// Helper function to get the best available version respecting rules
const getBestAvailableVersion = useCallback(
(_browserType?: string) => {
if (!releaseTypes) return null;
if (releaseTypes.stable) {
return { version: releaseTypes.stable, releaseType: "stable" as const };
}
return null;
},
[releaseTypes],
);
const getCreatableVersion = useCallback(
(browserType?: string) => {
const bestVersion = getBestAvailableVersion(browserType);
if (bestVersion && isVersionDownloaded(bestVersion.version)) {
return bestVersion;
}
const browserDownloaded = downloadedVersionsMap[browserType ?? ""] ?? [];
if (browserDownloaded.length > 0) {
const fallbackVersion = browserDownloaded[0];
return {
version: fallbackVersion,
releaseType: "stable" as const,
};
}
return null;
},
[getBestAvailableVersion, isVersionDownloaded, downloadedVersionsMap],
);
const handleDownload = async (browserStr: string) => {
const bestVersion = getBestAvailableVersion(browserStr);
if (!bestVersion) {
console.error("No version available for download");
return;
}
try {
await downloadBrowser(browserStr, bestVersion.version);
} catch (error) {
console.error("Failed to download browser:", error);
}
};
const handleCreate = async () => {
if (!profileName.trim()) return;
if (enablePassword && !ephemeral) {
if (password.length < PASSWORD_MIN_LEN) {
setPasswordError(
t("profilePassword.errors.tooShort", { min: PASSWORD_MIN_LEN }),
);
return;
}
if (password !== passwordConfirm) {
setPasswordError(t("profilePassword.errors.mismatch"));
return;
}
}
setPasswordError(null);
setIsCreating(true);
const isVpnSelection = selectedProxyId?.startsWith("vpn-") ?? false;
const resolvedProxyId = isVpnSelection ? undefined : selectedProxyId;
const resolvedVpnId =
isVpnSelection && selectedProxyId ? selectedProxyId.slice(4) : undefined;
const passwordToSet =
enablePassword && !ephemeral && password.length >= PASSWORD_MIN_LEN
? password
: undefined;
try {
if (activeTab === "anti-detect") {
// Anti-detect browser - check if Wayfern or Camoufox is selected
if (selectedBrowser === "wayfern") {
const bestWayfernVersion = getCreatableVersion("wayfern");
if (!bestWayfernVersion) {
console.error("No Wayfern version available");
return;
}
// The fingerprint will be generated at launch time by the Rust backend
const finalWayfernConfig = { ...wayfernConfig };
await onCreateProfile({
name: profileName.trim(),
browserStr: "wayfern" as BrowserTypeString,
version: bestWayfernVersion.version,
releaseType: bestWayfernVersion.releaseType,
proxyId: resolvedProxyId,
vpnId: resolvedVpnId,
wayfernConfig: finalWayfernConfig,
groupId:
selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined,
extensionGroupId: selectedExtensionGroupId,
ephemeral,
dnsBlocklist: dnsBlocklist || undefined,
launchHook: launchHook.trim() || undefined,
password: passwordToSet,
});
} else {
// Default to Camoufox
const bestCamoufoxVersion = getCreatableVersion("camoufox");
if (!bestCamoufoxVersion) {
console.error("No Camoufox version available");
return;
}
// The fingerprint will be generated at launch time by the Rust backend
// We don't need to generate it here during profile creation
const finalCamoufoxConfig = { ...camoufoxConfig };
await onCreateProfile({
name: profileName.trim(),
browserStr: "camoufox" as BrowserTypeString,
version: bestCamoufoxVersion.version,
releaseType: bestCamoufoxVersion.releaseType,
proxyId: resolvedProxyId,
vpnId: resolvedVpnId,
camoufoxConfig: finalCamoufoxConfig,
groupId:
selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined,
extensionGroupId: selectedExtensionGroupId,
ephemeral,
dnsBlocklist: dnsBlocklist || undefined,
launchHook: launchHook.trim() || undefined,
password: passwordToSet,
});
}
} else {
// Regular browser
if (!selectedBrowser) {
console.error("Missing required browser selection");
return;
}
// Use the best available version (stable preferred, nightly as fallback)
const bestVersion = getCreatableVersion(selectedBrowser);
if (!bestVersion) {
console.error("No version available");
return;
}
await onCreateProfile({
name: profileName.trim(),
browserStr: selectedBrowser,
version: bestVersion.version,
releaseType: bestVersion.releaseType,
proxyId: selectedProxyId,
groupId:
selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined,
dnsBlocklist: dnsBlocklist || undefined,
launchHook: launchHook.trim() || undefined,
password: passwordToSet,
});
}
handleClose();
} catch (error) {
console.error("Failed to create profile:", error);
} finally {
setIsCreating(false);
}
};
const handleClose = () => {
// Cancel any ongoing loading
loadingBrowserRef.current = null;
// Reset all states
setProfileName("");
setCurrentStep("browser-selection");
setActiveTab("anti-detect");
setSelectedBrowser(null);
setSelectedProxyId(undefined);
setLaunchHook("");
setReleaseTypes({});
setIsLoadingReleaseTypes(false);
setReleaseTypesError(null);
setCamoufoxConfig({
geoip: true, // Reset to automatic geoip
os: getCurrentOS(), // Reset to current OS
});
setWayfernConfig({
os: getCurrentOS() as WayfernOS, // Reset to current OS
});
setEphemeral(false);
setEnablePassword(false);
setPassword("");
setPasswordConfirm("");
setPasswordError(null);
onClose();
};
const updateCamoufoxConfig = (key: keyof CamoufoxConfig, value: unknown) => {
setCamoufoxConfig((prev) => ({ ...prev, [key]: value }));
};
const updateWayfernConfig = (key: keyof WayfernConfig, value: unknown) => {
setWayfernConfig((prev) => ({ ...prev, [key]: value }));
};
// Check if browser version is downloaded and available
const isBrowserVersionAvailable = useCallback(
(browserStr: string) => {
const bestVersion = getBestAvailableVersion(browserStr);
return bestVersion && isVersionDownloaded(bestVersion.version);
},
[isVersionDownloaded, getBestAvailableVersion],
);
// Check if browser is currently downloading
const isBrowserCurrentlyDownloading = useCallback(
(browserStr: string) => {
return isBrowserDownloading(browserStr);
},
[isBrowserDownloading],
);
const isCreateDisabled = useMemo(() => {
if (!profileName.trim()) return true;
if (!selectedBrowser) return true;
if (isBrowserCurrentlyDownloading(selectedBrowser)) return true;
if (!getCreatableVersion(selectedBrowser)) return true;
return false;
}, [
profileName,
selectedBrowser,
isBrowserCurrentlyDownloading,
getCreatableVersion,
]);
// Filter supported browsers for regular browsers
const regularBrowsers = browserOptions.filter((browser) =>
supportedBrowsers.includes(browser.value),
);
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="w-[380px] max-w-[380px] max-h-[90vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle>
{currentStep === "browser-selection"
? t("createProfile.title")
: t("createProfile.configureTitle", {
browser:
selectedBrowser === "wayfern"
? t("createProfile.chromiumLabel")
: t("createProfile.firefoxLabel"),
})}
</DialogTitle>
</DialogHeader>
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="flex flex-col flex-1 w-full min-h-0"
>
{/* Tab list hidden - only anti-detect browsers are supported */}
<ScrollArea className="overflow-y-auto flex-1">
<div className="flex flex-col justify-center items-center w-full">
<div className="py-4 space-y-6 w-full max-w-md">
{currentStep === "browser-selection" ? (
<>
<TabsContent value="anti-detect" className="mt-0 space-y-6">
{/* Anti-Detect Browser Selection */}
<div className="space-y-3 pt-8">
{/* Wayfern (Chromium) - First */}
<Button
onClick={() => {
handleBrowserSelect("wayfern");
}}
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
variant="outline"
>
<div className="flex justify-center items-center size-8">
{(() => {
const IconComponent = getBrowserIcon("wayfern");
return IconComponent ? (
<IconComponent className="size-6" />
) : null;
})()}
</div>
<div className="text-left">
<div className="font-medium">
{t("createProfile.chromiumLabel")}
</div>
<div className="text-sm text-muted-foreground">
{t("createProfile.chromiumSubtitle")}
</div>
</div>
</Button>
{/* Camoufox (Firefox) - Second */}
<Button
onClick={() => {
handleBrowserSelect("camoufox");
}}
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
variant="outline"
>
<div className="flex justify-center items-center size-8">
{(() => {
const IconComponent = getBrowserIcon("camoufox");
return IconComponent ? (
<IconComponent className="size-6" />
) : null;
})()}
</div>
<div className="text-left">
<div className="font-medium">
{t("createProfile.firefoxLabel")}
</div>
<div className="text-sm text-muted-foreground">
{t("createProfile.firefoxSubtitle")}
</div>
</div>
</Button>
</div>
</TabsContent>
<TabsContent value="regular" className="mt-0 space-y-6">
{/* Regular Browser Selection */}
<div className="space-y-6">
<div className="text-center">
<h3 className="text-lg font-medium">
{t("createProfile.regular.title")}
</h3>
<p className="mt-2 text-sm text-muted-foreground">
{t("createProfile.regular.description")}
</p>
</div>
<div className="space-y-3">
{regularBrowsers.map((browser) => {
if (browser.value === "camoufox") return null; // Skip camoufox as it's handled in anti-detect tab
const IconComponent = getBrowserIcon(browser.value);
return (
<Button
key={browser.value}
onClick={() => {
handleBrowserSelect(browser.value);
}}
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
variant="outline"
>
<div className="flex justify-center items-center size-8">
{IconComponent && (
<IconComponent className="size-6" />
)}
</div>
<div className="text-left">
<div className="font-medium">
{browser.label}
</div>
<div className="text-sm text-muted-foreground">
{t("createProfile.regular.badge")}
</div>
</div>
</Button>
);
})}
</div>
</div>
</TabsContent>
</>
) : (
<>
<TabsContent value="anti-detect" className="mt-0">
{/* Anti-Detect Configuration */}
<div className="space-y-6">
{/* Profile Name */}
<div className="space-y-2">
<Label htmlFor="profile-name">
{t("createProfile.profileName")}
</Label>
<Input
id="profile-name"
value={profileName}
onChange={(e) => {
setProfileName(e.target.value);
}}
onKeyDown={(e) => {
if (
e.key === "Enter" &&
!isCreateDisabled &&
!isCreating
) {
void handleCreate();
}
}}
placeholder={t(
"createProfile.profileNamePlaceholder",
)}
/>
</div>
{/* Ephemeral Option */}
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="flex items-center gap-x-2">
<Checkbox
id="ephemeral"
checked={ephemeral}
onCheckedChange={(checked) => {
setEphemeral(checked === true);
}}
/>
<Label htmlFor="ephemeral" className="font-medium">
{t("profiles.ephemeral")}
</Label>
</div>
<p className="text-sm text-muted-foreground ml-6">
{t("profiles.ephemeralDescription")}
</p>
</div>
{/* Password Option */}
{!ephemeral && (
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="flex items-center gap-x-2">
<Checkbox
id="enable-password"
checked={enablePassword}
onCheckedChange={(checked) => {
setEnablePassword(checked === true);
if (checked !== true) {
setPassword("");
setPasswordConfirm("");
setPasswordError(null);
}
}}
/>
<Label
htmlFor="enable-password"
className="font-medium"
>
{t("createProfile.passwordProtect.label")}
</Label>
</div>
<p className="text-sm text-muted-foreground ml-6">
{t("createProfile.passwordProtect.description")}
</p>
{enablePassword && (
<div className="ml-6 space-y-2">
<Input
type="password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
setPasswordError(null);
}}
placeholder={t(
"profilePassword.fields.newPassword",
)}
autoComplete="new-password"
/>
<Input
type="password"
value={passwordConfirm}
onChange={(e) => {
setPasswordConfirm(e.target.value);
setPasswordError(null);
}}
placeholder={t(
"profilePassword.fields.confirm",
)}
autoComplete="new-password"
/>
{passwordError && (
<p className="text-sm text-destructive">
{passwordError}
</p>
)}
</div>
)}
</div>
)}
{selectedBrowser === "wayfern" ? (
// Wayfern Configuration
<div className="space-y-6">
{/* Wayfern Download Status */}
{isLoadingReleaseTypes && (
<div className="flex gap-3 items-center p-3 rounded-md border">
<div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<p className="text-sm text-muted-foreground">
{t("createProfile.version.fetching")}
</p>
</div>
)}
{!isLoadingReleaseTypes && releaseTypesError && (
<div className="flex gap-3 items-center p-3 rounded-md border border-destructive/50 bg-destructive/10">
<p className="flex-1 text-sm text-destructive">
{releaseTypesError}
</p>
<RippleButton
onClick={() =>
selectedBrowser &&
loadReleaseTypes(selectedBrowser)
}
size="sm"
variant="outline"
>
{t("common.buttons.retry")}
</RippleButton>
</div>
)}
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!getBestAvailableVersion("wayfern") && (
<div className="flex gap-3 items-center p-3 rounded-md border border-warning/50 bg-warning/10">
<p className="text-sm text-warning">
{t("createProfile.platformUnavailable", {
browser: "Wayfern",
})}
</p>
</div>
)}
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!isBrowserCurrentlyDownloading("wayfern") &&
!isBrowserVersionAvailable("wayfern") &&
getBestAvailableVersion("wayfern") && (
<div className="flex gap-3 items-center p-3 rounded-md border">
<p className="text-sm text-muted-foreground">
{t("createProfile.version.needsDownload", {
browser: "Wayfern",
version:
getBestAvailableVersion("wayfern")
?.version,
})}
</p>
<LoadingButton
onClick={() => {
void handleDownload("wayfern");
}}
isLoading={isBrowserCurrentlyDownloading(
"wayfern",
)}
size="sm"
disabled={isBrowserCurrentlyDownloading(
"wayfern",
)}
>
{isBrowserCurrentlyDownloading("wayfern")
? t("common.buttons.downloading")
: t("common.buttons.download")}
</LoadingButton>
</div>
)}
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!isBrowserCurrentlyDownloading("wayfern") &&
isBrowserVersionAvailable("wayfern") && (
<div className="p-3 text-sm rounded-md border text-muted-foreground">
{" "}
{t("createProfile.version.available", {
browser: "Wayfern",
version:
getBestAvailableVersion("wayfern")
?.version,
})}
</div>
)}
{isBrowserCurrentlyDownloading("wayfern") && (
<div className="p-3 text-sm rounded-md border text-muted-foreground">
{t("createProfile.version.downloading", {
browser: "Wayfern",
version:
getBestAvailableVersion("wayfern")?.version,
})}
</div>
)}
<WayfernConfigForm
config={wayfernConfig}
onConfigChange={updateWayfernConfig}
isCreating
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
profileVersion={
getBestAvailableVersion("wayfern")?.version
}
profileBrowser="wayfern"
/>
</div>
) : selectedBrowser === "camoufox" ? (
// Camoufox Configuration
<div className="space-y-6">
{/* Camoufox Download Status */}
{isLoadingReleaseTypes && (
<div className="flex gap-3 items-center p-3 rounded-md border">
<div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<p className="text-sm text-muted-foreground">
{t("createProfile.version.fetching")}
</p>
</div>
)}
{!isLoadingReleaseTypes && releaseTypesError && (
<div className="flex gap-3 items-center p-3 rounded-md border border-destructive/50 bg-destructive/10">
<p className="flex-1 text-sm text-destructive">
{releaseTypesError}
</p>
<RippleButton
onClick={() =>
selectedBrowser &&
loadReleaseTypes(selectedBrowser)
}
size="sm"
variant="outline"
>
{t("common.buttons.retry")}
</RippleButton>
</div>
)}
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!getBestAvailableVersion("camoufox") && (
<div className="flex gap-3 items-center p-3 rounded-md border border-warning/50 bg-warning/10">
<p className="text-sm text-warning">
{t("createProfile.platformUnavailable", {
browser: "Camoufox",
})}
</p>
</div>
)}
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!isBrowserCurrentlyDownloading("camoufox") &&
!isBrowserVersionAvailable("camoufox") &&
getBestAvailableVersion("camoufox") && (
<div className="flex gap-3 items-center p-3 rounded-md border">
<p className="text-sm text-muted-foreground">
{t("createProfile.version.needsDownload", {
browser: "Camoufox",
version:
getBestAvailableVersion("camoufox")
?.version,
})}
</p>
<LoadingButton
onClick={() => {
void handleDownload("camoufox");
}}
isLoading={isBrowserCurrentlyDownloading(
"camoufox",
)}
size="sm"
disabled={isBrowserCurrentlyDownloading(
"camoufox",
)}
>
{isBrowserCurrentlyDownloading("camoufox")
? t("common.buttons.downloading")
: t("common.buttons.download")}
</LoadingButton>
</div>
)}
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!isBrowserCurrentlyDownloading("camoufox") &&
isBrowserVersionAvailable("camoufox") && (
<div className="p-3 text-sm rounded-md border text-muted-foreground">
{" "}
{t("createProfile.version.available", {
browser: "Camoufox",
version:
getBestAvailableVersion("camoufox")
?.version,
})}
</div>
)}
{isBrowserCurrentlyDownloading("camoufox") && (
<div className="p-3 text-sm rounded-md border text-muted-foreground">
{t("createProfile.version.downloading", {
browser: "Camoufox",
version:
getBestAvailableVersion("camoufox")
?.version,
})}
</div>
)}
{crossOsUnlocked && (
<Alert className="border-warning/50 bg-warning/10">
<AlertDescription className="text-sm">
{t("createProfile.camoufoxWarning")}
</AlertDescription>
</Alert>
)}
<SharedCamoufoxConfigForm
config={camoufoxConfig}
onConfigChange={updateCamoufoxConfig}
isCreating
browserType="camoufox"
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
profileVersion={
getBestAvailableVersion("camoufox")?.version
}
profileBrowser="camoufox"
/>
</div>
) : (
// Regular Browser Configuration (should not happen in anti-detect tab)
<div className="space-y-4">
{selectedBrowser && (
<div className="space-y-3">
{isLoadingReleaseTypes && (
<div className="flex gap-3 items-center">
<div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<p className="text-sm text-muted-foreground">
{t("createProfile.version.fetching")}
</p>
</div>
)}
{!isLoadingReleaseTypes &&
releaseTypesError && (
<div className="flex gap-3 items-center p-3 rounded-md border border-destructive/50 bg-destructive/10">
<p className="flex-1 text-sm text-destructive">
{releaseTypesError}
</p>
<RippleButton
onClick={() =>
selectedBrowser &&
loadReleaseTypes(selectedBrowser)
}
size="sm"
variant="outline"
>
Retry
</RippleButton>
</div>
)}
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!isBrowserCurrentlyDownloading(
selectedBrowser,
) &&
!isBrowserVersionAvailable(selectedBrowser) &&
getBestAvailableVersion(selectedBrowser) && (
<div className="flex gap-3 items-center">
<p className="text-sm text-muted-foreground">
{t(
"createProfile.version.latestNeedsDownload",
{
version:
getBestAvailableVersion(
selectedBrowser,
)?.version,
},
)}
</p>
<LoadingButton
onClick={() => {
void handleDownload(selectedBrowser);
}}
isLoading={isBrowserCurrentlyDownloading(
selectedBrowser,
)}
className="ml-auto"
size="sm"
disabled={isBrowserCurrentlyDownloading(
selectedBrowser,
)}
>
{t("common.buttons.download")}
</LoadingButton>
</div>
)}
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!isBrowserCurrentlyDownloading(
selectedBrowser,
) &&
isBrowserVersionAvailable(
selectedBrowser,
) && (
<div className="text-sm text-muted-foreground">
{" "}
{t(
"createProfile.version.latestAvailable",
{
version:
getBestAvailableVersion(
selectedBrowser,
)?.version,
},
)}
</div>
)}
{isBrowserCurrentlyDownloading(
selectedBrowser,
) && (
<div className="text-sm text-muted-foreground">
{t(
"createProfile.version.latestDownloading",
{
version:
getBestAvailableVersion(
selectedBrowser,
)?.version,
},
)}
</div>
)}
</div>
)}
</div>
)}
{/* Proxy / VPN Selection - Always visible */}
<div className="space-y-3">
<div className="flex justify-between items-center">
<Label>{t("createProfile.proxy.title")}</Label>
<RippleButton
size="sm"
variant="outline"
onClick={() => {
setShowProxyForm(true);
}}
className="px-2 h-7 text-xs"
>
<GoPlus className="mr-1 size-3" />{" "}
{t("createProfile.proxy.addProxy")}
</RippleButton>
</div>
{storedProxies.length > 0 || vpnConfigs.length > 0 ? (
<Popover
open={proxyPopoverOpen}
onOpenChange={setProxyPopoverOpen}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={proxyPopoverOpen}
aria-controls={proxyListboxIdAntiDetect}
className="w-full justify-between font-normal"
>
{(() => {
if (!selectedProxyId)
return t("createProfile.proxy.noProxy");
if (selectedProxyId.startsWith("vpn-")) {
const vpn = vpnConfigs.find(
(v) =>
v.id === selectedProxyId.slice(4),
);
return vpn
? `WG — ${vpn.name}`
: t("createProfile.proxy.noProxy");
}
const proxy = storedProxies.find(
(p) => p.id === selectedProxyId,
);
return (
proxy?.name ??
t("createProfile.proxy.noProxy")
);
})()}
<LuChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
id={proxyListboxIdAntiDetect}
className="w-[240px] p-0"
sideOffset={8}
>
<Command>
<CommandInput
placeholder={t(
"createProfile.proxy.search",
)}
/>
<CommandList>
<CommandEmpty>
{t("createProfile.proxy.notFound")}
</CommandEmpty>
<CommandGroup>
<CommandItem
value="__none__"
onSelect={() => {
setSelectedProxyId(undefined);
setProxyPopoverOpen(false);
}}
>
<LuCheck
className={cn(
"mr-2 size-4",
!selectedProxyId
? "opacity-100"
: "opacity-0",
)}
/>
{t("common.labels.none")}
</CommandItem>
{storedProxies.map((proxy) => (
<CommandItem
key={proxy.id}
value={proxy.name}
onSelect={() => {
setSelectedProxyId(proxy.id);
setProxyPopoverOpen(false);
}}
>
<LuCheck
className={cn(
"mr-2 size-4",
selectedProxyId === proxy.id
? "opacity-100"
: "opacity-0",
)}
/>
{proxy.name}
</CommandItem>
))}
</CommandGroup>
{vpnConfigs.length > 0 && (
<CommandGroup heading="VPNs">
{vpnConfigs.map((vpn) => (
<CommandItem
key={vpn.id}
value={`vpn-${vpn.name}`}
onSelect={() => {
setSelectedProxyId(
`vpn-${vpn.id}`,
);
setProxyPopoverOpen(false);
}}
>
<LuCheck
className={cn(
"mr-2 size-4",
selectedProxyId ===
`vpn-${vpn.id}`
? "opacity-100"
: "opacity-0",
)}
/>
<Badge
variant="outline"
className="text-[10px] px-1 py-0 leading-tight mr-1"
>
WG
</Badge>
{vpn.name}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground">
{t("createProfile.proxy.noProxiesAvailable")}
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="launch-hook-url">
{t("createProfile.launchHook.label")}
</Label>
<Input
id="launch-hook-url"
value={launchHook}
onChange={(e) => {
setLaunchHook(e.target.value);
}}
placeholder={t(
"createProfile.launchHook.placeholder",
)}
disabled={isCreating}
/>
</div>
{/* DNS Blocklist */}
<div className="space-y-2">
<Label>{t("dnsBlocklist.title")}</Label>
<Select
value={dnsBlocklist || "none"}
onValueChange={(val) => {
setDnsBlocklist(val === "none" ? "" : val);
}}
>
<SelectTrigger>
<SelectValue
placeholder={t("dnsBlocklist.none")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{t("dnsBlocklist.none")}
</SelectItem>
<SelectItem value="light">
{t("dnsBlocklist.light")}
</SelectItem>
<SelectItem value="normal">
{t("dnsBlocklist.normal")}
</SelectItem>
<SelectItem value="pro">
{t("dnsBlocklist.pro")}
</SelectItem>
<SelectItem value="pro_plus">
{t("dnsBlocklist.proPlus")}
</SelectItem>
<SelectItem value="ultimate">
{t("dnsBlocklist.ultimate")}
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Extension Group */}
{extensionGroups.length > 0 && (
<div className="space-y-2">
<Label>{t("extensions.extensionGroup")}</Label>
<Select
value={selectedExtensionGroupId ?? "none"}
onValueChange={(val) => {
setSelectedExtensionGroupId(
val === "none" ? undefined : val,
);
}}
>
<SelectTrigger>
<SelectValue
placeholder={t("profileInfo.values.none")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{t("profileInfo.values.none")}
</SelectItem>
{extensionGroups.map((g) => (
<SelectItem key={g.id} value={g.id}>
{g.name} ({g.extension_ids.length})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
</TabsContent>
<TabsContent value="regular" className="mt-0">
{/* Regular Browser Configuration */}
<div className="space-y-6">
{/* Profile Name */}
<div className="space-y-2">
<Label htmlFor="profile-name">
{t("createProfile.profileName")}
</Label>
<Input
id="profile-name"
value={profileName}
onChange={(e) => {
setProfileName(e.target.value);
}}
onKeyDown={(e) => {
if (
e.key === "Enter" &&
!isCreateDisabled &&
!isCreating
) {
void handleCreate();
}
}}
placeholder={t(
"createProfile.profileNamePlaceholder",
)}
/>
</div>
{/* Regular Browser Configuration */}
<div className="space-y-4">
{selectedBrowser && (
<div className="space-y-3">
{isLoadingReleaseTypes && (
<div className="flex gap-3 items-center">
<div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<p className="text-sm text-muted-foreground">
Fetching available versions...
</p>
</div>
)}
{!isLoadingReleaseTypes && releaseTypesError && (
<div className="flex gap-3 items-center p-3 rounded-md border border-destructive/50 bg-destructive/10">
<p className="flex-1 text-sm text-destructive">
{releaseTypesError}
</p>
<RippleButton
onClick={() =>
selectedBrowser &&
loadReleaseTypes(selectedBrowser)
}
size="sm"
variant="outline"
>
{t("common.buttons.retry")}
</RippleButton>
</div>
)}
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!isBrowserCurrentlyDownloading(
selectedBrowser,
) &&
!isBrowserVersionAvailable(selectedBrowser) &&
getBestAvailableVersion(selectedBrowser) && (
<div className="flex gap-3 items-center">
<p className="text-sm text-muted-foreground">
{t(
"createProfile.version.latestNeedsDownload",
{
version:
getBestAvailableVersion(
selectedBrowser,
)?.version,
},
)}
</p>
<LoadingButton
onClick={() => {
void handleDownload(selectedBrowser);
}}
isLoading={isBrowserCurrentlyDownloading(
selectedBrowser,
)}
className="ml-auto"
size="sm"
disabled={isBrowserCurrentlyDownloading(
selectedBrowser,
)}
>
{t("common.buttons.download")}
</LoadingButton>
</div>
)}
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!isBrowserCurrentlyDownloading(
selectedBrowser,
) &&
isBrowserVersionAvailable(selectedBrowser) && (
<div className="text-sm text-muted-foreground">
{" "}
{t(
"createProfile.version.latestAvailable",
{
version:
getBestAvailableVersion(
selectedBrowser,
)?.version,
},
)}
</div>
)}
{isBrowserCurrentlyDownloading(
selectedBrowser,
) && (
<div className="text-sm text-muted-foreground">
{t(
"createProfile.version.latestDownloading",
{
version:
getBestAvailableVersion(selectedBrowser)
?.version,
},
)}
</div>
)}
</div>
)}
</div>
{/* Proxy / VPN Selection - Always visible */}
<div className="space-y-3">
<div className="flex justify-between items-center">
<Label>{t("createProfile.proxy.title")}</Label>
<RippleButton
size="sm"
variant="outline"
onClick={() => {
setShowProxyForm(true);
}}
className="px-2 h-7 text-xs"
>
<GoPlus className="mr-1 size-3" />{" "}
{t("createProfile.proxy.addProxy")}
</RippleButton>
</div>
{storedProxies.length > 0 || vpnConfigs.length > 0 ? (
<Popover
open={proxyPopoverOpen}
onOpenChange={setProxyPopoverOpen}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={proxyPopoverOpen}
aria-controls={proxyListboxIdRegular}
className="w-full justify-between font-normal"
>
{(() => {
if (!selectedProxyId)
return t("createProfile.proxy.noProxy");
if (selectedProxyId.startsWith("vpn-")) {
const vpn = vpnConfigs.find(
(v) =>
v.id === selectedProxyId.slice(4),
);
return vpn
? `WG — ${vpn.name}`
: t("createProfile.proxy.noProxy");
}
const proxy = storedProxies.find(
(p) => p.id === selectedProxyId,
);
return (
proxy?.name ??
t("createProfile.proxy.noProxy")
);
})()}
<LuChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
id={proxyListboxIdRegular}
className="w-[240px] p-0"
sideOffset={8}
>
<Command>
<CommandInput
placeholder={t(
"createProfile.proxy.search",
)}
/>
<CommandList>
<CommandEmpty>
{t("createProfile.proxy.notFound")}
</CommandEmpty>
<CommandGroup>
<CommandItem
value="__none__"
onSelect={() => {
setSelectedProxyId(undefined);
setProxyPopoverOpen(false);
}}
>
<LuCheck
className={cn(
"mr-2 size-4",
!selectedProxyId
? "opacity-100"
: "opacity-0",
)}
/>
{t("common.labels.none")}
</CommandItem>
{storedProxies.map((proxy) => (
<CommandItem
key={proxy.id}
value={proxy.name}
onSelect={() => {
setSelectedProxyId(proxy.id);
setProxyPopoverOpen(false);
}}
>
<LuCheck
className={cn(
"mr-2 size-4",
selectedProxyId === proxy.id
? "opacity-100"
: "opacity-0",
)}
/>
{proxy.name}
</CommandItem>
))}
</CommandGroup>
{vpnConfigs.length > 0 && (
<CommandGroup heading="VPNs">
{vpnConfigs.map((vpn) => (
<CommandItem
key={vpn.id}
value={`vpn-${vpn.name}`}
onSelect={() => {
setSelectedProxyId(
`vpn-${vpn.id}`,
);
setProxyPopoverOpen(false);
}}
>
<LuCheck
className={cn(
"mr-2 size-4",
selectedProxyId ===
`vpn-${vpn.id}`
? "opacity-100"
: "opacity-0",
)}
/>
<Badge
variant="outline"
className="text-[10px] px-1 py-0 leading-tight mr-1"
>
WG
</Badge>
{vpn.name}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground">
{t("createProfile.proxy.noProxiesAvailable")}
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="launch-hook-url-regular">
{t("createProfile.launchHook.label")}
</Label>
<Input
id="launch-hook-url-regular"
value={launchHook}
onChange={(e) => {
setLaunchHook(e.target.value);
}}
placeholder={t(
"createProfile.launchHook.placeholder",
)}
disabled={isCreating}
/>
</div>
</div>
</TabsContent>
</>
)}
</div>
</div>
</ScrollArea>
</Tabs>
<DialogFooter className="flex-shrink-0 pt-4 border-t">
{currentStep === "browser-config" ? (
<>
<RippleButton variant="outline" onClick={handleBack}>
{t("common.buttons.back")}
</RippleButton>
<LoadingButton
onClick={handleCreate}
isLoading={isCreating}
disabled={isCreateDisabled}
>
{t("common.buttons.create")}
</LoadingButton>
</>
) : (
<RippleButton variant="outline" onClick={handleClose}>
{t("common.buttons.cancel")}
</RippleButton>
)}
</DialogFooter>
</DialogContent>
<ProxyFormDialog
isOpen={showProxyForm}
onClose={() => {
setShowProxyForm(false);
}}
/>
</Dialog>
);
}