refactor: cleanup

This commit is contained in:
zhom
2026-03-09 14:21:43 +04:00
parent a8be96d28e
commit 43ee6856f9
47 changed files with 5619 additions and 1535 deletions
+33 -11
View File
@@ -815,11 +815,12 @@ export default function Home() {
profile_id: string;
status: string;
error?: string;
profile_name?: string;
}>("profile-sync-status", (event) => {
const { profile_id, status, error } = event.payload;
const { profile_id, status, error, profile_name } = event.payload;
const toastId = `sync-${profile_id}`;
const profile = profiles.find((p) => p.id === profile_id);
const name = profile?.name ?? "Unknown";
const name = profile_name || profile?.name || "Unknown";
if (status === "syncing") {
showToast({
@@ -845,17 +846,38 @@ export default function Home() {
phase: string;
total_files?: number;
total_bytes?: number;
completed_files?: number;
completed_bytes?: number;
speed_bytes_per_sec?: number;
eta_seconds?: number;
failed_count?: number;
profile_name?: string;
}>("profile-sync-progress", (event) => {
const { profile_id, phase, total_files, total_bytes } = event.payload;
if (phase !== "started") return;
const payload = event.payload;
const toastId = `sync-${payload.profile_id}`;
const profile = profiles.find((p) => p.id === payload.profile_id);
const name = payload.profile_name || profile?.name || "Unknown";
const toastId = `sync-${profile_id}`;
const profile = profiles.find((p) => p.id === profile_id);
const name = profile?.name ?? "Unknown";
showSyncProgressToast(name, total_files ?? 0, total_bytes ?? 0, {
id: toastId,
});
if (
payload.phase === "started" ||
payload.phase === "uploading" ||
payload.phase === "downloading"
) {
showSyncProgressToast(
name,
{
completed_files: payload.completed_files ?? 0,
total_files: payload.total_files ?? 0,
completed_bytes: payload.completed_bytes ?? 0,
total_bytes: payload.total_bytes ?? 0,
speed_bytes_per_sec: payload.speed_bytes_per_sec ?? 0,
eta_seconds: payload.eta_seconds ?? 0,
failed_count: payload.failed_count ?? 0,
phase: payload.phase,
},
{ id: toastId },
);
}
});
} catch (error) {
console.error("Failed to listen for sync events:", error);
@@ -164,6 +164,8 @@ export function CamoufoxConfigDialog({
readOnly={isRunning}
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
profileVersion={profile.version}
profileBrowser="wayfern"
/>
) : (
<SharedCamoufoxConfigForm
@@ -174,6 +176,8 @@ export function CamoufoxConfigDialog({
browserType="camoufox"
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
profileVersion={profile.version}
profileBrowser="camoufox"
/>
)}
</div>
+320 -145
View File
@@ -4,11 +4,22 @@ import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, 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,
@@ -18,13 +29,16 @@ import {
} 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,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
@@ -34,6 +48,7 @@ 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,
@@ -127,6 +142,7 @@ export function CreateProfileDialog({
const [selectedBrowser, setSelectedBrowser] =
useState<BrowserTypeString | null>(null);
const [selectedProxyId, setSelectedProxyId] = useState<string>();
const [proxyPopoverOpen, setProxyPopoverOpen] = useState(false);
// Camoufox anti-detect states
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>(() => ({
@@ -557,8 +573,13 @@ export function CreateProfileDialog({
<DialogHeader className="flex-shrink-0">
<DialogTitle>
{currentStep === "browser-selection"
? "Create New Profile"
: "Configure Profile"}
? t("createProfile.title")
: t("createProfile.configureTitle", {
browser:
selectedBrowser === "wayfern"
? t("createProfile.chromiumLabel")
: t("createProfile.firefoxLabel"),
})}
</DialogTitle>
</DialogHeader>
@@ -576,62 +597,54 @@ export function CreateProfileDialog({
<>
<TabsContent value="anti-detect" className="mt-0 space-y-6">
{/* Anti-Detect Browser Selection */}
<div className="space-y-6">
<div className="text-center">
<h3 className="text-lg font-medium">
Anti-Detect Browser
</h3>
<p className="mt-2 text-sm text-muted-foreground">
Choose a browser with anti-detection capabilities
</p>
</div>
<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 w-8 h-8">
{(() => {
const IconComponent = getBrowserIcon("wayfern");
return IconComponent ? (
<IconComponent className="w-6 h-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>
<div className="space-y-3">
{/* 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 w-8 h-8">
{(() => {
const IconComponent = getBrowserIcon("wayfern");
return IconComponent ? (
<IconComponent className="w-6 h-6" />
) : null;
})()}
{/* 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 w-8 h-8">
{(() => {
const IconComponent = getBrowserIcon("camoufox");
return IconComponent ? (
<IconComponent className="w-6 h-6" />
) : null;
})()}
</div>
<div className="text-left">
<div className="font-medium">
{t("createProfile.firefoxLabel")}
</div>
<div className="text-left">
<div className="font-medium">Wayfern</div>
<div className="text-sm text-muted-foreground">
Anti-Detect Browser
</div>
<div className="text-sm text-muted-foreground">
{t("createProfile.firefoxSubtitle")}
</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 w-8 h-8">
{(() => {
const IconComponent =
getBrowserIcon("camoufox");
return IconComponent ? (
<IconComponent className="w-6 h-6" />
) : null;
})()}
</div>
<div className="text-left">
<div className="font-medium">Camoufox</div>
<div className="text-sm text-muted-foreground">
Anti-Detect Browser
</div>
</div>
</Button>
</div>
</div>
</Button>
</div>
</TabsContent>
@@ -823,6 +836,10 @@ export function CreateProfileDialog({
isCreating
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
profileVersion={
getBestAvailableVersion("wayfern")?.version
}
profileBrowser="wayfern"
/>
</div>
) : selectedBrowser === "camoufox" ? (
@@ -915,6 +932,14 @@ export function CreateProfileDialog({
</div>
)}
{crossOsUnlocked && (
<Alert className="border-yellow-500/50 bg-yellow-500/10">
<AlertDescription className="text-sm">
{t("createProfile.camoufoxWarning")}
</AlertDescription>
</Alert>
)}
<SharedCamoufoxConfigForm
config={camoufoxConfig}
onConfigChange={updateCamoufoxConfig}
@@ -922,6 +947,10 @@ export function CreateProfileDialog({
browserType="camoufox"
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
profileVersion={
getBestAvailableVersion("camoufox")?.version
}
profileBrowser="camoufox"
/>
</div>
) : (
@@ -1039,52 +1068,125 @@ export function CreateProfileDialog({
</RippleButton>
</div>
{storedProxies.length > 0 || vpnConfigs.length > 0 ? (
<Select
value={selectedProxyId || "none"}
onValueChange={(value) =>
setSelectedProxyId(
value === "none" ? undefined : value,
)
}
<Popover
open={proxyPopoverOpen}
onOpenChange={setProxyPopoverOpen}
>
<SelectTrigger>
<SelectValue placeholder="No proxy / VPN" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
No proxy / VPN
</SelectItem>
{storedProxies.length > 0 && (
<SelectGroup>
<SelectLabel>Proxies</SelectLabel>
{storedProxies.map((proxy) => (
<SelectItem
key={proxy.id}
value={proxy.id}
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={proxyPopoverOpen}
className="w-full justify-between font-normal"
>
{(() => {
if (!selectedProxyId)
return "No proxy / VPN";
if (selectedProxyId.startsWith("vpn-")) {
const vpn = vpnConfigs.find(
(v) =>
v.id === selectedProxyId.slice(4),
);
return vpn
? `${vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"}${vpn.name}`
: "No proxy / VPN";
}
const proxy = storedProxies.find(
(p) => p.id === selectedProxyId,
);
return proxy?.name ?? "No proxy / VPN";
})()}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[240px] p-0"
sideOffset={8}
>
<Command>
<CommandInput placeholder="Search proxies or VPNs..." />
<CommandList>
<CommandEmpty>
No proxies or VPNs found.
</CommandEmpty>
<CommandGroup>
<CommandItem
value="__none__"
onSelect={() => {
setSelectedProxyId(undefined);
setProxyPopoverOpen(false);
}}
>
{proxy.name}
</SelectItem>
))}
</SelectGroup>
)}
{vpnConfigs.length > 0 && (
<SelectGroup>
<SelectLabel>VPNs</SelectLabel>
{vpnConfigs.map((vpn) => (
<SelectItem
key={vpn.id}
value={`vpn-${vpn.id}`}
>
{vpn.vpn_type === "WireGuard"
? "WG"
: "OVPN"}{" "}
{vpn.name}
</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
!selectedProxyId
? "opacity-100"
: "opacity-0",
)}
/>
None
</CommandItem>
{storedProxies.map((proxy) => (
<CommandItem
key={proxy.id}
value={proxy.name}
onSelect={() => {
setSelectedProxyId(proxy.id);
setProxyPopoverOpen(false);
}}
>
<LuCheck
className={cn(
"mr-2 h-4 w-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 h-4 w-4",
selectedProxyId ===
`vpn-${vpn.id}`
? "opacity-100"
: "opacity-0",
)}
/>
<Badge
variant="outline"
className="text-[10px] px-1 py-0 leading-tight mr-1"
>
{vpn.vpn_type === "WireGuard"
? "WG"
: "OVPN"}
</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">
No proxies or VPNs available. Add one to route
@@ -1257,52 +1359,125 @@ export function CreateProfileDialog({
</RippleButton>
</div>
{storedProxies.length > 0 || vpnConfigs.length > 0 ? (
<Select
value={selectedProxyId || "none"}
onValueChange={(value) =>
setSelectedProxyId(
value === "none" ? undefined : value,
)
}
<Popover
open={proxyPopoverOpen}
onOpenChange={setProxyPopoverOpen}
>
<SelectTrigger>
<SelectValue placeholder="No proxy / VPN" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
No proxy / VPN
</SelectItem>
{storedProxies.length > 0 && (
<SelectGroup>
<SelectLabel>Proxies</SelectLabel>
{storedProxies.map((proxy) => (
<SelectItem
key={proxy.id}
value={proxy.id}
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={proxyPopoverOpen}
className="w-full justify-between font-normal"
>
{(() => {
if (!selectedProxyId)
return "No proxy / VPN";
if (selectedProxyId.startsWith("vpn-")) {
const vpn = vpnConfigs.find(
(v) =>
v.id === selectedProxyId.slice(4),
);
return vpn
? `${vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"} — ${vpn.name}`
: "No proxy / VPN";
}
const proxy = storedProxies.find(
(p) => p.id === selectedProxyId,
);
return proxy?.name ?? "No proxy / VPN";
})()}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[240px] p-0"
sideOffset={8}
>
<Command>
<CommandInput placeholder="Search proxies or VPNs..." />
<CommandList>
<CommandEmpty>
No proxies or VPNs found.
</CommandEmpty>
<CommandGroup>
<CommandItem
value="__none__"
onSelect={() => {
setSelectedProxyId(undefined);
setProxyPopoverOpen(false);
}}
>
{proxy.name}
</SelectItem>
))}
</SelectGroup>
)}
{vpnConfigs.length > 0 && (
<SelectGroup>
<SelectLabel>VPNs</SelectLabel>
{vpnConfigs.map((vpn) => (
<SelectItem
key={vpn.id}
value={`vpn-${vpn.id}`}
>
{vpn.vpn_type === "WireGuard"
? "WG"
: "OVPN"}{" "}
— {vpn.name}
</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
!selectedProxyId
? "opacity-100"
: "opacity-0",
)}
/>
None
</CommandItem>
{storedProxies.map((proxy) => (
<CommandItem
key={proxy.id}
value={proxy.name}
onSelect={() => {
setSelectedProxyId(proxy.id);
setProxyPopoverOpen(false);
}}
>
<LuCheck
className={cn(
"mr-2 h-4 w-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 h-4 w-4",
selectedProxyId ===
`vpn-${vpn.id}`
? "opacity-100"
: "opacity-0",
)}
/>
<Badge
variant="outline"
className="text-[10px] px-1 py-0 leading-tight mr-1"
>
{vpn.vpn_type === "WireGuard"
? "WG"
: "OVPN"}
</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">
No proxies or VPNs available. Add one to route
+83 -1
View File
@@ -116,6 +116,20 @@ interface TwilightUpdateToastProps extends BaseToastProps {
hasUpdate?: boolean;
}
interface SyncProgressToastProps extends BaseToastProps {
type: "sync-progress";
progress?: {
completed_files: number;
total_files: number;
completed_bytes: number;
total_bytes: number;
speed_bytes_per_sec: number;
eta_seconds: number;
failed_count: number;
phase: string;
};
}
type ToastProps =
| LoadingToastProps
| SuccessToastProps
@@ -123,7 +137,38 @@ type ToastProps =
| DownloadToastProps
| VersionUpdateToastProps
| FetchingToastProps
| TwilightUpdateToastProps;
| TwilightUpdateToastProps
| SyncProgressToastProps;
function formatBytesCompact(bytes: number): string {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
const i = Math.min(
Math.floor(Math.log(bytes) / Math.log(1024)),
units.length - 1,
);
const value = bytes / 1024 ** i;
return `${i === 0 ? value : value.toFixed(1)} ${units[i]}`;
}
function formatSpeedCompact(bytesPerSec: number): string {
if (bytesPerSec >= 1024 * 1024) {
return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`;
}
return `${(bytesPerSec / 1024).toFixed(0)} KB/s`;
}
function formatEtaCompact(seconds: number): string {
if (seconds >= 3600) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return `${h}h ${m}m`;
}
if (seconds >= 60) {
return `${Math.floor(seconds / 60)} min`;
}
return `${Math.round(seconds)}s`;
}
function getToastIcon(type: ToastProps["type"], stage?: string) {
switch (type) {
@@ -153,6 +198,10 @@ function getToastIcon(type: ToastProps["type"], stage?: string) {
return (
<LuRefreshCw className="flex-shrink-0 w-4 h-4 animate-spin text-foreground" />
);
case "sync-progress":
return (
<LuRefreshCw className="flex-shrink-0 w-4 h-4 animate-spin text-foreground" />
);
case "loading":
return (
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
@@ -237,6 +286,39 @@ export function UnifiedToast(props: ToastProps) {
</div>
)}
{/* Sync progress */}
{type === "sync-progress" &&
progress &&
"completed_files" in progress && (
<div className="mt-1">
<p className="text-xs text-muted-foreground">
{progress.phase === "uploading" ? "Uploading" : "Downloading"}{" "}
{progress.completed_files}/{progress.total_files} files
{" \u2022 "}
{formatBytesCompact(progress.completed_bytes)} /{" "}
{formatBytesCompact(progress.total_bytes)}
{progress.speed_bytes_per_sec > 0 && (
<>
{" \u2022 "}
{formatSpeedCompact(progress.speed_bytes_per_sec)}
</>
)}
{progress.eta_seconds > 0 &&
progress.completed_files < progress.total_files && (
<>
{" \u2022 ~"}
{formatEtaCompact(progress.eta_seconds)} remaining
</>
)}
</p>
{progress.failed_count > 0 && (
<p className="text-xs text-destructive mt-0.5">
{progress.failed_count} file(s) failed
</p>
)}
</div>
)}
{/* Twilight update progress */}
{type === "twilight-update" && (
<div className="mt-2">
File diff suppressed because it is too large Load Diff
+160 -146
View File
@@ -43,6 +43,7 @@ type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting";
function getSyncStatusDot(
group: GroupWithCount,
liveStatus: SyncStatus | undefined,
errorMessage?: string,
): { color: string; tooltip: string; animate: boolean } {
const status = liveStatus ?? (group.sync_enabled ? "synced" : "disabled");
@@ -64,7 +65,11 @@ function getSyncStatusDot(
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,
};
default:
return { color: "bg-gray-400", tooltip: "Not synced", animate: false };
}
@@ -95,6 +100,9 @@ export function GroupManagementDialog({
const [groupSyncStatus, setGroupSyncStatus] = useState<
Record<string, SyncStatus>
>({});
const [groupSyncErrors, setGroupSyncErrors] = useState<
Record<string, string>
>({});
const [groupInUse, setGroupInUse] = useState<Record<string, boolean>>({});
const [isTogglingSync, setIsTogglingSync] = useState<Record<string, boolean>>(
{},
@@ -105,14 +113,17 @@ export function GroupManagementDialog({
let unlisten: (() => void) | undefined;
const setupListener = async () => {
unlisten = await listen<{ id: string; status: string }>(
unlisten = await listen<{ id: string; status: string; error?: string }>(
"group-sync-status",
(event) => {
const { id, status } = event.payload;
const { id, status, error } = event.payload;
setGroupSyncStatus((prev) => ({
...prev,
[id]: status as SyncStatus,
}));
if (error) {
setGroupSyncErrors((prev) => ({ ...prev, [id]: error }));
}
},
);
};
@@ -216,7 +227,7 @@ export function GroupManagementDialog({
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl">
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>Manage Profile Groups</DialogTitle>
<DialogDescription>
@@ -225,149 +236,152 @@ export function GroupManagementDialog({
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Create new group button */}
<div className="flex justify-between items-center">
<Label>Groups</Label>
<RippleButton
size="sm"
onClick={() => setCreateDialogOpen(true)}
className="flex gap-2 items-center"
>
<GoPlus className="w-4 h-4" />
Create
</RippleButton>
<ScrollArea className="overflow-y-auto flex-1">
<div className="space-y-4">
{/* Create new group button */}
<div className="flex justify-between items-center">
<Label>Groups</Label>
<RippleButton
size="sm"
onClick={() => setCreateDialogOpen(true)}
className="flex gap-2 items-center"
>
<GoPlus className="w-4 h-4" />
Create
</RippleButton>
</div>
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
{error}
</div>
)}
{/* Groups list */}
{isLoading ? (
<div className="text-sm text-muted-foreground">
Loading groups...
</div>
) : groups.length === 0 ? (
<div className="text-sm text-muted-foreground">
No groups created yet. Create your first group using the
button above.
</div>
) : (
<div className="border rounded-md">
<ScrollArea className="h-[240px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="w-20">Profiles</TableHead>
<TableHead className="w-24">Sync</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groups.map((group) => {
const syncDot = getSyncStatusDot(
group,
groupSyncStatus[group.id],
groupSyncErrors[group.id],
);
return (
<TableRow key={group.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<div
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
syncDot.animate ? "animate-pulse" : ""
}`}
/>
</TooltipTrigger>
<TooltipContent>
<p>{syncDot.tooltip}</p>
</TooltipContent>
</Tooltip>
{group.name}
</div>
</TableCell>
<TableCell>
<Badge variant="secondary">{group.count}</Badge>
</TableCell>
<TableCell>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Checkbox
checked={group.sync_enabled}
onCheckedChange={() =>
handleToggleSync(group)
}
disabled={
isTogglingSync[group.id] ||
groupInUse[group.id]
}
/>
</div>
</TooltipTrigger>
<TooltipContent>
{groupInUse[group.id] ? (
<p>
Sync cannot be disabled while this group
is used by synced profiles
</p>
) : (
<p>
{group.sync_enabled
? "Disable sync"
: "Enable sync"}
</p>
)}
</TooltipContent>
</Tooltip>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditGroup(group)}
>
<LuPencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit group</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteGroup(group)}
>
<LuTrash2 className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete group</p>
</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</ScrollArea>
</div>
)}
</div>
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
{error}
</div>
)}
{/* Groups list */}
{isLoading ? (
<div className="text-sm text-muted-foreground">
Loading groups...
</div>
) : groups.length === 0 ? (
<div className="text-sm text-muted-foreground">
No groups created yet. Create your first group using the button
above.
</div>
) : (
<div className="border rounded-md">
<ScrollArea className="h-[240px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="w-20">Profiles</TableHead>
<TableHead className="w-24">Sync</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groups.map((group) => {
const syncDot = getSyncStatusDot(
group,
groupSyncStatus[group.id],
);
return (
<TableRow key={group.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<div
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
syncDot.animate ? "animate-pulse" : ""
}`}
/>
</TooltipTrigger>
<TooltipContent>
<p>{syncDot.tooltip}</p>
</TooltipContent>
</Tooltip>
{group.name}
</div>
</TableCell>
<TableCell>
<Badge variant="secondary">{group.count}</Badge>
</TableCell>
<TableCell>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Checkbox
checked={group.sync_enabled}
onCheckedChange={() =>
handleToggleSync(group)
}
disabled={
isTogglingSync[group.id] ||
groupInUse[group.id]
}
/>
</div>
</TooltipTrigger>
<TooltipContent>
{groupInUse[group.id] ? (
<p>
Sync cannot be disabled while this group
is used by synced profiles
</p>
) : (
<p>
{group.sync_enabled
? "Disable sync"
: "Enable sync"}
</p>
)}
</TooltipContent>
</Tooltip>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditGroup(group)}
>
<LuPencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit group</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteGroup(group)}
>
<LuTrash2 className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete group</p>
</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</ScrollArea>
</div>
)}
</div>
</ScrollArea>
<DialogFooter>
<RippleButton variant="outline" onClick={onClose}>
+160 -52
View File
@@ -2,6 +2,7 @@
import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import { Loader2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -29,26 +30,31 @@ export function LocationProxyDialog({
onClose,
}: LocationProxyDialogProps) {
const [countries, setCountries] = useState<LocationItem[]>([]);
const [states, setStates] = useState<LocationItem[]>([]);
const [regions, setRegions] = useState<LocationItem[]>([]);
const [cities, setCities] = useState<LocationItem[]>([]);
const [isps, setIsps] = useState<LocationItem[]>([]);
const [selectedCountry, setSelectedCountry] = useState("");
const [selectedState, setSelectedState] = useState("");
const [selectedRegion, setSelectedRegion] = useState("");
const [selectedCity, setSelectedCity] = useState("");
const [selectedIsp, setSelectedIsp] = useState("");
const [proxyName, setProxyName] = useState("");
const [isLoadingCountries, setIsLoadingCountries] = useState(false);
const [isLoadingStates, setIsLoadingStates] = useState(false);
const [isLoadingRegions, setIsLoadingRegions] = useState(false);
const [isLoadingCities, setIsLoadingCities] = useState(false);
const [isLoadingIsps, setIsLoadingIsps] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const handleClose = useCallback(() => {
setSelectedCountry("");
setSelectedState("");
setSelectedRegion("");
setSelectedCity("");
setSelectedIsp("");
setProxyName("");
setStates([]);
setRegions([]);
setCities([]);
setIsps([]);
onClose();
}, [onClose]);
@@ -65,52 +71,87 @@ export function LocationProxyDialog({
.finally(() => setIsLoadingCountries(false));
}, [isOpen]);
// Fetch states when country changes
// Fetch regions when country changes
useEffect(() => {
if (!selectedCountry) {
setStates([]);
setRegions([]);
return;
}
setIsLoadingStates(true);
setSelectedState("");
setIsLoadingRegions(true);
setSelectedRegion("");
setSelectedCity("");
setSelectedIsp("");
setCities([]);
invoke<LocationItem[]>("cloud_get_states", { country: selectedCountry })
.then((data) => setStates(data))
.catch((err) => console.error("Failed to fetch states:", err))
.finally(() => setIsLoadingStates(false));
setIsps([]);
invoke<LocationItem[]>("cloud_get_regions", { country: selectedCountry })
.then((data) => setRegions(data))
.catch((err) => console.error("Failed to fetch regions:", err))
.finally(() => setIsLoadingRegions(false));
}, [selectedCountry]);
// Fetch cities when state changes
// Fetch cities when country or region changes (cities can be loaded without region)
useEffect(() => {
if (!selectedCountry || !selectedState) {
if (!selectedCountry) {
setCities([]);
return;
}
setIsLoadingCities(true);
setSelectedCity("");
invoke<LocationItem[]>("cloud_get_cities", {
const args: { country: string; region?: string } = {
country: selectedCountry,
state: selectedState,
})
};
if (selectedRegion) {
args.region = selectedRegion;
}
invoke<LocationItem[]>("cloud_get_cities", args)
.then((data) => setCities(data))
.catch((err) => console.error("Failed to fetch cities:", err))
.finally(() => setIsLoadingCities(false));
}, [selectedCountry, selectedState]);
}, [selectedCountry, selectedRegion]);
// Fetch ISPs when country/region/city changes
useEffect(() => {
if (!selectedCountry) {
setIsps([]);
return;
}
setIsLoadingIsps(true);
setSelectedIsp("");
const args: { country: string; region?: string; city?: string } = {
country: selectedCountry,
};
if (selectedRegion) args.region = selectedRegion;
if (selectedCity) args.city = selectedCity;
invoke<LocationItem[]>("cloud_get_isps", args)
.then((data) => setIsps(data))
.catch((err) => console.error("Failed to fetch ISPs:", err))
.finally(() => setIsLoadingIsps(false));
}, [selectedCountry, selectedRegion, selectedCity]);
// Auto-generate name from selections
useEffect(() => {
const parts: string[] = [];
const countryItem = countries.find((c) => c.code === selectedCountry);
if (countryItem) parts.push(countryItem.name);
const stateItem = states.find((s) => s.code === selectedState);
if (stateItem) parts.push(stateItem.name);
const regionItem = regions.find((s) => s.code === selectedRegion);
if (regionItem) parts.push(regionItem.name);
const cityItem = cities.find((c) => c.code === selectedCity);
if (cityItem) parts.push(cityItem.name);
const ispItem = isps.find((i) => i.code === selectedIsp);
if (ispItem) parts.push(ispItem.name);
if (parts.length > 0) {
setProxyName(parts.join(" - "));
}
}, [selectedCountry, selectedState, selectedCity, countries, states, cities]);
}, [
selectedCountry,
selectedRegion,
selectedCity,
selectedIsp,
countries,
regions,
cities,
isps,
]);
const handleCreate = useCallback(async () => {
if (!selectedCountry || !proxyName.trim()) return;
@@ -119,8 +160,9 @@ export function LocationProxyDialog({
await invoke("create_cloud_location_proxy", {
name: proxyName.trim(),
country: selectedCountry,
state: selectedState || null,
region: selectedRegion || null,
city: selectedCity || null,
isp: selectedIsp || null,
});
toast.success("Location proxy created");
await emit("stored-proxies-changed");
@@ -133,14 +175,26 @@ export function LocationProxyDialog({
} finally {
setIsCreating(false);
}
}, [selectedCountry, selectedState, selectedCity, proxyName, handleClose]);
}, [
selectedCountry,
selectedRegion,
selectedCity,
selectedIsp,
proxyName,
handleClose,
]);
const countryOptions = countries.map((c) => ({
value: c.code,
label: c.name,
}));
const stateOptions = states.map((s) => ({ value: s.code, label: s.name }));
const regionOptions = regions.map((s) => ({ value: s.code, label: s.name }));
const cityOptions = cities.map((c) => ({ value: c.code, label: c.name }));
const ispOptions = isps.map((i) => ({ value: i.code, label: i.name }));
const LoadingSpinner = () => (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
);
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
@@ -148,48 +202,102 @@ export function LocationProxyDialog({
<DialogHeader>
<DialogTitle>Create Location Proxy</DialogTitle>
<DialogDescription>
Create a geo-targeted proxy from your cloud credentials
Create a geo-targeted proxy with a 24-hour sticky session
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Country - always visible */}
<div className="space-y-2">
<Label>Country (required)</Label>
<Label className="flex items-center gap-2">
Country (required)
{isLoadingCountries && <LoadingSpinner />}
</Label>
<Combobox
options={countryOptions}
value={selectedCountry}
onValueChange={setSelectedCountry}
placeholder={isLoadingCountries ? "Loading..." : "Select country"}
placeholder={
isLoadingCountries ? "Loading countries..." : "Select country"
}
searchPlaceholder="Search countries..."
disabled={isLoadingCountries}
/>
</div>
{selectedCountry && stateOptions.length > 0 && (
<div className="space-y-2">
<Label>State (optional)</Label>
<Combobox
options={stateOptions}
value={selectedState}
onValueChange={setSelectedState}
placeholder={isLoadingStates ? "Loading..." : "Select state"}
searchPlaceholder="Search states..."
/>
</div>
)}
{/* Region - always visible, disabled until country is selected */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
Region (optional)
{isLoadingRegions && <LoadingSpinner />}
</Label>
<Combobox
options={regionOptions}
value={selectedRegion}
onValueChange={setSelectedRegion}
placeholder={
!selectedCountry
? "Select a country first"
: isLoadingRegions
? "Loading regions..."
: regionOptions.length === 0
? "No regions available"
: "Select region"
}
searchPlaceholder="Search regions..."
disabled={!selectedCountry || isLoadingRegions}
/>
</div>
{selectedState && cityOptions.length > 0 && (
<div className="space-y-2">
<Label>City (optional)</Label>
<Combobox
options={cityOptions}
value={selectedCity}
onValueChange={setSelectedCity}
placeholder={isLoadingCities ? "Loading..." : "Select city"}
searchPlaceholder="Search cities..."
/>
</div>
)}
{/* City - always visible, disabled until country is selected */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
City (optional)
{isLoadingCities && <LoadingSpinner />}
</Label>
<Combobox
options={cityOptions}
value={selectedCity}
onValueChange={setSelectedCity}
placeholder={
!selectedCountry
? "Select a country first"
: isLoadingCities
? "Loading cities..."
: cityOptions.length === 0
? "No cities available"
: "Select city"
}
searchPlaceholder="Search cities..."
disabled={!selectedCountry || isLoadingCities}
/>
</div>
{/* ISP - always visible, disabled until country is selected */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
ISP (optional)
{isLoadingIsps && <LoadingSpinner />}
</Label>
<Combobox
options={ispOptions}
value={selectedIsp}
onValueChange={setSelectedIsp}
placeholder={
!selectedCountry
? "Select a country first"
: isLoadingIsps
? "Loading ISPs..."
: ispOptions.length === 0
? "No ISPs available"
: "Select ISP"
}
searchPlaceholder="Search ISPs..."
disabled={!selectedCountry || isLoadingIsps}
/>
</div>
{/* Name */}
<div className="space-y-2">
<Label>Name</Label>
<Input
+2 -1
View File
@@ -1048,8 +1048,9 @@ export function ProfilesDataTable({
await invoke("create_cloud_location_proxy", {
name: country.name,
country: country.code,
state: null,
region: null,
city: null,
isp: null,
});
await emit("stored-proxies-changed");
// Wait briefly for proxy list to update, then find and assign the new proxy
+13 -1
View File
@@ -217,6 +217,7 @@ export function ProfileInfoDialog({
disabled?: boolean;
destructive?: boolean;
proBadge?: boolean;
runningBadge?: boolean;
hidden?: boolean;
};
@@ -240,12 +241,14 @@ export function ProfileInfoDialog({
onClick: () =>
handleAction(() => onAssignProfilesToGroup?.([profile.id])),
disabled: isDisabled,
runningBadge: isRunning,
},
{
icon: <LuFingerprint className="w-4 h-4" />,
label: t("profiles.actions.changeFingerprint"),
onClick: () => handleAction(() => onConfigureCamoufox?.(profile)),
disabled: isDisabled,
runningBadge: isRunning,
hidden: !isCamoufoxOrWayfern || !onConfigureCamoufox,
},
{
@@ -254,6 +257,7 @@ export function ProfileInfoDialog({
onClick: () => handleAction(() => onCopyCookiesToProfile?.(profile)),
disabled: isDisabled || !crossOsUnlocked,
proBadge: !crossOsUnlocked,
runningBadge: isRunning && crossOsUnlocked,
hidden:
!isCamoufoxOrWayfern ||
profile.ephemeral === true ||
@@ -265,6 +269,7 @@ export function ProfileInfoDialog({
onClick: () => handleAction(() => onOpenCookieManagement?.(profile)),
disabled: isDisabled || !crossOsUnlocked,
proBadge: !crossOsUnlocked,
runningBadge: isRunning && crossOsUnlocked,
hidden:
!isCamoufoxOrWayfern ||
profile.ephemeral === true ||
@@ -275,6 +280,7 @@ export function ProfileInfoDialog({
label: t("profiles.actions.clone"),
onClick: () => handleAction(() => onCloneProfile?.(profile)),
disabled: isDisabled,
runningBadge: isRunning,
hidden: profile.ephemeral === true,
},
{
@@ -283,6 +289,7 @@ export function ProfileInfoDialog({
onClick: () => handleAction(() => onAssignExtensionGroup?.([profile.id])),
disabled: isDisabled || !crossOsUnlocked,
proBadge: !crossOsUnlocked,
runningBadge: isRunning && crossOsUnlocked,
hidden: profile.ephemeral === true,
},
{
@@ -488,7 +495,12 @@ export function ProfileInfoDialog({
{action.icon}
<span className="flex-1 flex items-center gap-2">
{action.label}
{action.proBadge && <ProBadge />}
{action.runningBadge && (
<span className="px-1.5 py-0.5 text-[10px] font-semibold rounded bg-primary/15 text-primary uppercase">
{t("common.status.running")}
</span>
)}
{action.proBadge && !action.runningBadge && <ProBadge />}
</span>
<LuChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
+22 -14
View File
@@ -1,7 +1,7 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LoadingButton } from "@/components/loading-button";
import { Badge } from "@/components/ui/badge";
@@ -53,6 +53,7 @@ export function ProfileSyncDialog({
const [hasSelfHostedConfig, setHasSelfHostedConfig] = useState(false);
const [hasE2ePassword, setHasE2ePassword] = useState(false);
const [isCheckingConfig, setIsCheckingConfig] = useState(false);
const [userChangedMode, setUserChangedMode] = useState(false);
const hasConfig = isCloudSyncEligible || hasSelfHostedConfig;
@@ -72,17 +73,21 @@ export function ProfileSyncDialog({
}
}, []);
useEffect(() => {
if (isOpen && profile) {
setSyncMode(profile.sync_mode ?? "Disabled");
setUserChangedMode(false);
void checkSyncConfig();
}
}, [isOpen, profile, checkSyncConfig]);
const handleOpenChange = useCallback(
(open: boolean) => {
if (open && profile) {
setSyncMode(profile.sync_mode ?? "Disabled");
void checkSyncConfig();
}
if (!open) {
onClose();
}
},
[profile, onClose, checkSyncConfig],
[onClose],
);
const handleModeChange = useCallback(
@@ -113,6 +118,7 @@ export function ProfileSyncDialog({
syncMode: newMode,
});
setSyncMode(newMode as SyncMode);
setUserChangedMode(true);
showSuccessToast(
newMode !== "Disabled"
? t("sync.mode.enabledToast")
@@ -273,14 +279,16 @@ export function ProfileSyncDialog({
</div>
</RadioGroup>
{syncMode === "Encrypted" && !hasE2ePassword && (
<div className="p-3 text-sm rounded-md bg-destructive/10 text-destructive">
{t(
"sync.mode.noPasswordWarning",
"E2E password not set. Please set a password in Settings.",
)}
</div>
)}
{syncMode === "Encrypted" &&
!hasE2ePassword &&
userChangedMode && (
<div className="p-3 text-sm rounded-md bg-destructive/10 text-destructive">
{t(
"sync.mode.noPasswordWarning",
"E2E password not set. Please set a password in Settings.",
)}
</div>
)}
<div className="space-y-2">
<Label>{t("sync.mode.lastSynced", "Last Synced")}</Label>
+121 -51
View File
@@ -3,9 +3,19 @@
import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import { LuCheck, LuChevronsUpDown } from "react-icons/lu";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
@@ -16,14 +26,11 @@ import {
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import type { BrowserProfile, StoredProxy, VpnConfig } from "@/types";
import { RippleButton } from "./ui/ripple";
@@ -51,6 +58,7 @@ export function ProxyAssignmentDialog({
"none",
);
const [isAssigning, setIsAssigning] = useState(false);
const [proxyPopoverOpen, setProxyPopoverOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleValueChange = useCallback((value: string) => {
@@ -126,13 +134,6 @@ export function ProxyAssignmentDialog({
}
}, [isOpen]);
const selectValue =
selectionType === "none"
? "none"
: selectionType === "vpn"
? `vpn-${selectedId}`
: (selectedId ?? "none");
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md">
@@ -166,43 +167,112 @@ export function ProxyAssignmentDialog({
<div className="space-y-2">
<Label htmlFor="proxy-vpn-select">Assign Proxy / VPN:</Label>
<Select value={selectValue} onValueChange={handleValueChange}>
<SelectTrigger>
<SelectValue placeholder="Select a proxy or VPN" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{storedProxies.length > 0 && (
<SelectGroup>
<SelectLabel>Proxies</SelectLabel>
{storedProxies.map((proxy) => (
<SelectItem key={proxy.id} value={proxy.id}>
{proxy.name}
{proxy.is_cloud_managed ? " (Included)" : ""}
</SelectItem>
))}
</SelectGroup>
)}
{vpnConfigs.length > 0 && (
<SelectGroup>
<SelectLabel>VPNs</SelectLabel>
{vpnConfigs.map((vpn) => (
<SelectItem key={vpn.id} value={`vpn-${vpn.id}`}>
<span className="flex items-center gap-1">
<Badge
variant="outline"
className="text-[10px] px-1 py-0 leading-tight"
<Popover open={proxyPopoverOpen} onOpenChange={setProxyPopoverOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={proxyPopoverOpen}
className="w-full justify-between font-normal"
>
{(() => {
if (selectionType === "none") return "None";
if (selectionType === "vpn") {
const vpn = vpnConfigs.find((v) => v.id === selectedId);
return vpn
? `${vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"}${vpn.name}`
: "None";
}
const proxy = storedProxies.find(
(p) => p.id === selectedId,
);
return proxy
? `${proxy.name}${proxy.is_cloud_managed ? " (Included)" : ""}`
: "None";
})()}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[240px] p-0" sideOffset={8}>
<Command>
<CommandInput placeholder="Search proxies or VPNs..." />
<CommandList>
<CommandEmpty>No proxies or VPNs found.</CommandEmpty>
<CommandGroup>
<CommandItem
value="__none__"
onSelect={() => {
handleValueChange("none");
setProxyPopoverOpen(false);
}}
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
selectionType === "none"
? "opacity-100"
: "opacity-0",
)}
/>
None
</CommandItem>
{storedProxies.map((proxy) => (
<CommandItem
key={proxy.id}
value={proxy.name}
onSelect={() => {
handleValueChange(proxy.id);
setProxyPopoverOpen(false);
}}
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
selectionType === "proxy" &&
selectedId === proxy.id
? "opacity-100"
: "opacity-0",
)}
/>
{proxy.name}
{proxy.is_cloud_managed ? " (Included)" : ""}
</CommandItem>
))}
</CommandGroup>
{vpnConfigs.length > 0 && (
<CommandGroup heading="VPNs">
{vpnConfigs.map((vpn) => (
<CommandItem
key={vpn.id}
value={`vpn-${vpn.name}`}
onSelect={() => {
handleValueChange(`vpn-${vpn.id}`);
setProxyPopoverOpen(false);
}}
>
{vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"}
</Badge>
{vpn.name}
</span>
</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
selectionType === "vpn" && selectedId === vpn.id
? "opacity-100"
: "opacity-0",
)}
/>
<Badge
variant="outline"
className="text-[10px] px-1 py-0 leading-tight mr-1"
>
{vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"}
</Badge>
{vpn.name}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{error && (
+339 -350
View File
@@ -53,6 +53,7 @@ type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting";
function getSyncStatusDot(
item: { sync_enabled?: boolean; last_sync?: number },
liveStatus: SyncStatus | undefined,
errorMessage?: string,
): { color: string; tooltip: string; animate: boolean } {
const status = liveStatus ?? (item.sync_enabled ? "synced" : "disabled");
@@ -74,7 +75,11 @@ function getSyncStatusDot(
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,
};
default:
return { color: "bg-gray-400", tooltip: "Not synced", animate: false };
}
@@ -104,6 +109,9 @@ export function ProxyManagementDialog({
const [proxySyncStatus, setProxySyncStatus] = useState<
Record<string, SyncStatus>
>({});
const [proxySyncErrors, setProxySyncErrors] = useState<
Record<string, string>
>({});
const [proxyInUse, setProxyInUse] = useState<Record<string, boolean>>({});
const [isTogglingSync, setIsTogglingSync] = useState<Record<string, boolean>>(
{},
@@ -119,6 +127,9 @@ export function ProxyManagementDialog({
const [vpnSyncStatus, setVpnSyncStatus] = useState<
Record<string, SyncStatus>
>({});
const [vpnSyncErrors, setVpnSyncErrors] = useState<Record<string, string>>(
{},
);
const [vpnInUse, setVpnInUse] = useState<Record<string, boolean>>({});
const [isTogglingVpnSync, setIsTogglingVpnSync] = useState<
Record<string, boolean>
@@ -126,50 +137,30 @@ export function ProxyManagementDialog({
const { storedProxies: rawProxies, proxyUsage, isLoading } = useProxyEvents();
const { vpnConfigs, vpnUsage, isLoading: isLoadingVpns } = useVpnEvents();
const [cloudProxyUsage, setCloudProxyUsage] = useState<{
used_mb: number;
limit_mb: number;
} | null>(null);
// Sort cloud-managed proxies first
const storedProxies = [...rawProxies].sort((a, b) => {
if (a.is_cloud_managed && !b.is_cloud_managed) return -1;
if (!a.is_cloud_managed && b.is_cloud_managed) return 1;
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
// Fetch cloud proxy usage
useEffect(() => {
const fetchUsage = async () => {
try {
const usage = await invoke<{
used_mb: number;
limit_mb: number;
remaining_mb: number;
} | null>("cloud_get_proxy_usage");
setCloudProxyUsage(usage);
} catch {
// ignore
}
};
if (isOpen) {
void fetchUsage();
}
}, [isOpen]);
// Filter out the base cloud-managed proxy (it's an internal indicator, not user-facing)
// Keep cloud-derived location proxies
const storedProxies = rawProxies
.filter((p) => !p.is_cloud_managed)
.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
const hasCloudProxy = rawProxies.some((p) => p.is_cloud_managed);
// Listen for proxy sync status events
useEffect(() => {
let unlisten: (() => void) | undefined;
const setupListener = async () => {
unlisten = await listen<{ id: string; status: string }>(
unlisten = await listen<{ id: string; status: string; error?: string }>(
"proxy-sync-status",
(event) => {
const { id, status } = event.payload;
const { id, status, error } = event.payload;
setProxySyncStatus((prev) => ({
...prev,
[id]: status as SyncStatus,
}));
if (error) {
setProxySyncErrors((prev) => ({ ...prev, [id]: error }));
}
},
);
};
@@ -185,14 +176,17 @@ export function ProxyManagementDialog({
let unlisten: (() => void) | undefined;
const setupListener = async () => {
unlisten = await listen<{ id: string; status: string }>(
unlisten = await listen<{ id: string; status: string; error?: string }>(
"vpn-sync-status",
(event) => {
const { id, status } = event.payload;
const { id, status, error } = event.payload;
setVpnSyncStatus((prev) => ({
...prev,
[id]: status as SyncStatus,
}));
if (error) {
setVpnSyncErrors((prev) => ({ ...prev, [id]: error }));
}
},
);
};
@@ -370,7 +364,7 @@ export function ProxyManagementDialog({
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl">
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>Proxies & VPNs</DialogTitle>
<DialogDescription>
@@ -378,96 +372,96 @@ export function ProxyManagementDialog({
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="proxies">
<TabsList className="w-full">
<TabsTrigger value="proxies" className="flex-1">
Proxies
</TabsTrigger>
<TabsTrigger value="vpns" className="flex-1">
VPNs
</TabsTrigger>
</TabsList>
<ScrollArea className="overflow-y-auto flex-1">
<Tabs defaultValue="proxies">
<TabsList className="w-full">
<TabsTrigger value="proxies" className="flex-1">
Proxies
</TabsTrigger>
<TabsTrigger value="vpns" className="flex-1">
VPNs
</TabsTrigger>
</TabsList>
<TabsContent value="proxies">
<div className="space-y-4">
<div className="flex justify-between items-center">
<div className="flex gap-2">
<RippleButton
size="sm"
variant="outline"
onClick={() => setShowImportDialog(true)}
className="flex gap-2 items-center"
>
<LuUpload className="w-4 h-4" />
Import
</RippleButton>
<RippleButton
size="sm"
variant="outline"
onClick={() => setShowExportDialog(true)}
className="flex gap-2 items-center"
disabled={storedProxies.length === 0}
>
<LuDownload className="w-4 h-4" />
Export
</RippleButton>
</div>
<div className="flex gap-2">
{storedProxies.some((p) => p.is_cloud_managed) && (
<TabsContent value="proxies">
<div className="space-y-4">
<div className="flex justify-between items-center">
<div className="flex gap-2">
<RippleButton
size="sm"
variant="outline"
onClick={() => setShowLocationDialog(true)}
onClick={() => setShowImportDialog(true)}
className="flex gap-2 items-center"
>
<GoGlobe className="w-4 h-4" />
Location
<LuUpload className="w-4 h-4" />
Import
</RippleButton>
)}
<RippleButton
size="sm"
onClick={handleCreateProxy}
className="flex gap-2 items-center"
>
<GoPlus className="w-4 h-4" />
Create
</RippleButton>
<RippleButton
size="sm"
variant="outline"
onClick={() => setShowExportDialog(true)}
className="flex gap-2 items-center"
disabled={storedProxies.length === 0}
>
<LuDownload className="w-4 h-4" />
Export
</RippleButton>
</div>
<div className="flex gap-2">
{hasCloudProxy && (
<RippleButton
size="sm"
variant="outline"
onClick={() => setShowLocationDialog(true)}
className="flex gap-2 items-center"
>
<GoGlobe className="w-4 h-4" />
Location
</RippleButton>
)}
<RippleButton
size="sm"
onClick={handleCreateProxy}
className="flex gap-2 items-center"
>
<GoPlus className="w-4 h-4" />
Create
</RippleButton>
</div>
</div>
</div>
{isLoading ? (
<div className="text-sm text-muted-foreground">
Loading proxies...
</div>
) : storedProxies.length === 0 ? (
<div className="text-sm text-muted-foreground">
No proxies created yet. Create your first proxy using the
button above.
</div>
) : (
<div className="border rounded-md">
<ScrollArea className="h-[240px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="w-20">Usage</TableHead>
<TableHead className="w-24">Sync</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{storedProxies.map((proxy) => {
const isCloud = proxy.is_cloud_managed === true;
const syncDot = getSyncStatusDot(
proxy,
proxySyncStatus[proxy.id],
);
const isDerived = proxy.is_cloud_derived === true;
return (
<TableRow key={proxy.id}>
<TableCell className="font-medium">
<div className="flex flex-col gap-0.5">
{isLoading ? (
<div className="text-sm text-muted-foreground">
Loading proxies...
</div>
) : storedProxies.length === 0 ? (
<div className="text-sm text-muted-foreground">
No proxies created yet. Create your first proxy using the
button above.
</div>
) : (
<div className="border rounded-md">
<ScrollArea className="h-[240px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="w-20">Usage</TableHead>
<TableHead className="w-24">Sync</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{storedProxies.map((proxy) => {
const syncDot = getSyncStatusDot(
proxy,
proxySyncStatus[proxy.id],
proxySyncErrors[proxy.id],
);
const isDerived = proxy.is_cloud_derived === true;
return (
<TableRow key={proxy.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
{isDerived && proxy.geo_country && (
<FlagIcon
@@ -475,7 +469,7 @@ export function ProxyManagementDialog({
className="shrink-0"
/>
)}
{!isCloud && !isDerived && (
{!isDerived && (
<Tooltip>
<TooltipTrigger asChild>
<div
@@ -493,23 +487,13 @@ export function ProxyManagementDialog({
)}
{proxy.name}
</div>
{isCloud && cloudProxyUsage && (
<span className="text-xs text-muted-foreground">
{cloudProxyUsage.used_mb} /{" "}
{cloudProxyUsage.limit_mb} MB used
</span>
)}
</div>
</TableCell>
<TableCell>
<Badge variant="secondary">
{proxyUsage[proxy.id] ?? 0}
</Badge>
</TableCell>
<TableCell>
{isCloud ? (
<Badge variant="outline">Cloud</Badge>
) : (
</TableCell>
<TableCell>
<Badge variant="secondary">
{proxyUsage[proxy.id] ?? 0}
</Badge>
</TableCell>
<TableCell>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
@@ -540,48 +524,50 @@ export function ProxyManagementDialog({
)}
</TooltipContent>
</Tooltip>
)}
</TableCell>
<TableCell>
<div className="flex gap-1">
<ProxyCheckButton
proxy={proxy}
profileId={proxy.id}
checkingProfileId={checkingProxyId}
cachedResult={proxyCheckResults[proxy.id]}
setCheckingProfileId={setCheckingProxyId}
onCheckComplete={(result) => {
setProxyCheckResults((prev) => ({
...prev,
[proxy.id]: result,
}));
}}
onCheckFailed={(result) => {
setProxyCheckResults((prev) => ({
...prev,
[proxy.id]: result,
}));
}}
/>
{!isCloud && !isDerived && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() =>
handleEditProxy(proxy)
}
>
<LuPencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit proxy</p>
</TooltipContent>
</Tooltip>
)}
{!isCloud && (
</TableCell>
<TableCell>
<div className="flex gap-1">
<ProxyCheckButton
proxy={proxy}
profileId={proxy.id}
checkingProfileId={checkingProxyId}
cachedResult={
proxyCheckResults[proxy.id]
}
setCheckingProfileId={
setCheckingProxyId
}
onCheckComplete={(result) => {
setProxyCheckResults((prev) => ({
...prev,
[proxy.id]: result,
}));
}}
onCheckFailed={(result) => {
setProxyCheckResults((prev) => ({
...prev,
[proxy.id]: result,
}));
}}
/>
{!isDerived && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() =>
handleEditProxy(proxy)
}
>
<LuPencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit proxy</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<span>
@@ -613,199 +599,202 @@ export function ProxyManagementDialog({
)}
</TooltipContent>
</Tooltip>
)}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</ScrollArea>
</div>
)}
</div>
</TabsContent>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</ScrollArea>
</div>
)}
</div>
</TabsContent>
<TabsContent value="vpns">
<div className="space-y-4">
<div className="flex justify-between items-center">
<div className="flex gap-2">
<TabsContent value="vpns">
<div className="space-y-4">
<div className="flex justify-between items-center">
<div className="flex gap-2">
<RippleButton
size="sm"
variant="outline"
onClick={() => setShowVpnImportDialog(true)}
className="flex gap-2 items-center"
>
<LuUpload className="w-4 h-4" />
Import
</RippleButton>
</div>
<RippleButton
size="sm"
variant="outline"
onClick={() => setShowVpnImportDialog(true)}
onClick={handleCreateVpn}
className="flex gap-2 items-center"
>
<LuUpload className="w-4 h-4" />
Import
<GoPlus className="w-4 h-4" />
Create
</RippleButton>
</div>
<RippleButton
size="sm"
onClick={handleCreateVpn}
className="flex gap-2 items-center"
>
<GoPlus className="w-4 h-4" />
Create
</RippleButton>
</div>
{isLoadingVpns ? (
<div className="text-sm text-muted-foreground">
Loading VPNs...
</div>
) : vpnConfigs.length === 0 ? (
<div className="text-sm text-muted-foreground">
No VPN configs created yet. Import or create one using the
buttons above.
</div>
) : (
<div className="border rounded-md">
<ScrollArea className="h-[240px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="w-16">Type</TableHead>
<TableHead className="w-20">Usage</TableHead>
<TableHead className="w-24">Sync</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{vpnConfigs.map((vpn) => {
const syncDot = getSyncStatusDot(
vpn,
vpnSyncStatus[vpn.id],
);
return (
<TableRow key={vpn.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
{isLoadingVpns ? (
<div className="text-sm text-muted-foreground">
Loading VPNs...
</div>
) : vpnConfigs.length === 0 ? (
<div className="text-sm text-muted-foreground">
No VPN configs created yet. Import or create one using the
buttons above.
</div>
) : (
<div className="border rounded-md">
<ScrollArea className="h-[240px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="w-16">Type</TableHead>
<TableHead className="w-20">Usage</TableHead>
<TableHead className="w-24">Sync</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{vpnConfigs.map((vpn) => {
const syncDot = getSyncStatusDot(
vpn,
vpnSyncStatus[vpn.id],
vpnSyncErrors[vpn.id],
);
return (
<TableRow key={vpn.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<div
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
syncDot.animate
? "animate-pulse"
: ""
}`}
/>
</TooltipTrigger>
<TooltipContent>
<p>{syncDot.tooltip}</p>
</TooltipContent>
</Tooltip>
{vpn.name}
</div>
</TableCell>
<TableCell>
<Badge variant="outline">
{vpn.vpn_type === "WireGuard"
? "WG"
: "OVPN"}
</Badge>
</TableCell>
<TableCell>
<Badge variant="secondary">
{vpnUsage[vpn.id] ?? 0}
</Badge>
</TableCell>
<TableCell>
<Tooltip>
<TooltipTrigger asChild>
<div
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
syncDot.animate
? "animate-pulse"
: ""
}`}
/>
</TooltipTrigger>
<TooltipContent>
<p>{syncDot.tooltip}</p>
</TooltipContent>
</Tooltip>
{vpn.name}
</div>
</TableCell>
<TableCell>
<Badge variant="outline">
{vpn.vpn_type === "WireGuard"
? "WG"
: "OVPN"}
</Badge>
</TableCell>
<TableCell>
<Badge variant="secondary">
{vpnUsage[vpn.id] ?? 0}
</Badge>
</TableCell>
<TableCell>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Checkbox
checked={vpn.sync_enabled}
onCheckedChange={() =>
handleToggleVpnSync(vpn)
}
disabled={
isTogglingVpnSync[vpn.id] ||
vpnInUse[vpn.id]
}
/>
</div>
</TooltipTrigger>
<TooltipContent>
{vpnInUse[vpn.id] ? (
<p>
Sync cannot be disabled while this VPN
is used by synced profiles
</p>
) : (
<p>
{vpn.sync_enabled
? "Disable sync"
: "Enable sync"}
</p>
)}
</TooltipContent>
</Tooltip>
</TableCell>
<TableCell>
<div className="flex gap-1">
<VpnCheckButton
vpnId={vpn.id}
vpnName={vpn.name}
checkingVpnId={checkingVpnId}
setCheckingVpnId={setCheckingVpnId}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditVpn(vpn)}
>
<LuPencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit VPN</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteVpn(vpn)}
disabled={
(vpnUsage[vpn.id] ?? 0) > 0
<div className="flex items-center">
<Checkbox
checked={vpn.sync_enabled}
onCheckedChange={() =>
handleToggleVpnSync(vpn)
}
>
<LuTrash2 className="w-4 h-4" />
</Button>
</span>
disabled={
isTogglingVpnSync[vpn.id] ||
vpnInUse[vpn.id]
}
/>
</div>
</TooltipTrigger>
<TooltipContent>
{(vpnUsage[vpn.id] ?? 0) > 0 ? (
{vpnInUse[vpn.id] ? (
<p>
Cannot delete: in use by{" "}
{vpnUsage[vpn.id]} profile
{vpnUsage[vpn.id] > 1 ? "s" : ""}
Sync cannot be disabled while this
VPN is used by synced profiles
</p>
) : (
<p>Delete VPN</p>
<p>
{vpn.sync_enabled
? "Disable sync"
: "Enable sync"}
</p>
)}
</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</ScrollArea>
</div>
)}
</div>
</TabsContent>
</Tabs>
</TableCell>
<TableCell>
<div className="flex gap-1">
<VpnCheckButton
vpnId={vpn.id}
vpnName={vpn.name}
checkingVpnId={checkingVpnId}
setCheckingVpnId={setCheckingVpnId}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditVpn(vpn)}
>
<LuPencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit VPN</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="ghost"
size="sm"
onClick={() =>
handleDeleteVpn(vpn)
}
disabled={
(vpnUsage[vpn.id] ?? 0) > 0
}
>
<LuTrash2 className="w-4 h-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{(vpnUsage[vpn.id] ?? 0) > 0 ? (
<p>
Cannot delete: in use by{" "}
{vpnUsage[vpn.id]} profile
{vpnUsage[vpn.id] > 1 ? "s" : ""}
</p>
) : (
<p>Delete VPN</p>
)}
</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</ScrollArea>
</div>
)}
</div>
</TabsContent>
</Tabs>
</ScrollArea>
<DialogFooter>
<RippleButton variant="outline" onClick={onClose}>
+30 -1
View File
@@ -9,6 +9,7 @@ import { BsCamera, BsMic } from "react-icons/bs";
import { LoadingButton } from "@/components/loading-button";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
ColorPicker,
ColorPickerAlpha,
@@ -60,6 +61,7 @@ interface AppSettings {
api_enabled: boolean;
api_port: number;
api_token?: string;
disable_auto_updates?: boolean;
}
interface CustomThemeState {
@@ -116,6 +118,7 @@ export function SettingsDialog({
const [requestingPermission, setRequestingPermission] =
useState<PermissionType | null>(null);
const [isMacOS, setIsMacOS] = useState(false);
const [isLinux, setIsLinux] = useState(false);
const [hasE2ePassword, setHasE2ePassword] = useState(false);
const [e2ePassword, setE2ePassword] = useState("");
const [e2ePasswordConfirm, setE2ePasswordConfirm] = useState("");
@@ -486,6 +489,8 @@ export function SettingsDialog({
const userAgent = navigator.userAgent;
const isMac = userAgent.includes("Mac");
setIsMacOS(isMac);
const isLin = !userAgent.includes("Mac") && !userAgent.includes("Win");
setIsLinux(isLin);
if (isMac) {
loadPermissions().catch(console.error);
@@ -547,7 +552,8 @@ export function SettingsDialog({
JSON.stringify(originalSettings.custom_theme ?? {})) ||
(settings.theme !== "custom" &&
JSON.stringify(settings.custom_theme ?? {}) !==
JSON.stringify(originalSettings.custom_theme ?? {}));
JSON.stringify(originalSettings.custom_theme ?? {})) ||
settings.disable_auto_updates !== originalSettings.disable_auto_updates;
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
@@ -1028,6 +1034,29 @@ export function SettingsDialog({
<div className="space-y-4">
<Label className="text-base font-medium">Advanced</Label>
{!isLinux && (
<div className="flex items-start space-x-3 p-3 rounded-lg border">
<Checkbox
id="disable-auto-updates"
checked={settings.disable_auto_updates || false}
onCheckedChange={(checked) =>
updateSetting("disable_auto_updates", checked as boolean)
}
/>
<div className="space-y-1">
<Label
htmlFor="disable-auto-updates"
className="text-sm font-medium"
>
{t("settings.disableAutoUpdates")}
</Label>
<p className="text-xs text-muted-foreground">
{t("settings.disableAutoUpdatesDescription")}
</p>
</div>
</div>
)}
<LoadingButton
isLoading={isClearingCache}
onClick={() => {
+42 -1
View File
@@ -1,7 +1,9 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LoadingButton } from "@/components/loading-button";
import MultipleSelector, { type Option } from "@/components/multiple-selector";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Checkbox } from "@/components/ui/checkbox";
@@ -33,6 +35,8 @@ interface SharedCamoufoxConfigFormProps {
browserType?: "camoufox" | "wayfern"; // Browser type to customize form options
crossOsUnlocked?: boolean; // Allow selecting non-current OS (paid feature)
limitedMode?: boolean; // Blur and disable advanced fields while keeping basic options accessible
profileVersion?: string;
profileBrowser?: string;
}
// Determine if fingerprint editing should be disabled
@@ -124,6 +128,8 @@ export function SharedCamoufoxConfigForm({
browserType = "camoufox",
crossOsUnlocked = false,
limitedMode = false,
profileVersion,
profileBrowser,
}: SharedCamoufoxConfigFormProps) {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState(
@@ -132,6 +138,26 @@ export function SharedCamoufoxConfigForm({
const [fingerprintConfig, setFingerprintConfig] =
useState<CamoufoxFingerprintConfig>({});
const [currentOS] = useState<CamoufoxOS>(getCurrentOS);
const [isGeneratingFingerprint, setIsGeneratingFingerprint] = useState(false);
const handleGenerateFingerprint = async () => {
if (!profileVersion) return;
const browser = profileBrowser || browserType || "camoufox";
setIsGeneratingFingerprint(true);
try {
const configJson = JSON.stringify(config);
const result = await invoke<string>("generate_sample_fingerprint", {
browser,
version: profileVersion,
configJson,
});
onConfigChange("fingerprint", result);
} catch (error) {
console.error("Failed to generate fingerprint:", error);
} finally {
setIsGeneratingFingerprint(false);
}
};
// Get selected OS (defaults to current OS)
const selectedOS = config.os || currentOS;
@@ -223,7 +249,22 @@ export function SharedCamoufoxConfigForm({
<div className="space-y-6">
{/* Operating System Selection */}
<div className="space-y-3">
<Label>{t("fingerprint.osLabel")}</Label>
<div className="flex items-center justify-between">
<Label>{t("fingerprint.osLabel")}</Label>
{profileVersion && (!isCreating || crossOsUnlocked) && (
<LoadingButton
isLoading={isGeneratingFingerprint}
onClick={handleGenerateFingerprint}
disabled={readOnly}
variant="outline"
size="sm"
>
{isCreating
? t("fingerprint.generateFingerprint")
: t("fingerprint.refreshFingerprint")}
</LoadingButton>
)}
</div>
<Select
value={selectedOS}
onValueChange={(value: CamoufoxOS) => onConfigChange("os", value)}
+45 -20
View File
@@ -32,6 +32,14 @@ interface SyncConfigDialogProps {
onClose: (loginOccurred?: boolean) => void;
}
interface ProxyUsage {
used_mb: number;
limit_mb: number;
remaining_mb: number;
recurring_limit_mb: number;
extra_limit_mb: number;
}
export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
const { t } = useTranslation();
@@ -59,6 +67,7 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
const [isVerifying, setIsVerifying] = useState(false);
const [activeTab, setActiveTab] = useState<string>("cloud");
const [liveProxyUsage, setLiveProxyUsage] = useState<ProxyUsage | null>(null);
const [connectionStatus, setConnectionStatus] = useState<
"unknown" | "testing" | "connected" | "error"
@@ -99,6 +108,9 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
setCodeSent(false);
setOtpCode("");
setEmail("");
invoke<ProxyUsage | null>("cloud_get_proxy_usage")
.then(setLiveProxyUsage)
.catch(() => setLiveProxyUsage(null));
}
}, [isOpen, loadSettings]);
@@ -288,26 +300,39 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
})}
</span>
</div>
{user.proxyBandwidthLimitMb > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Proxy Bandwidth</span>
<span>
{user.proxyBandwidthUsedMb} /{" "}
{user.proxyBandwidthLimitMb +
(user.proxyBandwidthExtraMb || 0)}{" "}
MB
</span>
</div>
)}
{(user.proxyBandwidthExtraMb || 0) > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Extra Bandwidth</span>
<span>
{user.proxyBandwidthExtraMb >= 1000
? `${(user.proxyBandwidthExtraMb / 1000).toFixed(1)} GB`
: `${user.proxyBandwidthExtraMb} MB`}
</span>
</div>
{liveProxyUsage && (
<>
<div className="flex justify-between">
<span className="text-muted-foreground">
Recurring Proxy Bandwidth
</span>
<span>
{Math.max(
0,
liveProxyUsage.recurring_limit_mb -
liveProxyUsage.used_mb,
)}{" "}
/ {liveProxyUsage.recurring_limit_mb} MB remaining
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">
Extra Proxy Bandwidth
</span>
<span>
{Math.max(
0,
liveProxyUsage.remaining_mb -
Math.max(
0,
liveProxyUsage.recurring_limit_mb -
liveProxyUsage.used_mb,
),
)}{" "}
/ {liveProxyUsage.extra_limit_mb} MB remaining
</span>
</div>
</>
)}
{user.teamName && (
<>
+4 -1
View File
@@ -32,6 +32,7 @@ interface ComboboxProps {
placeholder?: string;
searchPlaceholder?: string;
className?: string;
disabled?: boolean;
}
export function Combobox({
@@ -41,16 +42,18 @@ export function Combobox({
placeholder = "Select option...",
searchPlaceholder = "Search...",
className,
disabled,
}: ComboboxProps) {
const [open, setOpen] = React.useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={disabled ? undefined : setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn("w-full justify-between", className)}
>
{value
+41 -1
View File
@@ -1,7 +1,9 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LoadingButton } from "@/components/loading-button";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
@@ -31,6 +33,8 @@ interface WayfernConfigFormProps {
readOnly?: boolean;
crossOsUnlocked?: boolean;
limitedMode?: boolean;
profileVersion?: string;
profileBrowser?: string;
}
const isFingerprintEditingDisabled = (config: WayfernConfig): boolean => {
@@ -62,6 +66,8 @@ export function WayfernConfigForm({
readOnly = false,
crossOsUnlocked = false,
limitedMode = false,
profileVersion,
profileBrowser,
}: WayfernConfigFormProps) {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState(
@@ -70,6 +76,25 @@ export function WayfernConfigForm({
const [fingerprintConfig, setFingerprintConfig] =
useState<WayfernFingerprintConfig>({});
const [currentOS] = useState<WayfernOS>(getCurrentOS);
const [isGeneratingFingerprint, setIsGeneratingFingerprint] = useState(false);
const handleGenerateFingerprint = async () => {
if (!profileVersion) return;
setIsGeneratingFingerprint(true);
try {
const configJson = JSON.stringify(config);
const result = await invoke<string>("generate_sample_fingerprint", {
browser: profileBrowser || "wayfern",
version: profileVersion,
configJson,
});
onConfigChange("fingerprint", result);
} catch (error) {
console.error("Failed to generate fingerprint:", error);
} finally {
setIsGeneratingFingerprint(false);
}
};
const selectedOS = config.os || currentOS;
@@ -150,7 +175,22 @@ export function WayfernConfigForm({
<div className="space-y-6">
{/* Operating System Selection */}
<div className="space-y-3">
<Label>{t("fingerprint.osLabel")}</Label>
<div className="flex items-center justify-between">
<Label>{t("fingerprint.osLabel")}</Label>
{profileVersion && (!isCreating || crossOsUnlocked) && (
<LoadingButton
isLoading={isGeneratingFingerprint}
onClick={handleGenerateFingerprint}
disabled={readOnly}
variant="outline"
size="sm"
>
{isCreating
? t("fingerprint.generateFingerprint")
: t("fingerprint.refreshFingerprint")}
</LoadingButton>
)}
</div>
<Select
value={selectedOS}
onValueChange={(value: WayfernOS) => onConfigChange("os", value)}
+32 -5
View File
@@ -134,7 +134,9 @@
"title": "Advanced",
"clearCache": "Clear All Version Cache",
"clearCacheDescription": "Clear all cached browser version data and refresh all browser versions from their sources. This will force a fresh download of version information for all browsers."
}
},
"disableAutoUpdates": "Disable Auto Updates",
"disableAutoUpdatesDescription": "Only notify when browser updates are available, without downloading automatically."
},
"header": {
"searchPlaceholder": "Search profiles...",
@@ -189,7 +191,7 @@
},
"createProfile": {
"title": "Create New Profile",
"configureTitle": "Configure Profile",
"configureTitle": "Create New {{browser}} Profile",
"antiDetect": {
"title": "Anti-Detect Browser",
"description": "Choose a browser with anti-detection capabilities",
@@ -219,7 +221,12 @@
"latestNeedsDownload": "Latest version ({{version}}) needs to be downloaded",
"latestAvailable": "Latest version ({{version}}) is available",
"latestDownloading": "Downloading version ({{version}})..."
}
},
"chromiumLabel": "Chromium",
"chromiumSubtitle": "Powered by Wayfern",
"firefoxLabel": "Firefox",
"firefoxSubtitle": "Powered by Camoufox",
"camoufoxWarning": "Firefox (Camoufox) is maintained by a third-party organization. For production use, please use Chromium."
},
"deleteDialog": {
"title": "Delete Profile",
@@ -640,7 +647,9 @@
"productSub": "Product Sub",
"brand": "Brand",
"brandVersion": "Brand Version",
"proFeature": "This is a Pro feature"
"proFeature": "This is a Pro feature",
"generateFingerprint": "Generate Fingerprint",
"refreshFingerprint": "Refresh Fingerprint"
},
"warnings": {
"windowResizeTitle": "Custom Window Dimensions",
@@ -779,7 +788,25 @@
"assignTitle": "Assign Extension Group",
"assignDescription": "Assign {{count}} selected profile(s) to an extension group.",
"noGroup": "None (No Extension Group)",
"assignSuccess": "Extension group assigned successfully"
"assignSuccess": "Extension group assigned successfully",
"editExtension": "Edit extension",
"updateSuccess": "Extension updated successfully",
"reupload": "Re-upload",
"version": "Version",
"author": "Author",
"homepage": "Homepage",
"editGroup": "Edit Group",
"editGroupDescription": "Update the group name and manage which extensions are included.",
"groupExtensions": "Extensions in this group",
"noExtensionsInGroup": "No extensions added yet",
"editExtensionDescription": "Update extension name, view metadata, or re-upload the extension file.",
"metadata": "Metadata",
"noMetadata": "No metadata available from manifest.",
"selectFile": "Choose File",
"syncEnabled": "Sync enabled",
"syncDisabled": "Sync disabled",
"syncEnableTooltip": "Enable sync",
"syncDisableTooltip": "Disable sync"
},
"pro": {
"badge": "PRO",
+32 -5
View File
@@ -134,7 +134,9 @@
"title": "Avanzado",
"clearCache": "Limpiar Toda la Caché de Versiones",
"clearCacheDescription": "Limpia todos los datos de versiones de navegadores en caché y actualiza todas las versiones desde sus fuentes. Esto forzará una descarga nueva de información de versiones para todos los navegadores."
}
},
"disableAutoUpdates": "Desactivar Actualizaciones Automáticas",
"disableAutoUpdatesDescription": "Evita que la aplicación busque e instale actualizaciones automáticamente. Deberá actualizar la aplicación manualmente."
},
"header": {
"searchPlaceholder": "Buscar perfiles...",
@@ -189,7 +191,7 @@
},
"createProfile": {
"title": "Crear Nuevo Perfil",
"configureTitle": "Configurar Perfil",
"configureTitle": "Crear Nuevo Perfil de {{browser}}",
"antiDetect": {
"title": "Navegador Anti-Detección",
"description": "Elige un navegador con capacidades anti-detección",
@@ -219,7 +221,12 @@
"latestNeedsDownload": "La última versión ({{version}}) necesita ser descargada",
"latestAvailable": "La última versión ({{version}}) está disponible",
"latestDownloading": "Descargando versión ({{version}})..."
}
},
"chromiumLabel": "Chromium",
"chromiumSubtitle": "Impulsado por Wayfern",
"firefoxLabel": "Firefox",
"firefoxSubtitle": "Impulsado por Camoufox",
"camoufoxWarning": "Firefox (Camoufox) está mantenido por una organización de terceros. Para uso en producción, utilice Chromium."
},
"deleteDialog": {
"title": "Eliminar Perfil",
@@ -640,7 +647,9 @@
"productSub": "Product Sub",
"brand": "Marca",
"brandVersion": "Versión de marca",
"proFeature": "Esta es una función Pro"
"proFeature": "Esta es una función Pro",
"generateFingerprint": "Generar Huella Digital",
"refreshFingerprint": "Actualizar Huella Digital"
},
"warnings": {
"windowResizeTitle": "Dimensiones de ventana personalizadas",
@@ -779,7 +788,25 @@
"assignTitle": "Asignar Grupo de Extensiones",
"assignDescription": "Asignar {{count}} perfil(es) seleccionado(s) a un grupo de extensiones.",
"noGroup": "Ninguno (Sin Grupo de Extensiones)",
"assignSuccess": "Grupo de extensiones asignado exitosamente"
"assignSuccess": "Grupo de extensiones asignado exitosamente",
"editExtension": "Editar extensión",
"updateSuccess": "Extensión actualizada exitosamente",
"reupload": "Re-subir",
"version": "Versión",
"author": "Autor",
"homepage": "Página de inicio",
"editGroup": "Editar grupo",
"editGroupDescription": "Actualiza el nombre del grupo y gestiona qué extensiones están incluidas.",
"groupExtensions": "Extensiones en este grupo",
"noExtensionsInGroup": "Aún no se han añadido extensiones",
"editExtensionDescription": "Actualizar el nombre de la extensión, ver metadatos o volver a cargar el archivo de extensión.",
"metadata": "Metadatos",
"noMetadata": "No hay metadatos disponibles del manifiesto.",
"selectFile": "Elegir archivo",
"syncEnabled": "Sincronización habilitada",
"syncDisabled": "Sincronización deshabilitada",
"syncEnableTooltip": "Habilitar sincronización",
"syncDisableTooltip": "Deshabilitar sincronización"
},
"pro": {
"badge": "PRO",
+32 -5
View File
@@ -134,7 +134,9 @@
"title": "Avancé",
"clearCache": "Effacer tout le cache des versions",
"clearCacheDescription": "Efface toutes les données de versions de navigateurs en cache et actualise toutes les versions depuis leurs sources. Cela forcera un nouveau téléchargement des informations de version pour tous les navigateurs."
}
},
"disableAutoUpdates": "Désactiver les mises à jour automatiques",
"disableAutoUpdatesDescription": "Empêche l'application de vérifier et d'installer automatiquement les mises à jour. Vous devrez mettre à jour l'application manuellement."
},
"header": {
"searchPlaceholder": "Rechercher des profils...",
@@ -189,7 +191,7 @@
},
"createProfile": {
"title": "Créer un nouveau profil",
"configureTitle": "Configurer le profil",
"configureTitle": "Créer un nouveau profil {{browser}}",
"antiDetect": {
"title": "Navigateur anti-détection",
"description": "Choisissez un navigateur avec des capacités anti-détection",
@@ -219,7 +221,12 @@
"latestNeedsDownload": "La dernière version ({{version}}) doit être téléchargée",
"latestAvailable": "La dernière version ({{version}}) est disponible",
"latestDownloading": "Téléchargement de la version ({{version}})..."
}
},
"chromiumLabel": "Chromium",
"chromiumSubtitle": "Propulsé par Wayfern",
"firefoxLabel": "Firefox",
"firefoxSubtitle": "Propulsé par Camoufox",
"camoufoxWarning": "Firefox (Camoufox) est maintenu par une organisation tierce. Pour une utilisation en production, veuillez utiliser Chromium."
},
"deleteDialog": {
"title": "Supprimer le profil",
@@ -640,7 +647,9 @@
"productSub": "Product Sub",
"brand": "Marque",
"brandVersion": "Version de la marque",
"proFeature": "Ceci est une fonctionnalité Pro"
"proFeature": "Ceci est une fonctionnalité Pro",
"generateFingerprint": "Générer l'empreinte",
"refreshFingerprint": "Actualiser l'empreinte"
},
"warnings": {
"windowResizeTitle": "Dimensions de fenêtre personnalisées",
@@ -779,7 +788,25 @@
"assignTitle": "Assigner un Groupe d'Extensions",
"assignDescription": "Assigner {{count}} profil(s) sélectionné(s) à un groupe d'extensions.",
"noGroup": "Aucun (Pas de Groupe d'Extensions)",
"assignSuccess": "Groupe d'extensions assigné avec succès"
"assignSuccess": "Groupe d'extensions assigné avec succès",
"editExtension": "Modifier l'extension",
"updateSuccess": "Extension mise à jour avec succès",
"reupload": "Re-télécharger",
"version": "Version",
"author": "Auteur",
"homepage": "Page d'accueil",
"editGroup": "Modifier le groupe",
"editGroupDescription": "Mettez à jour le nom du groupe et gérez les extensions incluses.",
"groupExtensions": "Extensions dans ce groupe",
"noExtensionsInGroup": "Aucune extension ajoutée",
"editExtensionDescription": "Modifier le nom de l'extension, voir les métadonnées ou re-télécharger le fichier d'extension.",
"metadata": "Métadonnées",
"noMetadata": "Aucune métadonnée disponible depuis le manifeste.",
"selectFile": "Choisir un fichier",
"syncEnabled": "Synchronisation activée",
"syncDisabled": "Synchronisation désactivée",
"syncEnableTooltip": "Activer la synchronisation",
"syncDisableTooltip": "Désactiver la synchronisation"
},
"pro": {
"badge": "PRO",
+32 -5
View File
@@ -134,7 +134,9 @@
"title": "詳細設定",
"clearCache": "すべてのバージョンキャッシュをクリア",
"clearCacheDescription": "キャッシュされたすべてのブラウザバージョンデータをクリアし、すべてのブラウザバージョンをソースから更新します。これにより、すべてのブラウザのバージョン情報が強制的に再ダウンロードされます。"
}
},
"disableAutoUpdates": "自動更新を無効にする",
"disableAutoUpdatesDescription": "アプリケーションが自動的に更新を確認・インストールすることを防ぎます。手動でアプリケーションを更新する必要があります。"
},
"header": {
"searchPlaceholder": "プロファイルを検索...",
@@ -189,7 +191,7 @@
},
"createProfile": {
"title": "新しいプロファイルを作成",
"configureTitle": "プロファイルを設定",
"configureTitle": "新しい{{browser}}プロファイルを作成",
"antiDetect": {
"title": "アンチ検出ブラウザ",
"description": "アンチ検出機能を持つブラウザを選択",
@@ -219,7 +221,12 @@
"latestNeedsDownload": "最新バージョン ({{version}}) をダウンロードする必要があります",
"latestAvailable": "最新バージョン ({{version}}) は利用可能です",
"latestDownloading": "バージョン ({{version}}) をダウンロード中..."
}
},
"chromiumLabel": "Chromium",
"chromiumSubtitle": "Wayfern搭載",
"firefoxLabel": "Firefox",
"firefoxSubtitle": "Camoufox搭載",
"camoufoxWarning": "FirefoxCamoufox)はサードパーティの組織によって管理されています。本番環境での使用にはChromiumをご利用ください。"
},
"deleteDialog": {
"title": "プロファイルを削除",
@@ -640,7 +647,9 @@
"productSub": "Product Sub",
"brand": "ブランド",
"brandVersion": "ブランドバージョン",
"proFeature": "これはPro機能です"
"proFeature": "これはPro機能です",
"generateFingerprint": "フィンガープリントを生成",
"refreshFingerprint": "フィンガープリントを更新"
},
"warnings": {
"windowResizeTitle": "カスタムウィンドウサイズ",
@@ -779,7 +788,25 @@
"assignTitle": "拡張機能グループの割り当て",
"assignDescription": "選択した{{count}}件のプロファイルを拡張機能グループに割り当てます。",
"noGroup": "なし(拡張機能グループなし)",
"assignSuccess": "拡張機能グループが正常に割り当てられました"
"assignSuccess": "拡張機能グループが正常に割り当てられました",
"editExtension": "拡張機能を編集",
"updateSuccess": "拡張機能が正常に更新されました",
"reupload": "再アップロード",
"version": "バージョン",
"author": "作者",
"homepage": "ホームページ",
"editGroup": "グループを編集",
"editGroupDescription": "グループ名を更新し、含まれる拡張機能を管理します。",
"groupExtensions": "このグループの拡張機能",
"noExtensionsInGroup": "拡張機能がまだ追加されていません",
"editExtensionDescription": "拡張機能の名前を更新、メタデータを表示、またはファイルを再アップロードします。",
"metadata": "メタデータ",
"noMetadata": "マニフェストからのメタデータはありません。",
"selectFile": "ファイルを選択",
"syncEnabled": "同期が有効",
"syncDisabled": "同期が無効",
"syncEnableTooltip": "同期を有効にする",
"syncDisableTooltip": "同期を無効にする"
},
"pro": {
"badge": "PRO",
+32 -5
View File
@@ -134,7 +134,9 @@
"title": "Avançado",
"clearCache": "Limpar Todo o Cache de Versões",
"clearCacheDescription": "Limpa todos os dados de versões de navegadores em cache e atualiza todas as versões de suas fontes. Isso forçará um novo download das informações de versão para todos os navegadores."
}
},
"disableAutoUpdates": "Desativar Atualizações Automáticas",
"disableAutoUpdatesDescription": "Impede que o aplicativo verifique e instale atualizações automaticamente. Você precisará atualizar o aplicativo manualmente."
},
"header": {
"searchPlaceholder": "Pesquisar perfis...",
@@ -189,7 +191,7 @@
},
"createProfile": {
"title": "Criar Novo Perfil",
"configureTitle": "Configurar Perfil",
"configureTitle": "Criar Novo Perfil de {{browser}}",
"antiDetect": {
"title": "Navegador Anti-Detecção",
"description": "Escolha um navegador com capacidades anti-detecção",
@@ -219,7 +221,12 @@
"latestNeedsDownload": "A versão mais recente ({{version}}) precisa ser baixada",
"latestAvailable": "A versão mais recente ({{version}}) está disponível",
"latestDownloading": "Baixando versão ({{version}})..."
}
},
"chromiumLabel": "Chromium",
"chromiumSubtitle": "Desenvolvido com Wayfern",
"firefoxLabel": "Firefox",
"firefoxSubtitle": "Desenvolvido com Camoufox",
"camoufoxWarning": "O Firefox (Camoufox) é mantido por uma organização terceira. Para uso em produção, utilize o Chromium."
},
"deleteDialog": {
"title": "Excluir Perfil",
@@ -640,7 +647,9 @@
"productSub": "Product Sub",
"brand": "Marca",
"brandVersion": "Versão da Marca",
"proFeature": "Este é um recurso Pro"
"proFeature": "Este é um recurso Pro",
"generateFingerprint": "Gerar Impressão Digital",
"refreshFingerprint": "Atualizar Impressão Digital"
},
"warnings": {
"windowResizeTitle": "Dimensões de janela personalizadas",
@@ -779,7 +788,25 @@
"assignTitle": "Atribuir Grupo de Extensões",
"assignDescription": "Atribuir {{count}} perfil(is) selecionado(s) a um grupo de extensões.",
"noGroup": "Nenhum (Sem Grupo de Extensões)",
"assignSuccess": "Grupo de extensões atribuído com sucesso"
"assignSuccess": "Grupo de extensões atribuído com sucesso",
"editExtension": "Editar extensão",
"updateSuccess": "Extensão atualizada com sucesso",
"reupload": "Re-enviar",
"version": "Versão",
"author": "Autor",
"homepage": "Página inicial",
"editGroup": "Editar grupo",
"editGroupDescription": "Atualize o nome do grupo e gerencie quais extensões estão incluídas.",
"groupExtensions": "Extensões neste grupo",
"noExtensionsInGroup": "Nenhuma extensão adicionada ainda",
"editExtensionDescription": "Atualizar o nome da extensão, ver metadados ou reenviar o arquivo da extensão.",
"metadata": "Metadados",
"noMetadata": "Nenhum metadado disponível do manifesto.",
"selectFile": "Escolher arquivo",
"syncEnabled": "Sincronização ativada",
"syncDisabled": "Sincronização desativada",
"syncEnableTooltip": "Ativar sincronização",
"syncDisableTooltip": "Desativar sincronização"
},
"pro": {
"badge": "PRO",
+32 -5
View File
@@ -134,7 +134,9 @@
"title": "Дополнительно",
"clearCache": "Очистить весь кэш версий",
"clearCacheDescription": "Очищает все кэшированные данные версий браузеров и обновляет все версии из источников. Это принудительно загрузит информацию о версиях для всех браузеров."
}
},
"disableAutoUpdates": "Отключить автоматические обновления",
"disableAutoUpdatesDescription": "Запретить приложению автоматически проверять и устанавливать обновления. Вам нужно будет обновлять приложение вручную."
},
"header": {
"searchPlaceholder": "Поиск профилей...",
@@ -189,7 +191,7 @@
},
"createProfile": {
"title": "Создать новый профиль",
"configureTitle": "Настроить профиль",
"configureTitle": "Создать новый профиль {{browser}}",
"antiDetect": {
"title": "Антидетект браузер",
"description": "Выберите браузер с возможностями защиты от обнаружения",
@@ -219,7 +221,12 @@
"latestNeedsDownload": "Последнюю версию ({{version}}) необходимо скачать",
"latestAvailable": "Последняя версия ({{version}}) доступна",
"latestDownloading": "Загрузка версии ({{version}})..."
}
},
"chromiumLabel": "Chromium",
"chromiumSubtitle": "На базе Wayfern",
"firefoxLabel": "Firefox",
"firefoxSubtitle": "На базе Camoufox",
"camoufoxWarning": "Firefox (Camoufox) поддерживается сторонней организацией. Для промышленного использования используйте Chromium."
},
"deleteDialog": {
"title": "Удалить профиль",
@@ -640,7 +647,9 @@
"productSub": "Product Sub",
"brand": "Бренд",
"brandVersion": "Версия бренда",
"proFeature": "Это функция Pro"
"proFeature": "Это функция Pro",
"generateFingerprint": "Сгенерировать отпечаток",
"refreshFingerprint": "Обновить отпечаток"
},
"warnings": {
"windowResizeTitle": "Пользовательские размеры окна",
@@ -779,7 +788,25 @@
"assignTitle": "Назначить группу расширений",
"assignDescription": "Назначить {{count}} выбранных профилей в группу расширений.",
"noGroup": "Нет (Без группы расширений)",
"assignSuccess": "Группа расширений успешно назначена"
"assignSuccess": "Группа расширений успешно назначена",
"editExtension": "Редактировать расширение",
"updateSuccess": "Расширение успешно обновлено",
"reupload": "Загрузить заново",
"version": "Версия",
"author": "Автор",
"homepage": "Домашняя страница",
"editGroup": "Редактировать группу",
"editGroupDescription": "Обновите название группы и управляйте включёнными расширениями.",
"groupExtensions": "Расширения в этой группе",
"noExtensionsInGroup": "Расширения ещё не добавлены",
"editExtensionDescription": "Обновите имя расширения, просмотрите метаданные или загрузите файл расширения повторно.",
"metadata": "Метаданные",
"noMetadata": "Метаданные из манифеста недоступны.",
"selectFile": "Выбрать файл",
"syncEnabled": "Синхронизация включена",
"syncDisabled": "Синхронизация отключена",
"syncEnableTooltip": "Включить синхронизацию",
"syncDisableTooltip": "Отключить синхронизацию"
},
"pro": {
"badge": "PRO",
+32 -5
View File
@@ -134,7 +134,9 @@
"title": "高级",
"clearCache": "清除所有版本缓存",
"clearCacheDescription": "清除所有缓存的浏览器版本数据并从源刷新所有浏览器版本。这将强制重新下载所有浏览器的版本信息。"
}
},
"disableAutoUpdates": "禁用自动更新",
"disableAutoUpdatesDescription": "阻止应用程序自动检查和安装更新。您需要手动更新应用程序。"
},
"header": {
"searchPlaceholder": "搜索配置文件...",
@@ -189,7 +191,7 @@
},
"createProfile": {
"title": "创建新配置文件",
"configureTitle": "配置配置文件",
"configureTitle": "创建新的 {{browser}} 配置文件",
"antiDetect": {
"title": "防检测浏览器",
"description": "选择具有防检测功能的浏览器",
@@ -219,7 +221,12 @@
"latestNeedsDownload": "最新版本 ({{version}}) 需要下载",
"latestAvailable": "最新版本 ({{version}}) 可用",
"latestDownloading": "正在下载版本 ({{version}})..."
}
},
"chromiumLabel": "Chromium",
"chromiumSubtitle": "由 Wayfern 驱动",
"firefoxLabel": "Firefox",
"firefoxSubtitle": "由 Camoufox 驱动",
"camoufoxWarning": "FirefoxCamoufox)由第三方组织维护。在生产环境中,请使用 Chromium。"
},
"deleteDialog": {
"title": "删除配置文件",
@@ -640,7 +647,9 @@
"productSub": "Product Sub",
"brand": "品牌",
"brandVersion": "品牌版本",
"proFeature": "这是 Pro 功能"
"proFeature": "这是 Pro 功能",
"generateFingerprint": "生成指纹",
"refreshFingerprint": "刷新指纹"
},
"warnings": {
"windowResizeTitle": "自定义窗口尺寸",
@@ -779,7 +788,25 @@
"assignTitle": "分配扩展程序组",
"assignDescription": "将 {{count}} 个选定的配置文件分配到扩展程序组。",
"noGroup": "无(不使用扩展程序组)",
"assignSuccess": "扩展程序组分配成功"
"assignSuccess": "扩展程序组分配成功",
"editExtension": "编辑扩展",
"updateSuccess": "扩展更新成功",
"reupload": "重新上传",
"version": "版本",
"author": "作者",
"homepage": "主页",
"editGroup": "编辑分组",
"editGroupDescription": "更新分组名称并管理包含的扩展。",
"groupExtensions": "此分组中的扩展",
"noExtensionsInGroup": "尚未添加扩展",
"editExtensionDescription": "更新扩展名称、查看元数据或重新上传扩展文件。",
"metadata": "元数据",
"noMetadata": "清单中没有可用的元数据。",
"selectFile": "选择文件",
"syncEnabled": "同步已启用",
"syncDisabled": "同步已禁用",
"syncEnableTooltip": "启用同步",
"syncDisableTooltip": "禁用同步"
},
"pro": {
"badge": "PRO",
+31 -17
View File
@@ -48,12 +48,27 @@ interface VersionUpdateToastProps extends BaseToastProps {
};
}
interface SyncProgressToastProps extends BaseToastProps {
type: "sync-progress";
progress?: {
completed_files: number;
total_files: number;
completed_bytes: number;
total_bytes: number;
speed_bytes_per_sec: number;
eta_seconds: number;
failed_count: number;
phase: string;
};
}
type ToastProps =
| SuccessToastProps
| ErrorToastProps
| DownloadToastProps
| LoadingToastProps
| VersionUpdateToastProps;
| VersionUpdateToastProps
| SyncProgressToastProps;
export function showToast(props: ToastProps & { id?: string }) {
const toastId = props.id ?? `toast-${props.type}-${Date.now()}`;
@@ -85,6 +100,9 @@ export function showToast(props: ToastProps & { id?: string }) {
case "version-update":
duration = 15000;
break;
case "sync-progress":
duration = Number.POSITIVE_INFINITY;
break;
default:
duration = 5000;
}
@@ -232,28 +250,24 @@ export function dismissToast(id: string) {
sonnerToast.dismiss(id);
}
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
const i = Math.min(
Math.floor(Math.log(bytes) / Math.log(1024)),
units.length - 1,
);
const value = bytes / 1024 ** i;
return `${i === 0 ? value : value.toFixed(1)} ${units[i]}`;
}
export function showSyncProgressToast(
profileName: string,
totalFiles: number,
totalBytes: number,
progress: {
completed_files: number;
total_files: number;
completed_bytes: number;
total_bytes: number;
speed_bytes_per_sec: number;
eta_seconds: number;
failed_count: number;
phase: string;
},
options?: { id?: string },
) {
const description = `${totalFiles} files (${formatBytes(totalBytes)})`;
return showToast({
type: "loading",
type: "sync-progress",
title: `Syncing profile '${profileName}'...`,
description,
progress,
id: options?.id,
duration: Number.POSITIVE_INFINITY,
onCancel: () => {
+6
View File
@@ -47,6 +47,10 @@ export interface Extension {
updated_at: number;
sync_enabled?: boolean;
last_sync?: number;
version?: string;
description?: string;
author?: string;
homepage_url?: string;
}
export interface ExtensionGroup {
@@ -127,7 +131,9 @@ export interface StoredProxy {
is_cloud_derived?: boolean;
geo_country?: string;
geo_state?: string;
geo_region?: string;
geo_city?: string;
geo_isp?: string;
}
export interface LocationItem {