mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-06 23:13:58 +02:00
refactor: cleanup and decouple
This commit is contained in:
+66
-11
@@ -3,7 +3,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrent } from "@tauri-apps/plugin-deep-link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
|
||||
import { CommercialTrialModal } from "@/components/commercial-trial-modal";
|
||||
import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
|
||||
@@ -35,8 +35,14 @@ import { useProfileEvents } from "@/hooks/use-profile-events";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { useUpdateNotifications } from "@/hooks/use-update-notifications";
|
||||
import { useVersionUpdater } from "@/hooks/use-version-updater";
|
||||
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
|
||||
import { showErrorToast, showSuccessToast, showToast } from "@/lib/toast-utils";
|
||||
import {
|
||||
dismissToast,
|
||||
showErrorToast,
|
||||
showSuccessToast,
|
||||
showToast,
|
||||
} from "@/lib/toast-utils";
|
||||
import type { BrowserProfile, CamoufoxConfig, WayfernConfig } from "@/types";
|
||||
|
||||
type BrowserTypeString =
|
||||
@@ -77,6 +83,8 @@ export default function Home() {
|
||||
error: proxiesError,
|
||||
} = useProxyEvents();
|
||||
|
||||
const { vpnConfigs } = useVpnEvents();
|
||||
|
||||
// Wayfern terms and commercial trial hooks
|
||||
const {
|
||||
termsAccepted,
|
||||
@@ -141,6 +149,8 @@ export default function Home() {
|
||||
const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } =
|
||||
usePermissions();
|
||||
|
||||
const userInitiatedSyncIds = useRef<Set<string>>(new Set());
|
||||
|
||||
const handleSelectGroup = useCallback((groupId: string) => {
|
||||
setSelectedGroupId(groupId);
|
||||
setSelectedProfiles([]);
|
||||
@@ -448,6 +458,7 @@ export default function Home() {
|
||||
version: string;
|
||||
releaseType: string;
|
||||
proxyId?: string;
|
||||
vpnId?: string;
|
||||
camoufoxConfig?: CamoufoxConfig;
|
||||
wayfernConfig?: WayfernConfig;
|
||||
groupId?: string;
|
||||
@@ -459,6 +470,7 @@ export default function Home() {
|
||||
version: profileData.version,
|
||||
releaseType: profileData.releaseType,
|
||||
proxyId: profileData.proxyId,
|
||||
vpnId: profileData.vpnId,
|
||||
camoufoxConfig: profileData.camoufoxConfig,
|
||||
wayfernConfig: profileData.wayfernConfig,
|
||||
groupId:
|
||||
@@ -676,18 +688,19 @@ export default function Home() {
|
||||
const handleToggleProfileSync = useCallback(
|
||||
async (profile: BrowserProfile) => {
|
||||
try {
|
||||
const enabling = !profile.sync_enabled;
|
||||
await invoke("set_profile_sync_enabled", {
|
||||
profileId: profile.id,
|
||||
enabled: !profile.sync_enabled,
|
||||
enabled: enabling,
|
||||
});
|
||||
if (enabling) {
|
||||
userInitiatedSyncIds.current.add(profile.id);
|
||||
}
|
||||
showSuccessToast(enabling ? "Sync enabled" : "Sync disabled", {
|
||||
description: enabling
|
||||
? "Profile sync has been enabled"
|
||||
: "Profile sync has been disabled",
|
||||
});
|
||||
showSuccessToast(
|
||||
profile.sync_enabled ? "Sync disabled" : "Sync enabled",
|
||||
{
|
||||
description: profile.sync_enabled
|
||||
? "Profile sync has been disabled"
|
||||
: "Profile sync has been enabled",
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle sync:", error);
|
||||
showErrorToast("Failed to update sync settings");
|
||||
@@ -696,6 +709,47 @@ export default function Home() {
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
(async () => {
|
||||
try {
|
||||
unlisten = await listen<{ profile_id: string; status: string }>(
|
||||
"profile-sync-status",
|
||||
(event) => {
|
||||
const { profile_id, status } = event.payload;
|
||||
if (!userInitiatedSyncIds.current.has(profile_id)) return;
|
||||
|
||||
const toastId = `sync-${profile_id}`;
|
||||
const profile = profiles.find((p) => p.id === profile_id);
|
||||
const name = profile?.name ?? "Unknown";
|
||||
|
||||
if (status === "syncing") {
|
||||
showToast({
|
||||
type: "loading",
|
||||
title: `Syncing profile '${name}'...`,
|
||||
id: toastId,
|
||||
duration: 30000,
|
||||
});
|
||||
} else if (status === "synced") {
|
||||
dismissToast(toastId);
|
||||
showSuccessToast(`Profile '${name}' synced successfully`);
|
||||
userInitiatedSyncIds.current.delete(profile_id);
|
||||
} else if (status === "error") {
|
||||
dismissToast(toastId);
|
||||
showErrorToast(`Failed to sync profile '${name}'`);
|
||||
userInitiatedSyncIds.current.delete(profile_id);
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to listen for sync status events:", error);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, [profiles]);
|
||||
|
||||
useEffect(() => {
|
||||
// Check for startup default browser prompt
|
||||
void checkStartupPrompt();
|
||||
@@ -1035,6 +1089,7 @@ export default function Home() {
|
||||
onAssignmentComplete={handleProxyAssignmentComplete}
|
||||
profiles={profiles}
|
||||
storedProxies={storedProxies}
|
||||
vpnConfigs={vpnConfigs}
|
||||
/>
|
||||
|
||||
<CookieCopyDialog
|
||||
|
||||
@@ -20,7 +20,9 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
@@ -29,6 +31,7 @@ import { WayfernConfigForm } from "@/components/wayfern-config-form";
|
||||
|
||||
import { useBrowserDownload } from "@/hooks/use-browser-download";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||
import { getBrowserIcon } from "@/lib/browser-utils";
|
||||
import type {
|
||||
BrowserReleaseTypes,
|
||||
@@ -66,6 +69,7 @@ interface CreateProfileDialogProps {
|
||||
version: string;
|
||||
releaseType: string;
|
||||
proxyId?: string;
|
||||
vpnId?: string;
|
||||
camoufoxConfig?: CamoufoxConfig;
|
||||
wayfernConfig?: WayfernConfig;
|
||||
groupId?: string;
|
||||
@@ -155,6 +159,7 @@ export function CreateProfileDialog({
|
||||
|
||||
const [supportedBrowsers, setSupportedBrowsers] = useState<string[]>([]);
|
||||
const { storedProxies } = useProxyEvents();
|
||||
const { vpnConfigs } = useVpnEvents();
|
||||
const [showProxyForm, setShowProxyForm] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [releaseTypes, setReleaseTypes] = useState<BrowserReleaseTypes>();
|
||||
@@ -347,6 +352,11 @@ export function CreateProfileDialog({
|
||||
if (!profileName.trim()) return;
|
||||
|
||||
setIsCreating(true);
|
||||
|
||||
const isVpnSelection = selectedProxyId?.startsWith("vpn-") ?? false;
|
||||
const resolvedProxyId = isVpnSelection ? undefined : selectedProxyId;
|
||||
const resolvedVpnId =
|
||||
isVpnSelection && selectedProxyId ? selectedProxyId.slice(4) : undefined;
|
||||
try {
|
||||
if (activeTab === "anti-detect") {
|
||||
// Anti-detect browser - check if Wayfern or Camoufox is selected
|
||||
@@ -365,7 +375,8 @@ export function CreateProfileDialog({
|
||||
browserStr: "wayfern" as BrowserTypeString,
|
||||
version: bestWayfernVersion.version,
|
||||
releaseType: bestWayfernVersion.releaseType,
|
||||
proxyId: selectedProxyId,
|
||||
proxyId: resolvedProxyId,
|
||||
vpnId: resolvedVpnId,
|
||||
wayfernConfig: finalWayfernConfig,
|
||||
groupId:
|
||||
selectedGroupId !== "default" ? selectedGroupId : undefined,
|
||||
@@ -387,7 +398,8 @@ export function CreateProfileDialog({
|
||||
browserStr: "camoufox" as BrowserTypeString,
|
||||
version: bestCamoufoxVersion.version,
|
||||
releaseType: bestCamoufoxVersion.releaseType,
|
||||
proxyId: selectedProxyId,
|
||||
proxyId: resolvedProxyId,
|
||||
vpnId: resolvedVpnId,
|
||||
camoufoxConfig: finalCamoufoxConfig,
|
||||
groupId:
|
||||
selectedGroupId !== "default" ? selectedGroupId : undefined,
|
||||
@@ -946,10 +958,10 @@ export function CreateProfileDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Proxy Selection - Always visible */}
|
||||
{/* Proxy / VPN Selection - Always visible */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Proxy</Label>
|
||||
<Label>Proxy / VPN</Label>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -959,7 +971,7 @@ export function CreateProfileDialog({
|
||||
<GoPlus className="mr-1 w-3 h-3" /> Add Proxy
|
||||
</RippleButton>
|
||||
</div>
|
||||
{storedProxies.length > 0 ? (
|
||||
{storedProxies.length > 0 || vpnConfigs.length > 0 ? (
|
||||
<Select
|
||||
value={selectedProxyId || "none"}
|
||||
onValueChange={(value) =>
|
||||
@@ -969,21 +981,47 @@ export function CreateProfileDialog({
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No proxy" />
|
||||
<SelectValue placeholder="No proxy / VPN" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No proxy</SelectItem>
|
||||
{storedProxies.map((proxy) => (
|
||||
<SelectItem key={proxy.id} value={proxy.id}>
|
||||
{proxy.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="none">
|
||||
No proxy / VPN
|
||||
</SelectItem>
|
||||
{storedProxies.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Proxies</SelectLabel>
|
||||
{storedProxies.map((proxy) => (
|
||||
<SelectItem
|
||||
key={proxy.id}
|
||||
value={proxy.id}
|
||||
>
|
||||
{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>
|
||||
) : (
|
||||
<div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground">
|
||||
No proxies available. Add one to route this
|
||||
profile's traffic.
|
||||
No proxies or VPNs available. Add one to route
|
||||
this profile's traffic.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1107,10 +1145,10 @@ export function CreateProfileDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Proxy Selection - Always visible */}
|
||||
{/* Proxy / VPN Selection - Always visible */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Proxy</Label>
|
||||
<Label>Proxy / VPN</Label>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -1120,7 +1158,7 @@ export function CreateProfileDialog({
|
||||
<GoPlus className="mr-1 w-3 h-3" /> Add Proxy
|
||||
</RippleButton>
|
||||
</div>
|
||||
{storedProxies.length > 0 ? (
|
||||
{storedProxies.length > 0 || vpnConfigs.length > 0 ? (
|
||||
<Select
|
||||
value={selectedProxyId || "none"}
|
||||
onValueChange={(value) =>
|
||||
@@ -1130,21 +1168,47 @@ export function CreateProfileDialog({
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No proxy" />
|
||||
<SelectValue placeholder="No proxy / VPN" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No proxy</SelectItem>
|
||||
{storedProxies.map((proxy) => (
|
||||
<SelectItem key={proxy.id} value={proxy.id}>
|
||||
{proxy.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="none">
|
||||
No proxy / VPN
|
||||
</SelectItem>
|
||||
{storedProxies.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Proxies</SelectLabel>
|
||||
{storedProxies.map((proxy) => (
|
||||
<SelectItem
|
||||
key={proxy.id}
|
||||
value={proxy.id}
|
||||
>
|
||||
{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>
|
||||
) : (
|
||||
<div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground">
|
||||
No proxies available. Add one to route this
|
||||
profile's traffic.
|
||||
No proxies or VPNs available. Add one to route
|
||||
this profile's traffic.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
import { useBrowserState } from "@/hooks/use-browser-state";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { useTableSorting } from "@/hooks/use-table-sorting";
|
||||
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||
import {
|
||||
getBrowserDisplayName,
|
||||
getBrowserIcon,
|
||||
@@ -81,6 +82,7 @@ import type {
|
||||
ProxyCheckResult,
|
||||
StoredProxy,
|
||||
TrafficSnapshot,
|
||||
VpnConfig,
|
||||
} from "@/types";
|
||||
import { BandwidthMiniChart } from "./bandwidth-mini-chart";
|
||||
import {
|
||||
@@ -137,6 +139,14 @@ type TableMeta = {
|
||||
checkingProfileId: string | null;
|
||||
proxyCheckResults: Record<string, ProxyCheckResult>;
|
||||
|
||||
// VPN selector state
|
||||
vpnConfigs: VpnConfig[];
|
||||
vpnOverrides: Record<string, string | null>;
|
||||
handleVpnSelection: (
|
||||
profileId: string,
|
||||
vpnId: string | null,
|
||||
) => void | Promise<void>;
|
||||
|
||||
// Selection helpers
|
||||
isProfileSelected: (id: string) => boolean;
|
||||
handleToggleAll: (checked: boolean) => void;
|
||||
@@ -187,6 +197,53 @@ type TableMeta = {
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
type SyncStatusDot = { color: string; tooltip: string; animate: boolean };
|
||||
|
||||
function getProfileSyncStatusDot(
|
||||
profile: BrowserProfile,
|
||||
liveStatus:
|
||||
| "syncing"
|
||||
| "waiting"
|
||||
| "synced"
|
||||
| "error"
|
||||
| "disabled"
|
||||
| undefined,
|
||||
): SyncStatusDot | null {
|
||||
const status = liveStatus ?? (profile.sync_enabled ? "synced" : "disabled");
|
||||
|
||||
switch (status) {
|
||||
case "syncing":
|
||||
return { color: "bg-yellow-500", tooltip: "Syncing...", animate: true };
|
||||
case "waiting":
|
||||
return {
|
||||
color: "bg-yellow-500",
|
||||
tooltip: "Waiting to sync",
|
||||
animate: false,
|
||||
};
|
||||
case "synced":
|
||||
return {
|
||||
color: "bg-green-500",
|
||||
tooltip: profile.last_sync
|
||||
? `Synced ${new Date(profile.last_sync * 1000).toLocaleString()}`
|
||||
: "Synced",
|
||||
animate: false,
|
||||
};
|
||||
case "error":
|
||||
return { color: "bg-red-500", tooltip: "Sync error", animate: false };
|
||||
case "disabled":
|
||||
if (profile.last_sync) {
|
||||
return {
|
||||
color: "bg-gray-400",
|
||||
tooltip: `Sync disabled, last sync ${formatRelativeTime(profile.last_sync)}`,
|
||||
animate: false,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const TagsCell = React.memo<{
|
||||
profile: BrowserProfile;
|
||||
isDisabled: boolean;
|
||||
@@ -801,10 +858,14 @@ export function ProfilesDataTable({
|
||||
);
|
||||
|
||||
const { storedProxies } = useProxyEvents();
|
||||
const { vpnConfigs } = useVpnEvents();
|
||||
|
||||
const [proxyOverrides, setProxyOverrides] = React.useState<
|
||||
Record<string, string | null>
|
||||
>({});
|
||||
const [vpnOverrides, setVpnOverrides] = React.useState<
|
||||
Record<string, string | null>
|
||||
>({});
|
||||
const [showCheckboxes, setShowCheckboxes] = React.useState(false);
|
||||
const [tagsOverrides, setTagsOverrides] = React.useState<
|
||||
Record<string, string[]>
|
||||
@@ -903,7 +964,7 @@ export function ProfilesDataTable({
|
||||
proxyId,
|
||||
});
|
||||
setProxyOverrides((prev) => ({ ...prev, [profileId]: proxyId }));
|
||||
// Notify other parts of the app so usage counts and lists refresh
|
||||
setVpnOverrides((prev) => ({ ...prev, [profileId]: null }));
|
||||
await emit("profile-updated");
|
||||
} catch (error) {
|
||||
console.error("Failed to update proxy settings:", error);
|
||||
@@ -914,6 +975,25 @@ export function ProfilesDataTable({
|
||||
[],
|
||||
);
|
||||
|
||||
const handleVpnSelection = React.useCallback(
|
||||
async (profileId: string, vpnId: string | null) => {
|
||||
try {
|
||||
await invoke("update_profile_vpn", {
|
||||
profileId,
|
||||
vpnId,
|
||||
});
|
||||
setVpnOverrides((prev) => ({ ...prev, [profileId]: vpnId }));
|
||||
setProxyOverrides((prev) => ({ ...prev, [profileId]: null }));
|
||||
await emit("profile-updated");
|
||||
} catch (error) {
|
||||
console.error("Failed to update VPN settings:", error);
|
||||
} finally {
|
||||
setOpenProxySelectorFor(null);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCreateCountryProxy = React.useCallback(
|
||||
async (profileId: string, country: LocationItem) => {
|
||||
try {
|
||||
@@ -1347,6 +1427,11 @@ export function ProfilesDataTable({
|
||||
checkingProfileId,
|
||||
proxyCheckResults,
|
||||
|
||||
// VPN selector state
|
||||
vpnConfigs,
|
||||
vpnOverrides,
|
||||
handleVpnSelection,
|
||||
|
||||
// Selection helpers
|
||||
isProfileSelected: (id: string) => selectedProfiles.includes(id),
|
||||
handleToggleAll,
|
||||
@@ -1415,6 +1500,9 @@ export function ProfilesDataTable({
|
||||
handleProxySelection,
|
||||
checkingProfileId,
|
||||
proxyCheckResults,
|
||||
vpnConfigs,
|
||||
vpnOverrides,
|
||||
handleVpnSelection,
|
||||
handleToggleAll,
|
||||
handleCheckboxChange,
|
||||
handleIconClick,
|
||||
@@ -1891,7 +1979,7 @@ export function ProfilesDataTable({
|
||||
},
|
||||
{
|
||||
id: "proxy",
|
||||
header: "Proxy",
|
||||
header: "Proxy / VPN",
|
||||
cell: ({ row, table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const profile = row.original;
|
||||
@@ -1908,28 +1996,44 @@ export function ProfilesDataTable({
|
||||
isBrowserUpdating ||
|
||||
isCrossOs;
|
||||
|
||||
const hasOverride = Object.hasOwn(meta.proxyOverrides, profile.id);
|
||||
const effectiveProxyId = hasOverride
|
||||
const hasProxyOverride = Object.hasOwn(
|
||||
meta.proxyOverrides,
|
||||
profile.id,
|
||||
);
|
||||
const effectiveProxyId = hasProxyOverride
|
||||
? meta.proxyOverrides[profile.id]
|
||||
: (profile.proxy_id ?? null);
|
||||
const effectiveProxy = effectiveProxyId
|
||||
? (meta.storedProxies.find((p) => p.id === effectiveProxyId) ??
|
||||
null)
|
||||
: null;
|
||||
const displayName = effectiveProxy
|
||||
? effectiveProxy.name
|
||||
: "Not Selected";
|
||||
const profileHasProxy = Boolean(effectiveProxy);
|
||||
const tooltipText =
|
||||
profileHasProxy && effectiveProxy ? effectiveProxy.name : null;
|
||||
|
||||
const hasVpnOverride = Object.hasOwn(meta.vpnOverrides, profile.id);
|
||||
const effectiveVpnId = hasVpnOverride
|
||||
? meta.vpnOverrides[profile.id]
|
||||
: (profile.vpn_id ?? null);
|
||||
const effectiveVpn = effectiveVpnId
|
||||
? (meta.vpnConfigs.find((v) => v.id === effectiveVpnId) ?? null)
|
||||
: null;
|
||||
|
||||
const hasAssignment = Boolean(effectiveProxy || effectiveVpn);
|
||||
const displayName = effectiveVpn
|
||||
? effectiveVpn.name
|
||||
: effectiveProxy
|
||||
? effectiveProxy.name
|
||||
: "Not Selected";
|
||||
const vpnBadge = effectiveVpn
|
||||
? effectiveVpn.vpn_type === "WireGuard"
|
||||
? "WG"
|
||||
: "OVPN"
|
||||
: null;
|
||||
const tooltipText = hasAssignment ? displayName : null;
|
||||
const isSelectorOpen = meta.openProxySelectorFor === profile.id;
|
||||
const selectedId = effectiveVpnId ?? effectiveProxyId ?? null;
|
||||
|
||||
// When profile is running, show bandwidth chart instead of proxy selector
|
||||
if (isRunning && meta.trafficSnapshots) {
|
||||
// Find the traffic snapshot for this profile by matching profile_id
|
||||
const snapshot = meta.trafficSnapshots[profile.id];
|
||||
// Only use recent_bandwidth (last 60 seconds) - minimal data needed for mini chart
|
||||
// Create a new array reference to ensure React detects changes
|
||||
const bandwidthData = snapshot?.recent_bandwidth
|
||||
? [...snapshot.recent_bandwidth]
|
||||
: [];
|
||||
@@ -1966,13 +2070,21 @@ export function ProfilesDataTable({
|
||||
: "cursor-pointer hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
{vpnBadge && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 py-0 leading-tight"
|
||||
>
|
||||
{vpnBadge}
|
||||
</Badge>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
!profileHasProxy && "text-muted-foreground",
|
||||
!hasAssignment && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{profileHasProxy
|
||||
{hasAssignment
|
||||
? trimName(displayName, 10)
|
||||
: displayName}
|
||||
</span>
|
||||
@@ -1994,8 +2106,8 @@ export function ProfilesDataTable({
|
||||
<CommandInput
|
||||
placeholder={
|
||||
meta.canCreateLocationProxy
|
||||
? "Search proxies or countries..."
|
||||
: "Search proxies..."
|
||||
? "Search proxies, VPNs, or countries..."
|
||||
: "Search proxies or VPNs..."
|
||||
}
|
||||
onFocus={() => {
|
||||
if (meta.canCreateLocationProxy)
|
||||
@@ -2003,7 +2115,7 @@ export function ProfilesDataTable({
|
||||
}}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No proxies found.</CommandEmpty>
|
||||
<CommandEmpty>No proxies or VPNs found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="__none__"
|
||||
@@ -2014,12 +2126,12 @@ export function ProfilesDataTable({
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
effectiveProxyId === null
|
||||
selectedId === null
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
No Proxy
|
||||
None
|
||||
</CommandItem>
|
||||
{meta.storedProxies.map((proxy) => (
|
||||
<CommandItem
|
||||
@@ -2035,7 +2147,7 @@ export function ProfilesDataTable({
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
effectiveProxyId === proxy.id
|
||||
effectiveProxyId === proxy.id && !effectiveVpn
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
@@ -2044,6 +2156,38 @@ export function ProfilesDataTable({
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
{meta.vpnConfigs.length > 0 && (
|
||||
<CommandGroup heading="VPNs">
|
||||
{meta.vpnConfigs.map((vpn) => (
|
||||
<CommandItem
|
||||
key={vpn.id}
|
||||
value={`vpn-${vpn.name}`}
|
||||
onSelect={() =>
|
||||
void meta.handleVpnSelection(
|
||||
profile.id,
|
||||
vpn.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
effectiveVpnId === 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>
|
||||
)}
|
||||
{meta.canCreateLocationProxy &&
|
||||
meta.countries.length > 0 && (
|
||||
<CommandGroup heading="Create by country">
|
||||
@@ -2078,7 +2222,7 @@ export function ProfilesDataTable({
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
{profileHasProxy && effectiveProxy && !isDisabled && (
|
||||
{effectiveProxy && !effectiveVpn && !isDisabled && (
|
||||
<ProxyCheckButton
|
||||
proxy={effectiveProxy}
|
||||
profileId={profile.id}
|
||||
@@ -2107,26 +2251,32 @@ export function ProfilesDataTable({
|
||||
id: "sync",
|
||||
header: "",
|
||||
size: 24,
|
||||
cell: ({ row }) => {
|
||||
cell: ({ row, table }) => {
|
||||
const profile = row.original;
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const liveStatus = meta.syncStatuses[profile.id] as
|
||||
| "syncing"
|
||||
| "waiting"
|
||||
| "synced"
|
||||
| "error"
|
||||
| "disabled"
|
||||
| undefined;
|
||||
|
||||
if (!profile.sync_enabled && profile.last_sync) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex justify-center items-center w-3 h-3">
|
||||
<span className="w-2 h-2 rounded-full bg-orange-500" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Sync is disabled, last sync{" "}
|
||||
{formatRelativeTime(profile.last_sync)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
const dot = getProfileSyncStatusDot(profile, liveStatus);
|
||||
if (!dot) return null;
|
||||
|
||||
return null;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex justify-center items-center w-3 h-3">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${dot.color}${dot.animate ? " animate-pulse" : ""}`}
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{dot.tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ import { emit } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -17,11 +18,13 @@ import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { BrowserProfile, StoredProxy } from "@/types";
|
||||
import type { BrowserProfile, StoredProxy, VpnConfig } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface ProxyAssignmentDialogProps {
|
||||
@@ -31,6 +34,7 @@ interface ProxyAssignmentDialogProps {
|
||||
onAssignmentComplete: () => void;
|
||||
profiles?: BrowserProfile[];
|
||||
storedProxies?: StoredProxy[];
|
||||
vpnConfigs?: VpnConfig[];
|
||||
}
|
||||
|
||||
export function ProxyAssignmentDialog({
|
||||
@@ -40,11 +44,28 @@ export function ProxyAssignmentDialog({
|
||||
onAssignmentComplete,
|
||||
profiles = [],
|
||||
storedProxies = [],
|
||||
vpnConfigs = [],
|
||||
}: ProxyAssignmentDialogProps) {
|
||||
const [selectedProxyId, setSelectedProxyId] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [selectionType, setSelectionType] = useState<"none" | "proxy" | "vpn">(
|
||||
"none",
|
||||
);
|
||||
const [isAssigning, setIsAssigning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleValueChange = useCallback((value: string) => {
|
||||
if (value === "none") {
|
||||
setSelectedId(null);
|
||||
setSelectionType("none");
|
||||
} else if (value.startsWith("vpn-")) {
|
||||
setSelectedId(value.slice(4));
|
||||
setSelectionType("vpn");
|
||||
} else {
|
||||
setSelectedId(value);
|
||||
setSelectionType("proxy");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAssign = useCallback(async () => {
|
||||
setIsAssigning(true);
|
||||
setError(null);
|
||||
@@ -60,24 +81,29 @@ export function ProxyAssignmentDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
// Update each profile's proxy sequentially to avoid file locking issues
|
||||
for (const profileId of validProfiles) {
|
||||
await invoke("update_profile_proxy", {
|
||||
profileId,
|
||||
proxyId: selectedProxyId,
|
||||
});
|
||||
if (selectionType === "vpn") {
|
||||
await invoke("update_profile_vpn", {
|
||||
profileId,
|
||||
vpnId: selectedId,
|
||||
});
|
||||
} else {
|
||||
await invoke("update_profile_proxy", {
|
||||
profileId,
|
||||
proxyId: selectionType === "proxy" ? selectedId : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Notify other parts of the app so usage counts and lists refresh
|
||||
await emit("profile-updated");
|
||||
onAssignmentComplete();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to assign proxies to profiles:", err);
|
||||
console.error("Failed to assign proxy/VPN to profiles:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to assign proxies to profiles";
|
||||
: "Failed to assign proxy/VPN to profiles";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
@@ -85,7 +111,8 @@ export function ProxyAssignmentDialog({
|
||||
}
|
||||
}, [
|
||||
selectedProfiles,
|
||||
selectedProxyId,
|
||||
selectedId,
|
||||
selectionType,
|
||||
profiles,
|
||||
onAssignmentComplete,
|
||||
onClose,
|
||||
@@ -93,18 +120,27 @@ export function ProxyAssignmentDialog({
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSelectedProxyId(null);
|
||||
setSelectedId(null);
|
||||
setSelectionType("none");
|
||||
setError(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const selectValue =
|
||||
selectionType === "none"
|
||||
? "none"
|
||||
: selectionType === "vpn"
|
||||
? `vpn-${selectedId}`
|
||||
: (selectedId ?? "none");
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Assign Proxy</DialogTitle>
|
||||
<DialogTitle>Assign Proxy / VPN</DialogTitle>
|
||||
<DialogDescription>
|
||||
Assign a proxy to {selectedProfiles.length} selected profile(s).
|
||||
Assign a proxy or VPN to {selectedProfiles.length} selected
|
||||
profile(s).
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -120,7 +156,7 @@ export function ProxyAssignmentDialog({
|
||||
const displayName = profile ? profile.name : profileId;
|
||||
return (
|
||||
<li key={profileId} className="truncate">
|
||||
• {displayName}
|
||||
• {displayName}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
@@ -129,24 +165,42 @@ export function ProxyAssignmentDialog({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="proxy-select">Assign Proxy:</Label>
|
||||
<Select
|
||||
value={selectedProxyId || "none"}
|
||||
onValueChange={(value) => {
|
||||
setSelectedProxyId(value === "none" ? null : value);
|
||||
}}
|
||||
>
|
||||
<Label htmlFor="proxy-vpn-select">Assign Proxy / VPN:</Label>
|
||||
<Select value={selectValue} onValueChange={handleValueChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a proxy" />
|
||||
<SelectValue placeholder="Select a proxy or VPN" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No Proxy</SelectItem>
|
||||
{storedProxies.map((proxy) => (
|
||||
<SelectItem key={proxy.id} value={proxy.id}>
|
||||
{proxy.name}
|
||||
{proxy.is_cloud_managed ? " (Included)" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
<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"
|
||||
>
|
||||
{vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"}
|
||||
</Badge>
|
||||
{vpn.name}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { LuShield, LuUpload } from "react-icons/lu";
|
||||
import { LuUpload } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import {
|
||||
@@ -22,8 +22,6 @@ import type {
|
||||
ParsedProxyLine,
|
||||
ProxyImportResult,
|
||||
ProxyParseResult,
|
||||
VpnImportResult,
|
||||
VpnType,
|
||||
} from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
@@ -32,13 +30,7 @@ interface ProxyImportDialogProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type ImportStep =
|
||||
| "dropzone"
|
||||
| "preview"
|
||||
| "ambiguous"
|
||||
| "result"
|
||||
| "vpn-preview"
|
||||
| "vpn-result";
|
||||
type ImportStep = "dropzone" | "preview" | "ambiguous" | "result";
|
||||
|
||||
interface AmbiguousProxy {
|
||||
line: string;
|
||||
@@ -46,13 +38,6 @@ interface AmbiguousProxy {
|
||||
selectedFormat?: string;
|
||||
}
|
||||
|
||||
interface VpnPreviewData {
|
||||
content: string;
|
||||
filename: string;
|
||||
detectedType: VpnType | null;
|
||||
endpoint: string | null;
|
||||
}
|
||||
|
||||
export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
const [step, setStep] = useState<ImportStep>("dropzone");
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
@@ -68,11 +53,6 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [namePrefix, setNamePrefix] = useState("Imported");
|
||||
// VPN import state
|
||||
const [vpnPreview, setVpnPreview] = useState<VpnPreviewData | null>(null);
|
||||
const [vpnName, setVpnName] = useState("");
|
||||
const [vpnImportResult, setVpnImportResult] =
|
||||
useState<VpnImportResult | null>(null);
|
||||
|
||||
const os = getCurrentOS();
|
||||
const modKey = os === "macos" ? "⌘" : "Ctrl";
|
||||
@@ -86,76 +66,11 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
setImportResult(null);
|
||||
setIsImporting(false);
|
||||
setNamePrefix("Imported");
|
||||
// Reset VPN state
|
||||
setVpnPreview(null);
|
||||
setVpnName("");
|
||||
setVpnImportResult(null);
|
||||
}, []);
|
||||
|
||||
// Detect VPN type from content
|
||||
const detectVpnType = useCallback(
|
||||
(
|
||||
content: string,
|
||||
filename: string,
|
||||
): { isVpn: boolean; type: VpnType | null; endpoint: string | null } => {
|
||||
const lowerFilename = filename.toLowerCase();
|
||||
|
||||
// Check for WireGuard config
|
||||
if (
|
||||
lowerFilename.endsWith(".conf") &&
|
||||
content.includes("[Interface]") &&
|
||||
content.includes("[Peer]")
|
||||
) {
|
||||
// Extract endpoint from WireGuard config
|
||||
const endpointMatch = content.match(/Endpoint\s*=\s*([^\s\n]+)/i);
|
||||
return {
|
||||
isVpn: true,
|
||||
type: "WireGuard",
|
||||
endpoint: endpointMatch ? endpointMatch[1] : null,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for OpenVPN config
|
||||
if (
|
||||
lowerFilename.endsWith(".ovpn") ||
|
||||
(content.includes("remote ") &&
|
||||
(content.includes("client") || content.includes("dev tun")))
|
||||
) {
|
||||
// Extract remote from OpenVPN config
|
||||
const remoteMatch = content.match(/remote\s+(\S+)(?:\s+(\d+))?/i);
|
||||
const endpoint = remoteMatch
|
||||
? `${remoteMatch[1]}${remoteMatch[2] ? `:${remoteMatch[2]}` : ""}`
|
||||
: null;
|
||||
return { isVpn: true, type: "OpenVPN", endpoint };
|
||||
}
|
||||
|
||||
return { isVpn: false, type: null, endpoint: null };
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const processContent = useCallback(
|
||||
async (content: string, isJson: boolean, filename: string = "") => {
|
||||
async (content: string, isJson: boolean, _filename: string = "") => {
|
||||
try {
|
||||
// Check if it's a VPN config
|
||||
const vpnDetection = detectVpnType(content, filename);
|
||||
if (vpnDetection.isVpn) {
|
||||
setVpnPreview({
|
||||
content,
|
||||
filename,
|
||||
detectedType: vpnDetection.type,
|
||||
endpoint: vpnDetection.endpoint,
|
||||
});
|
||||
// Generate default name from filename
|
||||
const baseName = filename
|
||||
.replace(/\.(conf|ovpn)$/i, "")
|
||||
.replace(/_/g, " ")
|
||||
.replace(/-/g, " ");
|
||||
setVpnName(baseName || `${vpnDetection.type} VPN`);
|
||||
setStep("vpn-preview");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isJson) {
|
||||
setIsImporting(true);
|
||||
const result = await invoke<ProxyImportResult>(
|
||||
@@ -213,7 +128,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
setIsImporting(false);
|
||||
}
|
||||
},
|
||||
[detectVpnType],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleFileRead = useCallback(
|
||||
@@ -239,17 +154,13 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
const validFile = files.find(
|
||||
(f) =>
|
||||
f.name.endsWith(".json") ||
|
||||
f.name.endsWith(".txt") ||
|
||||
f.name.endsWith(".conf") || // WireGuard
|
||||
f.name.endsWith(".ovpn"), // OpenVPN
|
||||
(f) => f.name.endsWith(".json") || f.name.endsWith(".txt"),
|
||||
);
|
||||
|
||||
if (validFile) {
|
||||
handleFileRead(validFile);
|
||||
} else {
|
||||
toast.error("Please drop a .json, .txt, .conf, or .ovpn file");
|
||||
toast.error("Please drop a .json or .txt file");
|
||||
}
|
||||
},
|
||||
[handleFileRead],
|
||||
@@ -311,33 +222,6 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
}
|
||||
}, [parsedProxies, namePrefix]);
|
||||
|
||||
const handleVpnImport = useCallback(async () => {
|
||||
if (!vpnPreview) return;
|
||||
|
||||
setIsImporting(true);
|
||||
try {
|
||||
const result = await invoke<VpnImportResult>("import_vpn_config", {
|
||||
content: vpnPreview.content,
|
||||
filename: vpnPreview.filename,
|
||||
name: vpnName.trim() || null,
|
||||
});
|
||||
|
||||
setVpnImportResult(result);
|
||||
setStep("vpn-result");
|
||||
|
||||
if (result.success) {
|
||||
await emit("vpn-configs-changed");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to import VPN config:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to import VPN config",
|
||||
);
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
}, [vpnPreview, vpnName]);
|
||||
|
||||
const handleAmbiguousFormatSelect = useCallback(
|
||||
(index: number, format: string) => {
|
||||
setAmbiguousProxies((prev) =>
|
||||
@@ -389,20 +273,13 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{step === "vpn-preview" || step === "vpn-result"
|
||||
? "Import VPN Config"
|
||||
: "Import Proxies"}
|
||||
</DialogTitle>
|
||||
<DialogTitle>Import Proxies</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === "dropzone" &&
|
||||
"Import proxies from a JSON or TXT file, or VPN configs (.conf/.ovpn)"}
|
||||
{step === "dropzone" && "Import proxies from a JSON or TXT file"}
|
||||
{step === "preview" && "Review the proxies to import"}
|
||||
{step === "ambiguous" &&
|
||||
"Some proxies have ambiguous formats. Please select the correct format."}
|
||||
{step === "result" && "Import completed"}
|
||||
{step === "vpn-preview" && "Review the VPN configuration to import"}
|
||||
{step === "vpn-result" && "VPN import completed"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -432,14 +309,14 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
>
|
||||
<LuUpload className="w-10 h-10 text-muted-foreground mb-4" />
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Drop a proxy or VPN config file
|
||||
Drop a proxy config file
|
||||
<br />
|
||||
<span className="text-xs">(.json, .txt, .conf, .ovpn)</span>
|
||||
<span className="text-xs">(.json, .txt)</span>
|
||||
</p>
|
||||
<input
|
||||
id="proxy-file-input"
|
||||
type="file"
|
||||
accept=".json,.txt,.conf,.ovpn"
|
||||
accept=".json,.txt"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -594,75 +471,6 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "vpn-preview" && vpnPreview && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 p-4 bg-muted/30 rounded-lg">
|
||||
<LuShield className="w-8 h-8 text-primary" />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{vpnPreview.detectedType} Configuration
|
||||
</div>
|
||||
{vpnPreview.endpoint && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Endpoint: {vpnPreview.endpoint}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vpn-name">VPN Name</Label>
|
||||
<Input
|
||||
id="vpn-name"
|
||||
placeholder="My VPN"
|
||||
value={vpnName}
|
||||
onChange={(e) => setVpnName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Config Preview</Label>
|
||||
<ScrollArea className="h-[150px] border rounded-md">
|
||||
<pre className="p-2 text-xs font-mono whitespace-pre-wrap break-all">
|
||||
{vpnPreview.content.slice(0, 1000)}
|
||||
{vpnPreview.content.length > 1000 && "..."}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "vpn-result" && vpnImportResult && (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className={`p-4 rounded-lg ${vpnImportResult.success ? "bg-green-500/10" : "bg-red-500/10"}`}
|
||||
>
|
||||
{vpnImportResult.success ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<LuShield className="w-8 h-8 text-green-600 dark:text-green-400" />
|
||||
<div>
|
||||
<div className="font-medium text-green-600 dark:text-green-400">
|
||||
VPN Imported Successfully
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{vpnImportResult.name} ({vpnImportResult.vpn_type})
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium text-red-600 dark:text-red-400">
|
||||
Import Failed
|
||||
</div>
|
||||
<div className="text-sm text-red-600 dark:text-red-400">
|
||||
{vpnImportResult.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{step === "dropzone" && (
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
@@ -702,24 +510,6 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
{step === "result" && (
|
||||
<RippleButton onClick={handleClose}>Done</RippleButton>
|
||||
)}
|
||||
|
||||
{step === "vpn-preview" && (
|
||||
<>
|
||||
<RippleButton variant="outline" onClick={resetState}>
|
||||
Back
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isImporting}
|
||||
onClick={() => void handleVpnImport()}
|
||||
>
|
||||
Import VPN
|
||||
</LoadingButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "vpn-result" && (
|
||||
<RippleButton onClick={handleClose}>Done</RippleButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -30,26 +30,31 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import type { ProxyCheckResult, StoredProxy } from "@/types";
|
||||
import type { ProxyCheckResult, StoredProxy, VpnConfig } from "@/types";
|
||||
import { FlagIcon } from "./flag-icon";
|
||||
import { LocationProxyDialog } from "./location-proxy-dialog";
|
||||
import { ProxyCheckButton } from "./proxy-check-button";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
import { VpnCheckButton } from "./vpn-check-button";
|
||||
import { VpnFormDialog } from "./vpn-form-dialog";
|
||||
import { VpnImportDialog } from "./vpn-import-dialog";
|
||||
|
||||
type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting";
|
||||
|
||||
function getSyncStatusDot(
|
||||
proxy: StoredProxy,
|
||||
item: { sync_enabled?: boolean; last_sync?: number },
|
||||
liveStatus: SyncStatus | undefined,
|
||||
): { color: string; tooltip: string; animate: boolean } {
|
||||
const status = liveStatus ?? (proxy.sync_enabled ? "synced" : "disabled");
|
||||
const status = liveStatus ?? (item.sync_enabled ? "synced" : "disabled");
|
||||
|
||||
switch (status) {
|
||||
case "syncing":
|
||||
@@ -57,8 +62,8 @@ function getSyncStatusDot(
|
||||
case "synced":
|
||||
return {
|
||||
color: "bg-green-500",
|
||||
tooltip: proxy.last_sync
|
||||
? `Synced ${new Date(proxy.last_sync * 1000).toLocaleString()}`
|
||||
tooltip: item.last_sync
|
||||
? `Synced ${new Date(item.last_sync * 1000).toLocaleString()}`
|
||||
: "Synced",
|
||||
animate: false,
|
||||
};
|
||||
@@ -84,6 +89,7 @@ export function ProxyManagementDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: ProxyManagementDialogProps) {
|
||||
// Proxy state
|
||||
const [showProxyForm, setShowProxyForm] = useState(false);
|
||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||
const [showExportDialog, setShowExportDialog] = useState(false);
|
||||
@@ -103,7 +109,23 @@ export function ProxyManagementDialog({
|
||||
{},
|
||||
);
|
||||
|
||||
// VPN state
|
||||
const [showVpnForm, setShowVpnForm] = useState(false);
|
||||
const [showVpnImportDialog, setShowVpnImportDialog] = useState(false);
|
||||
const [editingVpn, setEditingVpn] = useState<VpnConfig | null>(null);
|
||||
const [vpnToDelete, setVpnToDelete] = useState<VpnConfig | null>(null);
|
||||
const [isDeletingVpn, setIsDeletingVpn] = useState(false);
|
||||
const [checkingVpnId, setCheckingVpnId] = useState<string | null>(null);
|
||||
const [vpnSyncStatus, setVpnSyncStatus] = useState<
|
||||
Record<string, SyncStatus>
|
||||
>({});
|
||||
const [vpnInUse, setVpnInUse] = useState<Record<string, boolean>>({});
|
||||
const [isTogglingVpnSync, setIsTogglingVpnSync] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
const { storedProxies: rawProxies, proxyUsage, isLoading } = useProxyEvents();
|
||||
const { vpnConfigs, vpnUsage, isLoading: isLoadingVpns } = useVpnEvents();
|
||||
const [cloudProxyUsage, setCloudProxyUsage] = useState<{
|
||||
used_mb: number;
|
||||
limit_mb: number;
|
||||
@@ -158,6 +180,29 @@ export function ProxyManagementDialog({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Listen for VPN sync status events
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
const setupListener = async () => {
|
||||
unlisten = await listen<{ id: string; status: string }>(
|
||||
"vpn-sync-status",
|
||||
(event) => {
|
||||
const { id, status } = event.payload;
|
||||
setVpnSyncStatus((prev) => ({
|
||||
...prev,
|
||||
[id]: status as SyncStatus,
|
||||
}));
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
void setupListener();
|
||||
return () => {
|
||||
unlisten?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Load cached check results on mount and when proxies change
|
||||
useEffect(() => {
|
||||
const loadCachedResults = async () => {
|
||||
@@ -190,8 +235,30 @@ export function ProxyManagementDialog({
|
||||
}
|
||||
}, [storedProxies]);
|
||||
|
||||
// Load VPN in-use status
|
||||
useEffect(() => {
|
||||
const loadVpnInUse = async () => {
|
||||
const inUse: Record<string, boolean> = {};
|
||||
for (const vpn of vpnConfigs) {
|
||||
try {
|
||||
const inUseBySynced = await invoke<boolean>(
|
||||
"is_vpn_in_use_by_synced_profile",
|
||||
{ vpnId: vpn.id },
|
||||
);
|
||||
inUse[vpn.id] = inUseBySynced;
|
||||
} catch (_error) {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
setVpnInUse(inUse);
|
||||
};
|
||||
if (vpnConfigs.length > 0) {
|
||||
void loadVpnInUse();
|
||||
}
|
||||
}, [vpnConfigs]);
|
||||
|
||||
// Proxy handlers
|
||||
const handleDeleteProxy = useCallback((proxy: StoredProxy) => {
|
||||
// Open in-app confirmation dialog
|
||||
setProxyToDelete(proxy);
|
||||
}, []);
|
||||
|
||||
@@ -245,106 +312,377 @@ export function ProxyManagementDialog({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// VPN handlers
|
||||
const handleDeleteVpn = useCallback((vpn: VpnConfig) => {
|
||||
setVpnToDelete(vpn);
|
||||
}, []);
|
||||
|
||||
const handleConfirmDeleteVpn = useCallback(async () => {
|
||||
if (!vpnToDelete) return;
|
||||
setIsDeletingVpn(true);
|
||||
try {
|
||||
await invoke("delete_vpn_config", { vpnId: vpnToDelete.id });
|
||||
toast.success("VPN deleted successfully");
|
||||
await emit("vpn-configs-changed");
|
||||
} catch (error) {
|
||||
console.error("Failed to delete VPN:", error);
|
||||
toast.error("Failed to delete VPN");
|
||||
} finally {
|
||||
setIsDeletingVpn(false);
|
||||
setVpnToDelete(null);
|
||||
}
|
||||
}, [vpnToDelete]);
|
||||
|
||||
const handleCreateVpn = useCallback(() => {
|
||||
setEditingVpn(null);
|
||||
setShowVpnForm(true);
|
||||
}, []);
|
||||
|
||||
const handleEditVpn = useCallback((vpn: VpnConfig) => {
|
||||
setEditingVpn(vpn);
|
||||
setShowVpnForm(true);
|
||||
}, []);
|
||||
|
||||
const handleVpnFormClose = useCallback(() => {
|
||||
setShowVpnForm(false);
|
||||
setEditingVpn(null);
|
||||
}, []);
|
||||
|
||||
const handleToggleVpnSync = useCallback(async (vpn: VpnConfig) => {
|
||||
setIsTogglingVpnSync((prev) => ({ ...prev, [vpn.id]: true }));
|
||||
try {
|
||||
await invoke("set_vpn_sync_enabled", {
|
||||
vpnId: vpn.id,
|
||||
enabled: !vpn.sync_enabled,
|
||||
});
|
||||
showSuccessToast(vpn.sync_enabled ? "Sync disabled" : "Sync enabled");
|
||||
await emit("vpn-configs-changed");
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle VPN sync:", error);
|
||||
showErrorToast(
|
||||
error instanceof Error ? error.message : "Failed to update sync",
|
||||
);
|
||||
} finally {
|
||||
setIsTogglingVpnSync((prev) => ({ ...prev, [vpn.id]: false }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Proxy Management</DialogTitle>
|
||||
<DialogTitle>Proxies & VPNs</DialogTitle>
|
||||
<DialogDescription>
|
||||
Manage your saved proxy configurations for reuse across profiles
|
||||
Manage your proxy and VPN configurations for reuse across profiles
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Proxy actions */}
|
||||
<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>
|
||||
<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) && (
|
||||
<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>
|
||||
|
||||
{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">
|
||||
<div className="flex items-center gap-2">
|
||||
{isDerived && proxy.geo_country && (
|
||||
<FlagIcon
|
||||
countryCode={proxy.geo_country}
|
||||
className="shrink-0"
|
||||
/>
|
||||
)}
|
||||
{!isCloud && !isDerived && (
|
||||
<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>
|
||||
)}
|
||||
{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>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
checked={proxy.sync_enabled}
|
||||
onCheckedChange={() =>
|
||||
handleToggleSync(proxy)
|
||||
}
|
||||
disabled={
|
||||
isTogglingSync[proxy.id] ||
|
||||
proxyInUse[proxy.id]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{proxyInUse[proxy.id] ? (
|
||||
<p>
|
||||
Sync cannot be disabled while this
|
||||
proxy is used by synced profiles
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{proxy.sync_enabled
|
||||
? "Disable sync"
|
||||
: "Enable sync"}
|
||||
</p>
|
||||
)}
|
||||
</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 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleDeleteProxy(proxy)
|
||||
}
|
||||
disabled={
|
||||
(proxyUsage[proxy.id] ?? 0) > 0
|
||||
}
|
||||
>
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{(proxyUsage[proxy.id] ?? 0) > 0 ? (
|
||||
<p>
|
||||
Cannot delete: in use by{" "}
|
||||
{proxyUsage[proxy.id]} profile
|
||||
{proxyUsage[proxy.id] > 1
|
||||
? "s"
|
||||
: ""}
|
||||
</p>
|
||||
) : (
|
||||
<p>Delete proxy</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{storedProxies.some((p) => p.is_cloud_managed) && (
|
||||
</TabsContent>
|
||||
|
||||
<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={() => setShowLocationDialog(true)}
|
||||
onClick={handleCreateVpn}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoGlobe className="w-4 h-4" />
|
||||
Location
|
||||
<GoPlus className="w-4 h-4" />
|
||||
Create
|
||||
</RippleButton>
|
||||
)}
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={handleCreateProxy}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoPlus className="w-4 h-4" />
|
||||
Create
|
||||
</RippleButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Proxies list */}
|
||||
{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">
|
||||
<div className="flex items-center gap-2">
|
||||
{isDerived && proxy.geo_country && (
|
||||
<FlagIcon
|
||||
countryCode={proxy.geo_country}
|
||||
className="shrink-0"
|
||||
/>
|
||||
)}
|
||||
{!isCloud && !isDerived && (
|
||||
{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">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
@@ -359,137 +697,115 @@ export function ProxyManagementDialog({
|
||||
<p>{syncDot.tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{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>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
checked={proxy.sync_enabled}
|
||||
onCheckedChange={() =>
|
||||
handleToggleSync(proxy)
|
||||
}
|
||||
disabled={
|
||||
isTogglingSync[proxy.id] ||
|
||||
proxyInUse[proxy.id]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{proxyInUse[proxy.id] ? (
|
||||
<p>
|
||||
Sync cannot be disabled while this proxy
|
||||
is used by synced profiles
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{proxy.sync_enabled
|
||||
? "Disable sync"
|
||||
: "Enable sync"}
|
||||
</p>
|
||||
)}
|
||||
</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 && (
|
||||
{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>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditProxy(proxy)}
|
||||
>
|
||||
<LuPencil className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit proxy</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!isCloud && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleDeleteProxy(proxy)
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
checked={vpn.sync_enabled}
|
||||
onCheckedChange={() =>
|
||||
handleToggleVpnSync(vpn)
|
||||
}
|
||||
disabled={
|
||||
(proxyUsage[proxy.id] ?? 0) > 0
|
||||
isTogglingVpnSync[vpn.id] ||
|
||||
vpnInUse[vpn.id]
|
||||
}
|
||||
>
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</span>
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{(proxyUsage[proxy.id] ?? 0) > 0 ? (
|
||||
{vpnInUse[vpn.id] ? (
|
||||
<p>
|
||||
Cannot delete: in use by{" "}
|
||||
{proxyUsage[proxy.id]} profile
|
||||
{proxyUsage[proxy.id] > 1 ? "s" : ""}
|
||||
Sync cannot be disabled while this VPN
|
||||
is used by synced profiles
|
||||
</p>
|
||||
) : (
|
||||
<p>Delete proxy</p>
|
||||
<p>
|
||||
{vpn.sync_enabled
|
||||
? "Disable sync"
|
||||
: "Enable sync"}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter>
|
||||
<RippleButton variant="outline" onClick={onClose}>
|
||||
@@ -525,6 +841,25 @@ export function ProxyManagementDialog({
|
||||
isOpen={showLocationDialog}
|
||||
onClose={() => setShowLocationDialog(false)}
|
||||
/>
|
||||
|
||||
<VpnFormDialog
|
||||
isOpen={showVpnForm}
|
||||
onClose={handleVpnFormClose}
|
||||
editingVpn={editingVpn}
|
||||
/>
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={vpnToDelete !== null}
|
||||
onClose={() => setVpnToDelete(null)}
|
||||
onConfirm={handleConfirmDeleteVpn}
|
||||
title="Delete VPN"
|
||||
description={`This action cannot be undone. This will permanently delete the VPN "${vpnToDelete?.name ?? ""}".`}
|
||||
confirmButtonText="Delete"
|
||||
isLoading={isDeletingVpn}
|
||||
/>
|
||||
<VpnImportDialog
|
||||
isOpen={showVpnImportDialog}
|
||||
onClose={() => setShowVpnImportDialog(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import * as React from "react";
|
||||
import { FiCheck } from "react-icons/fi";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { formatRelativeTime } from "@/lib/flag-utils";
|
||||
import type { ProxyCheckResult } from "@/types";
|
||||
|
||||
interface VpnCheckButtonProps {
|
||||
vpnId: string;
|
||||
vpnName: string;
|
||||
checkingVpnId: string | null;
|
||||
setCheckingVpnId: (id: string | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function VpnCheckButton({
|
||||
vpnId,
|
||||
vpnName,
|
||||
checkingVpnId,
|
||||
setCheckingVpnId,
|
||||
disabled = false,
|
||||
}: VpnCheckButtonProps) {
|
||||
const [result, setResult] = React.useState<ProxyCheckResult | undefined>();
|
||||
|
||||
const handleCheck = React.useCallback(async () => {
|
||||
if (checkingVpnId === vpnId) return;
|
||||
|
||||
setCheckingVpnId(vpnId);
|
||||
try {
|
||||
const checkResult = await invoke<ProxyCheckResult>("check_vpn_validity", {
|
||||
vpnId,
|
||||
});
|
||||
setResult(checkResult);
|
||||
|
||||
if (checkResult.is_valid) {
|
||||
toast.success(`VPN "${vpnName}" configuration is valid`);
|
||||
} else {
|
||||
toast.error(`VPN "${vpnName}" configuration is invalid`);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(`VPN check failed: ${errorMessage}`);
|
||||
|
||||
setResult({
|
||||
ip: "",
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
is_valid: false,
|
||||
});
|
||||
} finally {
|
||||
setCheckingVpnId(null);
|
||||
}
|
||||
}, [vpnId, vpnName, checkingVpnId, setCheckingVpnId]);
|
||||
|
||||
const isCurrentlyChecking = checkingVpnId === vpnId;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={handleCheck}
|
||||
disabled={isCurrentlyChecking || disabled}
|
||||
>
|
||||
{isCurrentlyChecking ? (
|
||||
<div className="w-3 h-3 rounded-full border border-current animate-spin border-t-transparent" />
|
||||
) : result?.is_valid ? (
|
||||
<FiCheck className="w-3 h-3 text-green-500" />
|
||||
) : result && !result.is_valid ? (
|
||||
<span className="text-destructive text-sm">✕</span>
|
||||
) : (
|
||||
<FiCheck className="w-3 h-3" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isCurrentlyChecking ? (
|
||||
<p>Checking VPN config...</p>
|
||||
) : result?.is_valid ? (
|
||||
<div className="space-y-1">
|
||||
<p>Configuration valid</p>
|
||||
<p className="text-xs text-primary-foreground/70">
|
||||
Checked {formatRelativeTime(result.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
) : result && !result.is_valid ? (
|
||||
<div>
|
||||
<p>Configuration invalid</p>
|
||||
<p className="text-xs text-primary-foreground/70">
|
||||
Checked {formatRelativeTime(result.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p>Check VPN config validity</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RippleButton } from "@/components/ui/ripple";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { VpnConfig, VpnType } from "@/types";
|
||||
|
||||
interface VpnFormDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
editingVpn?: VpnConfig | null;
|
||||
}
|
||||
|
||||
interface WireGuardFormData {
|
||||
name: string;
|
||||
privateKey: string;
|
||||
address: string;
|
||||
dns: string;
|
||||
mtu: string;
|
||||
peerPublicKey: string;
|
||||
peerEndpoint: string;
|
||||
allowedIps: string;
|
||||
persistentKeepalive: string;
|
||||
presharedKey: string;
|
||||
}
|
||||
|
||||
interface OpenVpnFormData {
|
||||
name: string;
|
||||
rawConfig: string;
|
||||
}
|
||||
|
||||
const defaultWireGuardForm: WireGuardFormData = {
|
||||
name: "",
|
||||
privateKey: "",
|
||||
address: "",
|
||||
dns: "",
|
||||
mtu: "",
|
||||
peerPublicKey: "",
|
||||
peerEndpoint: "",
|
||||
allowedIps: "0.0.0.0/0, ::/0",
|
||||
persistentKeepalive: "",
|
||||
presharedKey: "",
|
||||
};
|
||||
|
||||
const defaultOpenVpnForm: OpenVpnFormData = {
|
||||
name: "",
|
||||
rawConfig: "",
|
||||
};
|
||||
|
||||
function buildWireGuardConfig(form: WireGuardFormData): string {
|
||||
const lines: string[] = ["[Interface]"];
|
||||
lines.push(`PrivateKey = ${form.privateKey.trim()}`);
|
||||
lines.push(`Address = ${form.address.trim()}`);
|
||||
if (form.dns.trim()) lines.push(`DNS = ${form.dns.trim()}`);
|
||||
if (form.mtu.trim()) lines.push(`MTU = ${form.mtu.trim()}`);
|
||||
lines.push("");
|
||||
lines.push("[Peer]");
|
||||
lines.push(`PublicKey = ${form.peerPublicKey.trim()}`);
|
||||
lines.push(`Endpoint = ${form.peerEndpoint.trim()}`);
|
||||
lines.push(`AllowedIPs = ${form.allowedIps.trim()}`);
|
||||
if (form.persistentKeepalive.trim())
|
||||
lines.push(`PersistentKeepalive = ${form.persistentKeepalive.trim()}`);
|
||||
if (form.presharedKey.trim())
|
||||
lines.push(`PresharedKey = ${form.presharedKey.trim()}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function VpnFormDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
editingVpn,
|
||||
}: VpnFormDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [vpnType, setVpnType] = useState<VpnType>("WireGuard");
|
||||
const [wireGuardForm, setWireGuardForm] =
|
||||
useState<WireGuardFormData>(defaultWireGuardForm);
|
||||
const [openVpnForm, setOpenVpnForm] =
|
||||
useState<OpenVpnFormData>(defaultOpenVpnForm);
|
||||
|
||||
const resetForms = useCallback(() => {
|
||||
setVpnType("WireGuard");
|
||||
setWireGuardForm(defaultWireGuardForm);
|
||||
setOpenVpnForm(defaultOpenVpnForm);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (editingVpn) {
|
||||
setVpnType(editingVpn.vpn_type);
|
||||
if (editingVpn.vpn_type === "WireGuard") {
|
||||
setWireGuardForm({ ...defaultWireGuardForm, name: editingVpn.name });
|
||||
} else {
|
||||
setOpenVpnForm({ name: editingVpn.name, rawConfig: "" });
|
||||
}
|
||||
} else {
|
||||
resetForms();
|
||||
}
|
||||
}
|
||||
}, [isOpen, editingVpn, resetForms]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (!isSubmitting) {
|
||||
onClose();
|
||||
}
|
||||
}, [isSubmitting, onClose]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (editingVpn) {
|
||||
const name =
|
||||
vpnType === "WireGuard"
|
||||
? wireGuardForm.name.trim()
|
||||
: openVpnForm.name.trim();
|
||||
|
||||
if (!name) {
|
||||
toast.error("VPN name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await invoke("update_vpn_config", {
|
||||
vpnId: editingVpn.id,
|
||||
name,
|
||||
});
|
||||
await emit("vpn-configs-changed");
|
||||
toast.success("VPN updated successfully");
|
||||
onClose();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(`Failed to update VPN: ${errorMessage}`);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (vpnType === "WireGuard") {
|
||||
const { name, privateKey, address, peerPublicKey, peerEndpoint } =
|
||||
wireGuardForm;
|
||||
|
||||
if (!name.trim()) {
|
||||
toast.error("VPN name is required");
|
||||
return;
|
||||
}
|
||||
if (!privateKey.trim()) {
|
||||
toast.error("Private key is required");
|
||||
return;
|
||||
}
|
||||
if (!address.trim()) {
|
||||
toast.error("Address is required");
|
||||
return;
|
||||
}
|
||||
if (!peerPublicKey.trim()) {
|
||||
toast.error("Peer public key is required");
|
||||
return;
|
||||
}
|
||||
if (!peerEndpoint.trim()) {
|
||||
toast.error("Peer endpoint is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const configData = buildWireGuardConfig(wireGuardForm);
|
||||
await invoke("create_vpn_config_manual", {
|
||||
name: name.trim(),
|
||||
vpnType: "WireGuard",
|
||||
configData,
|
||||
});
|
||||
await emit("vpn-configs-changed");
|
||||
toast.success("WireGuard VPN created successfully");
|
||||
onClose();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(`Failed to create VPN: ${errorMessage}`);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
} else {
|
||||
const { name, rawConfig } = openVpnForm;
|
||||
|
||||
if (!name.trim()) {
|
||||
toast.error("VPN name is required");
|
||||
return;
|
||||
}
|
||||
if (!rawConfig.trim()) {
|
||||
toast.error("OpenVPN config content is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await invoke("create_vpn_config_manual", {
|
||||
name: name.trim(),
|
||||
vpnType: "OpenVPN",
|
||||
configData: rawConfig,
|
||||
});
|
||||
await emit("vpn-configs-changed");
|
||||
toast.success("OpenVPN configuration created successfully");
|
||||
onClose();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(`Failed to create VPN: ${errorMessage}`);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
}, [editingVpn, vpnType, wireGuardForm, openVpnForm, onClose]);
|
||||
|
||||
const updateWireGuard = useCallback(
|
||||
(field: keyof WireGuardFormData, value: string) => {
|
||||
setWireGuardForm((prev) => ({ ...prev, [field]: value }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const updateOpenVpn = useCallback(
|
||||
(field: keyof OpenVpnFormData, value: string) => {
|
||||
setOpenVpnForm((prev) => ({ ...prev, [field]: value }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const dialogTitle = editingVpn
|
||||
? "Edit VPN"
|
||||
: vpnType === "WireGuard"
|
||||
? "Create WireGuard VPN"
|
||||
: "Create OpenVPN Configuration";
|
||||
|
||||
const dialogDescription = editingVpn
|
||||
? "Update the name of your VPN configuration."
|
||||
: vpnType === "WireGuard"
|
||||
? "Enter your WireGuard interface and peer details."
|
||||
: "Paste your .ovpn configuration file content.";
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
<DialogDescription>{dialogDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-[60vh] pr-4">
|
||||
<div className="grid gap-4 py-2">
|
||||
{!editingVpn && (
|
||||
<div className="grid gap-2">
|
||||
<Label>VPN Type</Label>
|
||||
<Select
|
||||
value={vpnType}
|
||||
onValueChange={(value) => setVpnType(value as VpnType)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select VPN type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="WireGuard">WireGuard</SelectItem>
|
||||
<SelectItem value="OpenVPN">OpenVPN</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{vpnType === "WireGuard" && (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-name">Name</Label>
|
||||
<Input
|
||||
id="wg-name"
|
||||
value={wireGuardForm.name}
|
||||
onChange={(e) => updateWireGuard("name", e.target.value)}
|
||||
placeholder="e.g. Home WireGuard"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!editingVpn && (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-private-key">Private Key</Label>
|
||||
<Input
|
||||
id="wg-private-key"
|
||||
value={wireGuardForm.privateKey}
|
||||
onChange={(e) =>
|
||||
updateWireGuard("privateKey", e.target.value)
|
||||
}
|
||||
placeholder="Base64-encoded private key"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-address">Address</Label>
|
||||
<Input
|
||||
id="wg-address"
|
||||
value={wireGuardForm.address}
|
||||
onChange={(e) =>
|
||||
updateWireGuard("address", e.target.value)
|
||||
}
|
||||
placeholder="e.g. 10.0.0.2/24"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-dns">DNS (optional)</Label>
|
||||
<Input
|
||||
id="wg-dns"
|
||||
value={wireGuardForm.dns}
|
||||
onChange={(e) =>
|
||||
updateWireGuard("dns", e.target.value)
|
||||
}
|
||||
placeholder="e.g. 1.1.1.1"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-mtu">MTU (optional)</Label>
|
||||
<Input
|
||||
id="wg-mtu"
|
||||
type="number"
|
||||
value={wireGuardForm.mtu}
|
||||
onChange={(e) =>
|
||||
updateWireGuard("mtu", e.target.value)
|
||||
}
|
||||
placeholder="e.g. 1420"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-peer-public-key">
|
||||
Peer Public Key
|
||||
</Label>
|
||||
<Input
|
||||
id="wg-peer-public-key"
|
||||
value={wireGuardForm.peerPublicKey}
|
||||
onChange={(e) =>
|
||||
updateWireGuard("peerPublicKey", e.target.value)
|
||||
}
|
||||
placeholder="Base64-encoded peer public key"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-peer-endpoint">Peer Endpoint</Label>
|
||||
<Input
|
||||
id="wg-peer-endpoint"
|
||||
value={wireGuardForm.peerEndpoint}
|
||||
onChange={(e) =>
|
||||
updateWireGuard("peerEndpoint", e.target.value)
|
||||
}
|
||||
placeholder="e.g. vpn.example.com:51820"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-allowed-ips">Allowed IPs</Label>
|
||||
<Input
|
||||
id="wg-allowed-ips"
|
||||
value={wireGuardForm.allowedIps}
|
||||
onChange={(e) =>
|
||||
updateWireGuard("allowedIps", e.target.value)
|
||||
}
|
||||
placeholder="e.g. 0.0.0.0/0, ::/0"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-keepalive">
|
||||
Persistent Keepalive (optional)
|
||||
</Label>
|
||||
<Input
|
||||
id="wg-keepalive"
|
||||
type="number"
|
||||
value={wireGuardForm.persistentKeepalive}
|
||||
onChange={(e) =>
|
||||
updateWireGuard(
|
||||
"persistentKeepalive",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="e.g. 25"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-preshared-key">
|
||||
Preshared Key (optional)
|
||||
</Label>
|
||||
<Input
|
||||
id="wg-preshared-key"
|
||||
value={wireGuardForm.presharedKey}
|
||||
onChange={(e) =>
|
||||
updateWireGuard("presharedKey", e.target.value)
|
||||
}
|
||||
placeholder="Base64-encoded preshared key"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{vpnType === "OpenVPN" && (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="ovpn-name">Name</Label>
|
||||
<Input
|
||||
id="ovpn-name"
|
||||
value={openVpnForm.name}
|
||||
onChange={(e) => updateOpenVpn("name", e.target.value)}
|
||||
placeholder="e.g. Work OpenVPN"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!editingVpn && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="ovpn-config">Raw Config</Label>
|
||||
<Textarea
|
||||
id="ovpn-config"
|
||||
value={openVpnForm.rawConfig}
|
||||
onChange={(e) =>
|
||||
updateOpenVpn("rawConfig", e.target.value)
|
||||
}
|
||||
placeholder="Paste your .ovpn file content here..."
|
||||
className="min-h-[200px] font-mono text-xs"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</RippleButton>
|
||||
<LoadingButton isLoading={isSubmitting} onClick={handleSubmit}>
|
||||
{editingVpn ? "Update VPN" : "Create VPN"}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { LuShield, LuUpload } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RippleButton } from "@/components/ui/ripple";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { getCurrentOS } from "@/lib/browser-utils";
|
||||
import type { VpnImportResult, VpnType } from "@/types";
|
||||
|
||||
interface VpnImportDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type ImportStep = "dropzone" | "vpn-preview" | "vpn-result";
|
||||
|
||||
interface VpnPreviewData {
|
||||
content: string;
|
||||
filename: string;
|
||||
detectedType: VpnType | null;
|
||||
endpoint: string | null;
|
||||
}
|
||||
|
||||
const detectVpnType = (
|
||||
content: string,
|
||||
filename: string,
|
||||
): { isVpn: boolean; type: VpnType | null; endpoint: string | null } => {
|
||||
const lowerFilename = filename.toLowerCase();
|
||||
if (
|
||||
lowerFilename.endsWith(".conf") &&
|
||||
content.includes("[Interface]") &&
|
||||
content.includes("[Peer]")
|
||||
) {
|
||||
const endpointMatch = content.match(/Endpoint\s*=\s*([^\s\n]+)/i);
|
||||
return {
|
||||
isVpn: true,
|
||||
type: "WireGuard",
|
||||
endpoint: endpointMatch ? endpointMatch[1] : null,
|
||||
};
|
||||
}
|
||||
if (
|
||||
lowerFilename.endsWith(".ovpn") ||
|
||||
(content.includes("remote ") &&
|
||||
(content.includes("client") || content.includes("dev tun")))
|
||||
) {
|
||||
const remoteMatch = content.match(/remote\s+(\S+)(?:\s+(\d+))?/i);
|
||||
const endpoint = remoteMatch
|
||||
? `${remoteMatch[1]}${remoteMatch[2] ? `:${remoteMatch[2]}` : ""}`
|
||||
: null;
|
||||
return { isVpn: true, type: "OpenVPN", endpoint };
|
||||
}
|
||||
return { isVpn: false, type: null, endpoint: null };
|
||||
};
|
||||
|
||||
export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
const [step, setStep] = useState<ImportStep>("dropzone");
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [vpnPreview, setVpnPreview] = useState<VpnPreviewData | null>(null);
|
||||
const [vpnName, setVpnName] = useState("");
|
||||
const [vpnImportResult, setVpnImportResult] =
|
||||
useState<VpnImportResult | null>(null);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
|
||||
const os = getCurrentOS();
|
||||
const modKey = os === "macos" ? "⌘" : "Ctrl";
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setStep("dropzone");
|
||||
setIsDragOver(false);
|
||||
setVpnPreview(null);
|
||||
setVpnName("");
|
||||
setVpnImportResult(null);
|
||||
setIsImporting(false);
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
resetState();
|
||||
onClose();
|
||||
}, [resetState, onClose]);
|
||||
|
||||
const processContent = useCallback((content: string, filename: string) => {
|
||||
const detection = detectVpnType(content, filename);
|
||||
if (!detection.isVpn) {
|
||||
toast.error("Content does not appear to be a valid VPN configuration");
|
||||
return;
|
||||
}
|
||||
setVpnPreview({
|
||||
content,
|
||||
filename,
|
||||
detectedType: detection.type,
|
||||
endpoint: detection.endpoint,
|
||||
});
|
||||
const baseName = filename
|
||||
.replace(/\.(conf|ovpn)$/i, "")
|
||||
.replace(/_/g, " ")
|
||||
.replace(/-/g, " ");
|
||||
setVpnName(baseName || `${detection.type} VPN`);
|
||||
setStep("vpn-preview");
|
||||
}, []);
|
||||
|
||||
const handleFileRead = useCallback(
|
||||
(file: File) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
processContent(content, file.name);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
toast.error("Failed to read file");
|
||||
};
|
||||
reader.readAsText(file);
|
||||
},
|
||||
[processContent],
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
const validFile = files.find(
|
||||
(f) => f.name.endsWith(".conf") || f.name.endsWith(".ovpn"),
|
||||
);
|
||||
if (validFile) {
|
||||
handleFileRead(validFile);
|
||||
} else {
|
||||
toast.error("Please drop a .conf or .ovpn file");
|
||||
}
|
||||
},
|
||||
[handleFileRead],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || step !== "dropzone") return;
|
||||
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
const text = e.clipboardData?.getData("text");
|
||||
if (text) {
|
||||
processContent(text, "pasted.conf");
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("paste", handlePaste);
|
||||
return () => {
|
||||
document.removeEventListener("paste", handlePaste);
|
||||
};
|
||||
}, [isOpen, step, processContent]);
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
if (!vpnPreview) return;
|
||||
setIsImporting(true);
|
||||
try {
|
||||
const result = await invoke<VpnImportResult>("import_vpn_config", {
|
||||
content: vpnPreview.content,
|
||||
filename: vpnPreview.filename,
|
||||
name: vpnName.trim() || null,
|
||||
});
|
||||
setVpnImportResult(result);
|
||||
setStep("vpn-result");
|
||||
if (result.success) {
|
||||
await emit("vpn-configs-changed");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to import VPN config",
|
||||
);
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
}, [vpnPreview, vpnName]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import VPN Config</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === "dropzone" &&
|
||||
"Import a WireGuard (.conf) or OpenVPN (.ovpn) configuration file"}
|
||||
{step === "vpn-preview" && "Review the VPN configuration to import"}
|
||||
{step === "vpn-result" && "VPN import completed"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step === "dropzone" && (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`
|
||||
flex flex-col items-center justify-center
|
||||
border-2 border-dashed rounded-lg p-8
|
||||
transition-colors cursor-pointer
|
||||
${isDragOver ? "border-primary bg-primary/5" : "border-muted-foreground/25 hover:border-muted-foreground/50"}
|
||||
`}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={() => document.getElementById("vpn-file-input")?.click()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
document.getElementById("vpn-file-input")?.click();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LuUpload className="w-10 h-10 text-muted-foreground mb-4" />
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Drop a VPN config file here or click to browse
|
||||
<br />
|
||||
<span className="text-xs">
|
||||
(.conf for WireGuard, .ovpn for OpenVPN)
|
||||
</span>
|
||||
</p>
|
||||
<input
|
||||
id="vpn-file-input"
|
||||
type="file"
|
||||
accept=".conf,.ovpn"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFileRead(file);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Paste from clipboard with {modKey}+V
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "vpn-preview" && vpnPreview && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 p-4 bg-muted/30 rounded-lg">
|
||||
<LuShield className="w-8 h-8 text-primary" />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{vpnPreview.detectedType} Configuration
|
||||
</div>
|
||||
{vpnPreview.endpoint && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Endpoint: {vpnPreview.endpoint}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vpn-name">VPN Name</Label>
|
||||
<Input
|
||||
id="vpn-name"
|
||||
placeholder="My VPN"
|
||||
value={vpnName}
|
||||
onChange={(e) => setVpnName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Config Preview</Label>
|
||||
<ScrollArea className="h-[150px] border rounded-md">
|
||||
<pre className="p-2 text-xs font-mono whitespace-pre-wrap break-all">
|
||||
{vpnPreview.content.slice(0, 1000)}
|
||||
{vpnPreview.content.length > 1000 && "..."}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "vpn-result" && vpnImportResult && (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className={`p-4 rounded-lg ${vpnImportResult.success ? "bg-green-500/10" : "bg-red-500/10"}`}
|
||||
>
|
||||
{vpnImportResult.success ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<LuShield className="w-8 h-8 text-green-600 dark:text-green-400" />
|
||||
<div>
|
||||
<div className="font-medium text-green-600 dark:text-green-400">
|
||||
VPN Imported Successfully
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{vpnImportResult.name} ({vpnImportResult.vpn_type})
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium text-red-600 dark:text-red-400">
|
||||
Import Failed
|
||||
</div>
|
||||
<div className="text-sm text-red-600 dark:text-red-400">
|
||||
{vpnImportResult.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{step === "dropzone" && (
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</RippleButton>
|
||||
)}
|
||||
|
||||
{step === "vpn-preview" && (
|
||||
<>
|
||||
<RippleButton variant="outline" onClick={resetState}>
|
||||
Back
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isImporting}
|
||||
onClick={() => void handleImport()}
|
||||
>
|
||||
Import VPN
|
||||
</LoadingButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "vpn-result" && (
|
||||
<RippleButton onClick={handleClose}>Done</RippleButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { VpnConfig } from "@/types";
|
||||
|
||||
/**
|
||||
* Custom hook to manage VPN-related state and listen for backend events.
|
||||
* This hook eliminates the need for manual UI refreshes by automatically
|
||||
* updating state when the backend emits VPN change events.
|
||||
*/
|
||||
export function useVpnEvents() {
|
||||
const [vpnConfigs, setVpnConfigs] = useState<VpnConfig[]>([]);
|
||||
const [vpnUsage, setVpnUsage] = useState<Record<string, number>>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadVpnUsage = useCallback(async () => {
|
||||
try {
|
||||
const profiles = await invoke<Array<{ vpn_id?: string }>>(
|
||||
"list_browser_profiles",
|
||||
);
|
||||
const counts: Record<string, number> = {};
|
||||
for (const p of profiles) {
|
||||
if (p.vpn_id) counts[p.vpn_id] = (counts[p.vpn_id] ?? 0) + 1;
|
||||
}
|
||||
setVpnUsage(counts);
|
||||
} catch (err) {
|
||||
console.error("Failed to load VPN usage:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadVpnConfigs = useCallback(async () => {
|
||||
try {
|
||||
const configs = await invoke<VpnConfig[]>("list_vpn_configs");
|
||||
setVpnConfigs(configs);
|
||||
await loadVpnUsage();
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to load VPN configs:", err);
|
||||
setError(`Failed to load VPN configs: ${JSON.stringify(err)}`);
|
||||
}
|
||||
}, [loadVpnUsage]);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let vpnConfigsUnlisten: (() => void) | undefined;
|
||||
let profilesUnlisten: (() => void) | undefined;
|
||||
|
||||
const setupListeners = async () => {
|
||||
try {
|
||||
await loadVpnConfigs();
|
||||
|
||||
vpnConfigsUnlisten = await listen("vpn-configs-changed", () => {
|
||||
void loadVpnConfigs();
|
||||
});
|
||||
|
||||
profilesUnlisten = await listen("profiles-changed", () => {
|
||||
void loadVpnUsage();
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to setup VPN event listeners:", err);
|
||||
setError(`Failed to setup VPN event listeners: ${JSON.stringify(err)}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void setupListeners();
|
||||
|
||||
return () => {
|
||||
if (vpnConfigsUnlisten) vpnConfigsUnlisten();
|
||||
if (profilesUnlisten) profilesUnlisten();
|
||||
};
|
||||
}, [loadVpnConfigs, loadVpnUsage]);
|
||||
|
||||
return {
|
||||
vpnConfigs,
|
||||
vpnUsage,
|
||||
isLoading,
|
||||
error,
|
||||
loadVpnConfigs,
|
||||
clearError,
|
||||
};
|
||||
}
|
||||
@@ -126,7 +126,7 @@
|
||||
"createProfile": "Create a new profile",
|
||||
"menu": {
|
||||
"settings": "Settings",
|
||||
"proxies": "Proxies",
|
||||
"proxies": "Proxies & VPNs",
|
||||
"groups": "Groups",
|
||||
"syncService": "Account",
|
||||
"integrations": "Integrations",
|
||||
@@ -147,7 +147,7 @@
|
||||
"actions": "Actions",
|
||||
"note": "Note",
|
||||
"group": "Group",
|
||||
"proxy": "Proxy",
|
||||
"proxy": "Proxy / VPN",
|
||||
"lastLaunch": "Last Launch"
|
||||
},
|
||||
"actions": {
|
||||
@@ -178,10 +178,10 @@
|
||||
"profileName": "Profile Name",
|
||||
"profileNamePlaceholder": "Enter profile name",
|
||||
"proxy": {
|
||||
"title": "Proxy",
|
||||
"title": "Proxy / VPN",
|
||||
"addProxy": "Add Proxy",
|
||||
"noProxy": "No proxy",
|
||||
"noProxiesAvailable": "No proxies available. Add one to route this profile's traffic."
|
||||
"noProxy": "No proxy / VPN",
|
||||
"noProxiesAvailable": "No proxies or VPNs available. Add one to route this profile's traffic."
|
||||
},
|
||||
"version": {
|
||||
"fetching": "Fetching available versions...",
|
||||
@@ -203,7 +203,7 @@
|
||||
},
|
||||
"proxies": {
|
||||
"title": "Proxies",
|
||||
"management": "Proxy Management",
|
||||
"management": "Proxies & VPNs",
|
||||
"add": "Add Proxy",
|
||||
"edit": "Edit Proxy",
|
||||
"delete": "Delete Proxy",
|
||||
@@ -429,6 +429,7 @@
|
||||
"importSuccess": "Successfully imported {{count}} items",
|
||||
"exportSuccess": "Successfully exported {{count}} items",
|
||||
"syncSuccess": "Sync completed successfully",
|
||||
"profileSynced": "Profile '{{name}}' synced successfully",
|
||||
"cacheCleared": "Cache cleared successfully"
|
||||
},
|
||||
"error": {
|
||||
@@ -448,6 +449,7 @@
|
||||
"importFailed": "Failed to import",
|
||||
"exportFailed": "Failed to export",
|
||||
"syncFailed": "Sync failed",
|
||||
"profileSyncFailed": "Failed to sync profile '{{name}}'",
|
||||
"cacheClearFailed": "Failed to clear cache",
|
||||
"unknown": "An unknown error occurred"
|
||||
},
|
||||
@@ -456,6 +458,7 @@
|
||||
"extracting": "Extracting {{browser}} {{version}}",
|
||||
"verifying": "Verifying {{browser}} {{version}}",
|
||||
"syncing": "Syncing...",
|
||||
"syncingProfile": "Syncing profile '{{name}}'...",
|
||||
"updatingVersions": "Updating browser versions..."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
"createProfile": "Crear un nuevo perfil",
|
||||
"menu": {
|
||||
"settings": "Configuración",
|
||||
"proxies": "Proxies",
|
||||
"proxies": "Proxies y VPNs",
|
||||
"groups": "Grupos",
|
||||
"syncService": "Cuenta",
|
||||
"integrations": "Integraciones",
|
||||
@@ -147,7 +147,7 @@
|
||||
"actions": "Acciones",
|
||||
"note": "Nota",
|
||||
"group": "Grupo",
|
||||
"proxy": "Proxy",
|
||||
"proxy": "Proxy / VPN",
|
||||
"lastLaunch": "Último Inicio"
|
||||
},
|
||||
"actions": {
|
||||
@@ -178,10 +178,10 @@
|
||||
"profileName": "Nombre del Perfil",
|
||||
"profileNamePlaceholder": "Ingresa el nombre del perfil",
|
||||
"proxy": {
|
||||
"title": "Proxy",
|
||||
"title": "Proxy / VPN",
|
||||
"addProxy": "Agregar Proxy",
|
||||
"noProxy": "Sin proxy",
|
||||
"noProxiesAvailable": "No hay proxies disponibles. Agrega uno para enrutar el tráfico de este perfil."
|
||||
"noProxy": "Sin proxy / VPN",
|
||||
"noProxiesAvailable": "No hay proxies o VPNs disponibles. Agrega uno para enrutar el tráfico de este perfil."
|
||||
},
|
||||
"version": {
|
||||
"fetching": "Obteniendo versiones disponibles...",
|
||||
@@ -203,7 +203,7 @@
|
||||
},
|
||||
"proxies": {
|
||||
"title": "Proxies",
|
||||
"management": "Gestión de Proxies",
|
||||
"management": "Proxies y VPNs",
|
||||
"add": "Agregar Proxy",
|
||||
"edit": "Editar Proxy",
|
||||
"delete": "Eliminar Proxy",
|
||||
@@ -429,6 +429,7 @@
|
||||
"importSuccess": "{{count}} elementos importados exitosamente",
|
||||
"exportSuccess": "{{count}} elementos exportados exitosamente",
|
||||
"syncSuccess": "Sincronización completada exitosamente",
|
||||
"profileSynced": "Perfil '{{name}}' sincronizado exitosamente",
|
||||
"cacheCleared": "Caché limpiada exitosamente"
|
||||
},
|
||||
"error": {
|
||||
@@ -448,6 +449,7 @@
|
||||
"importFailed": "Error al importar",
|
||||
"exportFailed": "Error al exportar",
|
||||
"syncFailed": "Error de sincronización",
|
||||
"profileSyncFailed": "Error al sincronizar perfil '{{name}}'",
|
||||
"cacheClearFailed": "Error al limpiar caché",
|
||||
"unknown": "Ocurrió un error desconocido"
|
||||
},
|
||||
@@ -456,6 +458,7 @@
|
||||
"extracting": "Extrayendo {{browser}} {{version}}",
|
||||
"verifying": "Verificando {{browser}} {{version}}",
|
||||
"syncing": "Sincronizando...",
|
||||
"syncingProfile": "Sincronizando perfil '{{name}}'...",
|
||||
"updatingVersions": "Actualizando versiones de navegadores..."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
"createProfile": "Créer un nouveau profil",
|
||||
"menu": {
|
||||
"settings": "Paramètres",
|
||||
"proxies": "Proxies",
|
||||
"proxies": "Proxys et VPNs",
|
||||
"groups": "Groupes",
|
||||
"syncService": "Compte",
|
||||
"integrations": "Intégrations",
|
||||
@@ -147,7 +147,7 @@
|
||||
"actions": "Actions",
|
||||
"note": "Note",
|
||||
"group": "Groupe",
|
||||
"proxy": "Proxy",
|
||||
"proxy": "Proxy / VPN",
|
||||
"lastLaunch": "Dernier lancement"
|
||||
},
|
||||
"actions": {
|
||||
@@ -178,10 +178,10 @@
|
||||
"profileName": "Nom du profil",
|
||||
"profileNamePlaceholder": "Entrez le nom du profil",
|
||||
"proxy": {
|
||||
"title": "Proxy",
|
||||
"title": "Proxy / VPN",
|
||||
"addProxy": "Ajouter un proxy",
|
||||
"noProxy": "Pas de proxy",
|
||||
"noProxiesAvailable": "Aucun proxy disponible. Ajoutez-en un pour acheminer le trafic de ce profil."
|
||||
"noProxy": "Pas de proxy / VPN",
|
||||
"noProxiesAvailable": "Aucun proxy ou VPN disponible. Ajoutez-en un pour router le trafic de ce profil."
|
||||
},
|
||||
"version": {
|
||||
"fetching": "Récupération des versions disponibles...",
|
||||
@@ -203,7 +203,7 @@
|
||||
},
|
||||
"proxies": {
|
||||
"title": "Proxies",
|
||||
"management": "Gestion des proxies",
|
||||
"management": "Proxys et VPNs",
|
||||
"add": "Ajouter un proxy",
|
||||
"edit": "Modifier le proxy",
|
||||
"delete": "Supprimer le proxy",
|
||||
@@ -429,6 +429,7 @@
|
||||
"importSuccess": "{{count}} éléments importés avec succès",
|
||||
"exportSuccess": "{{count}} éléments exportés avec succès",
|
||||
"syncSuccess": "Synchronisation terminée avec succès",
|
||||
"profileSynced": "Profil '{{name}}' synchronisé avec succès",
|
||||
"cacheCleared": "Cache effacé avec succès"
|
||||
},
|
||||
"error": {
|
||||
@@ -448,6 +449,7 @@
|
||||
"importFailed": "Échec de l'importation",
|
||||
"exportFailed": "Échec de l'exportation",
|
||||
"syncFailed": "Échec de la synchronisation",
|
||||
"profileSyncFailed": "Échec de la synchronisation du profil '{{name}}'",
|
||||
"cacheClearFailed": "Échec de l'effacement du cache",
|
||||
"unknown": "Une erreur inconnue s'est produite"
|
||||
},
|
||||
@@ -456,6 +458,7 @@
|
||||
"extracting": "Extraction de {{browser}} {{version}}",
|
||||
"verifying": "Vérification de {{browser}} {{version}}",
|
||||
"syncing": "Synchronisation...",
|
||||
"syncingProfile": "Synchronisation du profil '{{name}}'...",
|
||||
"updatingVersions": "Mise à jour des versions de navigateurs..."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
"createProfile": "新しいプロファイルを作成",
|
||||
"menu": {
|
||||
"settings": "設定",
|
||||
"proxies": "プロキシ",
|
||||
"proxies": "プロキシ & VPN",
|
||||
"groups": "グループ",
|
||||
"syncService": "アカウント",
|
||||
"integrations": "統合",
|
||||
@@ -147,7 +147,7 @@
|
||||
"actions": "アクション",
|
||||
"note": "メモ",
|
||||
"group": "グループ",
|
||||
"proxy": "プロキシ",
|
||||
"proxy": "プロキシ / VPN",
|
||||
"lastLaunch": "最終起動"
|
||||
},
|
||||
"actions": {
|
||||
@@ -178,10 +178,10 @@
|
||||
"profileName": "プロファイル名",
|
||||
"profileNamePlaceholder": "プロファイル名を入力",
|
||||
"proxy": {
|
||||
"title": "プロキシ",
|
||||
"title": "プロキシ / VPN",
|
||||
"addProxy": "プロキシを追加",
|
||||
"noProxy": "プロキシなし",
|
||||
"noProxiesAvailable": "利用可能なプロキシがありません。このプロファイルのトラフィックをルーティングするためにプロキシを追加してください。"
|
||||
"noProxy": "プロキシ / VPNなし",
|
||||
"noProxiesAvailable": "利用可能なプロキシまたはVPNがありません。このプロファイルのトラフィックをルーティングするために追加してください。"
|
||||
},
|
||||
"version": {
|
||||
"fetching": "利用可能なバージョンを取得中...",
|
||||
@@ -203,7 +203,7 @@
|
||||
},
|
||||
"proxies": {
|
||||
"title": "プロキシ",
|
||||
"management": "プロキシ管理",
|
||||
"management": "プロキシ & VPN",
|
||||
"add": "プロキシを追加",
|
||||
"edit": "プロキシを編集",
|
||||
"delete": "プロキシを削除",
|
||||
@@ -429,6 +429,7 @@
|
||||
"importSuccess": "{{count}} 個のアイテムが正常にインポートされました",
|
||||
"exportSuccess": "{{count}} 個のアイテムが正常にエクスポートされました",
|
||||
"syncSuccess": "同期が正常に完了しました",
|
||||
"profileSynced": "プロファイル '{{name}}' が正常に同期されました",
|
||||
"cacheCleared": "キャッシュが正常にクリアされました"
|
||||
},
|
||||
"error": {
|
||||
@@ -448,6 +449,7 @@
|
||||
"importFailed": "インポートに失敗しました",
|
||||
"exportFailed": "エクスポートに失敗しました",
|
||||
"syncFailed": "同期に失敗しました",
|
||||
"profileSyncFailed": "プロファイル '{{name}}' の同期に失敗しました",
|
||||
"cacheClearFailed": "キャッシュのクリアに失敗しました",
|
||||
"unknown": "不明なエラーが発生しました"
|
||||
},
|
||||
@@ -456,6 +458,7 @@
|
||||
"extracting": "{{browser}} {{version}} を展開中",
|
||||
"verifying": "{{browser}} {{version}} を確認中",
|
||||
"syncing": "同期中...",
|
||||
"syncingProfile": "プロファイル '{{name}}' を同期中...",
|
||||
"updatingVersions": "ブラウザバージョンを更新中..."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
"createProfile": "Criar um novo perfil",
|
||||
"menu": {
|
||||
"settings": "Configurações",
|
||||
"proxies": "Proxies",
|
||||
"proxies": "Proxies e VPNs",
|
||||
"groups": "Grupos",
|
||||
"syncService": "Conta",
|
||||
"integrations": "Integrações",
|
||||
@@ -147,7 +147,7 @@
|
||||
"actions": "Ações",
|
||||
"note": "Nota",
|
||||
"group": "Grupo",
|
||||
"proxy": "Proxy",
|
||||
"proxy": "Proxy / VPN",
|
||||
"lastLaunch": "Último Início"
|
||||
},
|
||||
"actions": {
|
||||
@@ -178,10 +178,10 @@
|
||||
"profileName": "Nome do Perfil",
|
||||
"profileNamePlaceholder": "Digite o nome do perfil",
|
||||
"proxy": {
|
||||
"title": "Proxy",
|
||||
"title": "Proxy / VPN",
|
||||
"addProxy": "Adicionar Proxy",
|
||||
"noProxy": "Sem proxy",
|
||||
"noProxiesAvailable": "Nenhum proxy disponível. Adicione um para rotear o tráfego deste perfil."
|
||||
"noProxy": "Sem proxy / VPN",
|
||||
"noProxiesAvailable": "Nenhum proxy ou VPN disponível. Adicione um para rotear o tráfego deste perfil."
|
||||
},
|
||||
"version": {
|
||||
"fetching": "Buscando versões disponíveis...",
|
||||
@@ -203,7 +203,7 @@
|
||||
},
|
||||
"proxies": {
|
||||
"title": "Proxies",
|
||||
"management": "Gerenciamento de Proxies",
|
||||
"management": "Proxies e VPNs",
|
||||
"add": "Adicionar Proxy",
|
||||
"edit": "Editar Proxy",
|
||||
"delete": "Excluir Proxy",
|
||||
@@ -429,6 +429,7 @@
|
||||
"importSuccess": "{{count}} itens importados com sucesso",
|
||||
"exportSuccess": "{{count}} itens exportados com sucesso",
|
||||
"syncSuccess": "Sincronização concluída com sucesso",
|
||||
"profileSynced": "Perfil '{{name}}' sincronizado com sucesso",
|
||||
"cacheCleared": "Cache limpo com sucesso"
|
||||
},
|
||||
"error": {
|
||||
@@ -448,6 +449,7 @@
|
||||
"importFailed": "Falha ao importar",
|
||||
"exportFailed": "Falha ao exportar",
|
||||
"syncFailed": "Falha na sincronização",
|
||||
"profileSyncFailed": "Falha ao sincronizar perfil '{{name}}'",
|
||||
"cacheClearFailed": "Falha ao limpar cache",
|
||||
"unknown": "Ocorreu um erro desconhecido"
|
||||
},
|
||||
@@ -456,6 +458,7 @@
|
||||
"extracting": "Extraindo {{browser}} {{version}}",
|
||||
"verifying": "Verificando {{browser}} {{version}}",
|
||||
"syncing": "Sincronizando...",
|
||||
"syncingProfile": "Sincronizando perfil '{{name}}'...",
|
||||
"updatingVersions": "Atualizando versões de navegadores..."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
"createProfile": "Создать новый профиль",
|
||||
"menu": {
|
||||
"settings": "Настройки",
|
||||
"proxies": "Прокси",
|
||||
"proxies": "Прокси и VPN",
|
||||
"groups": "Группы",
|
||||
"syncService": "Аккаунт",
|
||||
"integrations": "Интеграции",
|
||||
@@ -147,7 +147,7 @@
|
||||
"actions": "Действия",
|
||||
"note": "Заметка",
|
||||
"group": "Группа",
|
||||
"proxy": "Прокси",
|
||||
"proxy": "Прокси / VPN",
|
||||
"lastLaunch": "Последний запуск"
|
||||
},
|
||||
"actions": {
|
||||
@@ -178,10 +178,10 @@
|
||||
"profileName": "Название профиля",
|
||||
"profileNamePlaceholder": "Введите название профиля",
|
||||
"proxy": {
|
||||
"title": "Прокси",
|
||||
"title": "Прокси / VPN",
|
||||
"addProxy": "Добавить прокси",
|
||||
"noProxy": "Без прокси",
|
||||
"noProxiesAvailable": "Нет доступных прокси. Добавьте один для маршрутизации трафика этого профиля."
|
||||
"noProxy": "Без прокси / VPN",
|
||||
"noProxiesAvailable": "Нет доступных прокси или VPN. Добавьте один для маршрутизации трафика этого профиля."
|
||||
},
|
||||
"version": {
|
||||
"fetching": "Получение доступных версий...",
|
||||
@@ -203,7 +203,7 @@
|
||||
},
|
||||
"proxies": {
|
||||
"title": "Прокси",
|
||||
"management": "Управление прокси",
|
||||
"management": "Прокси и VPN",
|
||||
"add": "Добавить прокси",
|
||||
"edit": "Редактировать прокси",
|
||||
"delete": "Удалить прокси",
|
||||
@@ -429,6 +429,7 @@
|
||||
"importSuccess": "Успешно импортировано {{count}} элементов",
|
||||
"exportSuccess": "Успешно экспортировано {{count}} элементов",
|
||||
"syncSuccess": "Синхронизация успешно завершена",
|
||||
"profileSynced": "Профиль '{{name}}' успешно синхронизирован",
|
||||
"cacheCleared": "Кэш успешно очищен"
|
||||
},
|
||||
"error": {
|
||||
@@ -448,6 +449,7 @@
|
||||
"importFailed": "Ошибка импорта",
|
||||
"exportFailed": "Ошибка экспорта",
|
||||
"syncFailed": "Ошибка синхронизации",
|
||||
"profileSyncFailed": "Ошибка синхронизации профиля '{{name}}'",
|
||||
"cacheClearFailed": "Ошибка очистки кэша",
|
||||
"unknown": "Произошла неизвестная ошибка"
|
||||
},
|
||||
@@ -456,6 +458,7 @@
|
||||
"extracting": "Распаковка {{browser}} {{version}}",
|
||||
"verifying": "Проверка {{browser}} {{version}}",
|
||||
"syncing": "Синхронизация...",
|
||||
"syncingProfile": "Синхронизация профиля '{{name}}'...",
|
||||
"updatingVersions": "Обновление версий браузеров..."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
"createProfile": "创建新配置文件",
|
||||
"menu": {
|
||||
"settings": "设置",
|
||||
"proxies": "代理",
|
||||
"proxies": "代理和VPN",
|
||||
"groups": "分组",
|
||||
"syncService": "账户",
|
||||
"integrations": "集成",
|
||||
@@ -147,7 +147,7 @@
|
||||
"actions": "操作",
|
||||
"note": "备注",
|
||||
"group": "分组",
|
||||
"proxy": "代理",
|
||||
"proxy": "代理 / VPN",
|
||||
"lastLaunch": "最后启动"
|
||||
},
|
||||
"actions": {
|
||||
@@ -178,10 +178,10 @@
|
||||
"profileName": "配置文件名称",
|
||||
"profileNamePlaceholder": "输入配置文件名称",
|
||||
"proxy": {
|
||||
"title": "代理",
|
||||
"title": "代理 / VPN",
|
||||
"addProxy": "添加代理",
|
||||
"noProxy": "无代理",
|
||||
"noProxiesAvailable": "没有可用的代理。添加一个代理来路由此配置文件的流量。"
|
||||
"noProxy": "无代理 / VPN",
|
||||
"noProxiesAvailable": "没有可用的代理或VPN。添加一个来路由此配置文件的流量。"
|
||||
},
|
||||
"version": {
|
||||
"fetching": "正在获取可用版本...",
|
||||
@@ -203,7 +203,7 @@
|
||||
},
|
||||
"proxies": {
|
||||
"title": "代理",
|
||||
"management": "代理管理",
|
||||
"management": "代理和VPN",
|
||||
"add": "添加代理",
|
||||
"edit": "编辑代理",
|
||||
"delete": "删除代理",
|
||||
@@ -429,6 +429,7 @@
|
||||
"importSuccess": "成功导入 {{count}} 个项目",
|
||||
"exportSuccess": "成功导出 {{count}} 个项目",
|
||||
"syncSuccess": "同步成功完成",
|
||||
"profileSynced": "配置文件 '{{name}}' 同步成功",
|
||||
"cacheCleared": "缓存清除成功"
|
||||
},
|
||||
"error": {
|
||||
@@ -448,6 +449,7 @@
|
||||
"importFailed": "导入失败",
|
||||
"exportFailed": "导出失败",
|
||||
"syncFailed": "同步失败",
|
||||
"profileSyncFailed": "配置文件 '{{name}}' 同步失败",
|
||||
"cacheClearFailed": "清除缓存失败",
|
||||
"unknown": "发生未知错误"
|
||||
},
|
||||
@@ -456,6 +458,7 @@
|
||||
"extracting": "正在解压 {{browser}} {{version}}",
|
||||
"verifying": "正在验证 {{browser}} {{version}}",
|
||||
"syncing": "同步中...",
|
||||
"syncingProfile": "正在同步配置文件 '{{name}}'...",
|
||||
"updatingVersions": "正在更新浏览器版本..."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface BrowserProfile {
|
||||
browser: string;
|
||||
version: string;
|
||||
proxy_id?: string; // Reference to stored proxy
|
||||
vpn_id?: string; // Reference to stored VPN config
|
||||
process_id?: number;
|
||||
last_launch?: number;
|
||||
release_type: string; // "stable" or "nightly"
|
||||
@@ -605,6 +606,8 @@ export interface VpnConfig {
|
||||
config_data: string; // Raw config content (may be empty in list view)
|
||||
created_at: number;
|
||||
last_used?: number;
|
||||
sync_enabled?: boolean;
|
||||
last_sync?: number;
|
||||
}
|
||||
|
||||
export interface VpnImportResult {
|
||||
|
||||
Reference in New Issue
Block a user