feat: daemon support, general improvement, and preparation for Windows release

This commit is contained in:
zhom
2026-02-01 20:55:09 +04:00
parent e9f4edd120
commit 4a59459eb2
58 changed files with 9763 additions and 296 deletions
+2 -7
View File
@@ -58,13 +58,8 @@ export function CommercialTrialModal({
<div className="space-y-4 py-4">
<p className="text-sm text-muted-foreground">
If you are using Donut Browser for business purposes, you need to
purchase a commercial license to continue.
</p>
<p className="text-sm font-medium">
Personal use remains free and unrestricted.
</p>
<p className="text-xs text-muted-foreground">
Visit our website to learn more about commercial licensing options.
purchase a commercial license to continue. You can still use it for
personal use for free.
</p>
</div>
+12 -10
View File
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { FaDownload } from "react-icons/fa";
import { FiWifi } from "react-icons/fi";
import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go";
@@ -37,6 +38,7 @@ const HomeHeader = ({
searchQuery,
onSearchQueryChange,
}: Props) => {
const { t } = useTranslation();
const handleLogoClick = () => {
// Trigger the same URL handling logic as if the URL came from the system
const event = new CustomEvent("url-open-request", {
@@ -61,7 +63,7 @@ const HomeHeader = ({
<div className="relative">
<Input
type="text"
placeholder="Search profiles..."
placeholder={t("header.searchPlaceholder")}
value={searchQuery}
onChange={(e) => onSearchQueryChange(e.target.value)}
className="pr-8 pl-10 w-48"
@@ -72,7 +74,7 @@ const HomeHeader = ({
type="button"
onClick={() => onSearchQueryChange("")}
className="absolute right-2 top-1/2 p-1 rounded-sm transition-colors transform -translate-y-1/2 hover:bg-accent"
aria-label="Clear search"
aria-label={t("header.clearSearch")}
>
<LuX className="w-4 h-4 text-muted-foreground hover:text-foreground" />
</button>
@@ -93,7 +95,7 @@ const HomeHeader = ({
</Button>
</span>
</TooltipTrigger>
<TooltipContent>More actions</TooltipContent>
<TooltipContent>{t("header.moreActions")}</TooltipContent>
</Tooltip>
</span>
</DropdownMenuTrigger>
@@ -104,7 +106,7 @@ const HomeHeader = ({
}}
>
<GoGear className="mr-2 w-4 h-4" />
Settings
{t("header.menu.settings")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
@@ -112,7 +114,7 @@ const HomeHeader = ({
}}
>
<FiWifi className="mr-2 w-4 h-4" />
Proxies
{t("header.menu.proxies")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
@@ -120,7 +122,7 @@ const HomeHeader = ({
}}
>
<LuUsers className="mr-2 w-4 h-4" />
Groups
{t("header.menu.groups")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
@@ -128,7 +130,7 @@ const HomeHeader = ({
}}
>
<LuCloud className="mr-2 w-4 h-4" />
Sync Service
{t("header.menu.syncService")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
@@ -136,7 +138,7 @@ const HomeHeader = ({
}}
>
<LuPlug className="mr-2 w-4 h-4" />
Integrations
{t("header.menu.integrations")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
@@ -144,7 +146,7 @@ const HomeHeader = ({
}}
>
<FaDownload className="mr-2 w-4 h-4" />
Import Profile
{t("header.menu.importProfile")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -166,7 +168,7 @@ const HomeHeader = ({
arrowOffset={-8}
style={{ transform: "translateX(-8px)" }}
>
Create a new profile
{t("header.createProfile")}
</TooltipContent>
</Tooltip>
</div>
+52
View File
@@ -0,0 +1,52 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import { I18nextProvider } from "react-i18next";
import i18n, { getLanguageWithFallback, SUPPORTED_LANGUAGES } from "@/i18n";
interface AppSettings {
language?: string | null;
[key: string]: unknown;
}
interface I18nProviderProps {
children: React.ReactNode;
}
export function I18nProvider({ children }: I18nProviderProps) {
const [isReady, setIsReady] = useState(false);
useEffect(() => {
const initializeLanguage = async () => {
try {
const settings = await invoke<AppSettings>("get_app_settings");
let language = settings.language;
if (!language) {
const systemLanguage = await invoke<string>("get_system_language");
language = getLanguageWithFallback(systemLanguage);
}
if (
language &&
SUPPORTED_LANGUAGES.some((lang) => lang.code === language)
) {
await i18n.changeLanguage(language);
}
} catch (error) {
console.error("Failed to initialize language:", error);
} finally {
setIsReady(true);
}
};
void initializeLanguage();
}, []);
if (!isReady) {
return null;
}
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
}
+169
View File
@@ -0,0 +1,169 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { LuCheck, LuCopy, LuDownload } from "react-icons/lu";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { ScrollArea } from "@/components/ui/scroll-area";
import { RippleButton } from "./ui/ripple";
interface ProxyExportDialogProps {
isOpen: boolean;
onClose: () => void;
}
export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
const [format, setFormat] = useState<"json" | "txt">("json");
const [exportContent, setExportContent] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
const [copied, setCopied] = useState(false);
const loadExportContent = useCallback(async () => {
setIsLoading(true);
try {
const content = await invoke<string>("export_proxies", { format });
setExportContent(content);
} catch (error) {
console.error("Failed to export proxies:", error);
toast.error("Failed to export proxies");
setExportContent("");
} finally {
setIsLoading(false);
}
}, [format]);
useEffect(() => {
if (isOpen) {
void loadExportContent();
}
}, [isOpen, loadExportContent]);
const handleCopyToClipboard = useCallback(async () => {
try {
await navigator.clipboard.writeText(exportContent);
setCopied(true);
toast.success("Copied to clipboard");
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error("Failed to copy to clipboard:", error);
toast.error("Failed to copy to clipboard");
}
}, [exportContent]);
const handleDownload = useCallback(() => {
const filename = format === "json" ? "proxies.json" : "proxies.txt";
const mimeType = format === "json" ? "application/json" : "text/plain";
const blob = new Blob([exportContent], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success(`Downloaded ${filename}`);
}, [format, exportContent]);
const handleClose = useCallback(() => {
setFormat("json");
setExportContent("");
setCopied(false);
onClose();
}, [onClose]);
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Export Proxies</DialogTitle>
<DialogDescription>
Export your proxy configurations to a file
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Export Format</Label>
<RadioGroup
value={format}
onValueChange={(value) => setFormat(value as "json" | "txt")}
className="flex gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="json" id="format-json" />
<Label htmlFor="format-json" className="cursor-pointer">
JSON
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="txt" id="format-txt" />
<Label htmlFor="format-txt" className="cursor-pointer">
TXT (URL format)
</Label>
</div>
</RadioGroup>
</div>
<div className="space-y-2">
<Label>Preview</Label>
<ScrollArea className="h-[200px] border rounded-md bg-muted/30">
{isLoading ? (
<div className="flex items-center justify-center h-full p-4 text-sm text-muted-foreground">
Loading...
</div>
) : exportContent ? (
<pre className="p-3 text-xs font-mono whitespace-pre-wrap break-all">
{exportContent}
</pre>
) : (
<div className="flex items-center justify-center h-full p-4 text-sm text-muted-foreground">
No proxies to export
</div>
)}
</ScrollArea>
</div>
</div>
<DialogFooter className="flex-col sm:flex-row gap-2">
<RippleButton variant="outline" onClick={handleClose}>
Close
</RippleButton>
<RippleButton
variant="outline"
onClick={() => void handleCopyToClipboard()}
disabled={!exportContent || isLoading}
className="flex gap-2 items-center"
>
{copied ? (
<LuCheck className="w-4 h-4" />
) : (
<LuCopy className="w-4 h-4" />
)}
{copied ? "Copied" : "Copy"}
</RippleButton>
<RippleButton
onClick={handleDownload}
disabled={!exportContent || isLoading}
className="flex gap-2 items-center"
>
<LuDownload className="w-4 h-4" />
Download
</RippleButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+727
View File
@@ -0,0 +1,727 @@
"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 { ScrollArea } from "@/components/ui/scroll-area";
import { getCurrentOS } from "@/lib/browser-utils";
import type {
ParsedProxyLine,
ProxyImportResult,
ProxyParseResult,
VpnImportResult,
VpnType,
} from "@/types";
import { RippleButton } from "./ui/ripple";
interface ProxyImportDialogProps {
isOpen: boolean;
onClose: () => void;
}
type ImportStep =
| "dropzone"
| "preview"
| "ambiguous"
| "result"
| "vpn-preview"
| "vpn-result";
interface AmbiguousProxy {
line: string;
possible_formats: string[];
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);
const [parsedProxies, setParsedProxies] = useState<ParsedProxyLine[]>([]);
const [ambiguousProxies, setAmbiguousProxies] = useState<AmbiguousProxy[]>(
[],
);
const [invalidProxies, setInvalidProxies] = useState<
{ line: string; reason: string }[]
>([]);
const [importResult, setImportResult] = useState<ProxyImportResult | null>(
null,
);
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";
const resetState = useCallback(() => {
setStep("dropzone");
setIsDragOver(false);
setParsedProxies([]);
setAmbiguousProxies([]);
setInvalidProxies([]);
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 = "") => {
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>(
"import_proxies_json",
{
content,
},
);
setImportResult(result);
setStep("result");
await emit("stored-proxies-changed");
} else {
const results = await invoke<ProxyParseResult[]>(
"parse_txt_proxies",
{
content,
},
);
const parsed: ParsedProxyLine[] = [];
const ambiguous: AmbiguousProxy[] = [];
const invalid: { line: string; reason: string }[] = [];
for (const result of results) {
if (result.status === "parsed") {
parsed.push(result);
} else if (result.status === "ambiguous") {
ambiguous.push({
line: result.line,
possible_formats: result.possible_formats,
});
} else if (result.status === "invalid") {
invalid.push({ line: result.line, reason: result.reason });
}
}
setParsedProxies(parsed);
setAmbiguousProxies(ambiguous);
setInvalidProxies(invalid);
if (ambiguous.length > 0) {
setStep("ambiguous");
} else if (parsed.length > 0) {
setStep("preview");
} else {
toast.error("No valid proxies found in the file");
}
}
} catch (error) {
console.error("Failed to process content:", error);
toast.error(
error instanceof Error ? error.message : "Failed to process file",
);
} finally {
setIsImporting(false);
}
},
[detectVpnType],
);
const handleFileRead = useCallback(
(file: File) => {
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result as string;
const isJson = file.name.endsWith(".json");
void processContent(content, isJson, 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(".json") ||
f.name.endsWith(".txt") ||
f.name.endsWith(".conf") || // WireGuard
f.name.endsWith(".ovpn"), // OpenVPN
);
if (validFile) {
handleFileRead(validFile);
} else {
toast.error("Please drop a .json, .txt, .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);
}, []);
// Handle paste from clipboard
useEffect(() => {
if (!isOpen || step !== "dropzone") return;
const handlePaste = async (e: ClipboardEvent) => {
const text = e.clipboardData?.getData("text");
if (text) {
// Try to detect if it's JSON
const trimmed = text.trim();
const isJson =
(trimmed.startsWith("{") && trimmed.endsWith("}")) ||
(trimmed.startsWith("[") && trimmed.endsWith("]"));
// Use "pasted.txt" as filename to trigger content-based detection
await processContent(text, isJson, "pasted.txt");
}
};
document.addEventListener("paste", handlePaste);
return () => {
document.removeEventListener("paste", handlePaste);
};
}, [isOpen, step, processContent]);
const handleImport = useCallback(async () => {
setIsImporting(true);
try {
const result = await invoke<ProxyImportResult>(
"import_proxies_from_parsed",
{
parsedProxies,
namePrefix: namePrefix.trim() || "Imported",
},
);
setImportResult(result);
setStep("result");
await emit("stored-proxies-changed");
} catch (error) {
console.error("Failed to import proxies:", error);
toast.error(
error instanceof Error ? error.message : "Failed to import proxies",
);
} finally {
setIsImporting(false);
}
}, [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) =>
prev.map((p, i) =>
i === index ? { ...p, selectedFormat: format } : p,
),
);
},
[],
);
const handleResolveAmbiguous = useCallback(() => {
// Convert ambiguous proxies to parsed based on selected format
const resolved: ParsedProxyLine[] = ambiguousProxies
.filter((p) => p.selectedFormat)
.map((p) => {
const parts = p.line.split(":");
if (p.selectedFormat === "host:port:username:password") {
return {
proxy_type: "http",
host: parts[0],
port: Number.parseInt(parts[1], 10),
username: parts[2],
password: parts[3],
original_line: p.line,
};
}
// username:password:host:port
return {
proxy_type: "http",
host: parts[2],
port: Number.parseInt(parts[3], 10),
username: parts[0],
password: parts[1],
original_line: p.line,
};
});
setParsedProxies((prev) => [...prev, ...resolved]);
setStep("preview");
}, [ambiguousProxies]);
const handleClose = useCallback(() => {
resetState();
onClose();
}, [resetState, onClose]);
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>
{step === "vpn-preview" || step === "vpn-result"
? "Import VPN Config"
: "Import Proxies"}
</DialogTitle>
<DialogDescription>
{step === "dropzone" &&
"Import proxies from a JSON or TXT file, or VPN configs (.conf/.ovpn)"}
{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>
{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("proxy-file-input")?.click()
}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
document.getElementById("proxy-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 proxy or VPN config file
<br />
<span className="text-xs">(.json, .txt, .conf, .ovpn)</span>
</p>
<input
id="proxy-file-input"
type="file"
accept=".json,.txt,.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 === "preview" && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name-prefix">Name Prefix</Label>
<Input
id="name-prefix"
placeholder="Imported"
value={namePrefix}
onChange={(e) => setNamePrefix(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Proxies will be named &quot;{namePrefix || "Imported"} Proxy
1&quot;, &quot;{namePrefix || "Imported"} Proxy 2&quot;, etc.
</p>
</div>
<div className="space-y-2">
<Label>
Proxies to import ({parsedProxies.length})
{invalidProxies.length > 0 && (
<span className="text-muted-foreground ml-2">
({invalidProxies.length} invalid)
</span>
)}
</Label>
<ScrollArea className="h-[200px] border rounded-md">
<div className="p-2 space-y-1">
{parsedProxies.map((proxy, i) => (
<div
key={`${proxy.original_line}-${i}`}
className="text-xs font-mono p-2 bg-muted/30 rounded"
>
<span className="text-primary">
{proxy.proxy_type}://
</span>
{proxy.username && (
<span className="text-muted-foreground">
{proxy.username}:***@
</span>
)}
<span>
{proxy.host}:{proxy.port}
</span>
</div>
))}
</div>
</ScrollArea>
</div>
</div>
)}
{step === "ambiguous" && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
The following proxies have an ambiguous format. Please select the
correct interpretation for each.
</p>
<ScrollArea className="h-[250px] border rounded-md">
<div className="p-3 space-y-4">
{ambiguousProxies.map((proxy, i) => (
<div
key={`${proxy.line}-${i}`}
className="space-y-2 pb-3 border-b last:border-0"
>
<code className="text-xs bg-muted px-2 py-1 rounded block">
{proxy.line}
</code>
<div className="flex flex-col gap-2">
{proxy.possible_formats.map((format) => (
<label
key={format}
className="flex items-center gap-2 cursor-pointer"
>
<input
type="radio"
name={`format-${i}`}
checked={proxy.selectedFormat === format}
onChange={() =>
handleAmbiguousFormatSelect(i, format)
}
className="accent-primary"
/>
<span className="text-xs">{format}</span>
</label>
))}
</div>
</div>
))}
</div>
</ScrollArea>
</div>
)}
{step === "result" && importResult && (
<div className="space-y-4">
<div className="p-4 bg-muted/30 rounded-lg space-y-2">
<div className="flex justify-between">
<span className="text-sm">Imported:</span>
<span className="text-sm font-medium text-green-600 dark:text-green-400">
{importResult.imported_count}
</span>
</div>
{importResult.skipped_count > 0 && (
<div className="flex justify-between">
<span className="text-sm">Skipped (duplicates):</span>
<span className="text-sm font-medium text-yellow-600 dark:text-yellow-400">
{importResult.skipped_count}
</span>
</div>
)}
{importResult.errors.length > 0 && (
<div className="flex justify-between">
<span className="text-sm">Errors:</span>
<span className="text-sm font-medium text-red-600 dark:text-red-400">
{importResult.errors.length}
</span>
</div>
)}
</div>
{importResult.errors.length > 0 && (
<div className="space-y-2">
<Label>Errors</Label>
<ScrollArea className="h-[100px] border rounded-md">
<div className="p-2 space-y-1">
{importResult.errors.map((error, i) => (
<div
key={`error-${i}`}
className="text-xs text-red-600 dark:text-red-400"
>
{error}
</div>
))}
</div>
</ScrollArea>
</div>
)}
</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 === "preview" && (
<>
<RippleButton variant="outline" onClick={resetState}>
Back
</RippleButton>
<LoadingButton
isLoading={isImporting}
onClick={() => void handleImport()}
disabled={parsedProxies.length === 0}
>
Import {parsedProxies.length} Proxies
</LoadingButton>
</>
)}
{step === "ambiguous" && (
<>
<RippleButton variant="outline" onClick={resetState}>
Back
</RippleButton>
<RippleButton
onClick={handleResolveAmbiguous}
disabled={ambiguousProxies.some((p) => !p.selectedFormat)}
>
Continue
</RippleButton>
</>
)}
{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>
);
}
+35 -4
View File
@@ -4,10 +4,12 @@ import { invoke } from "@tauri-apps/api/core";
import { emit, listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import { GoPlus } from "react-icons/go";
import { LuPencil, LuTrash2 } from "react-icons/lu";
import { LuDownload, LuPencil, LuTrash2, LuUpload } from "react-icons/lu";
import { toast } from "sonner";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
import { ProxyExportDialog } from "@/components/proxy-export-dialog";
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
import { ProxyImportDialog } from "@/components/proxy-import-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@@ -19,7 +21,6 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Table,
@@ -82,6 +83,8 @@ export function ProxyManagementDialog({
onClose,
}: ProxyManagementDialogProps) {
const [showProxyForm, setShowProxyForm] = useState(false);
const [showImportDialog, setShowImportDialog] = useState(false);
const [showExportDialog, setShowExportDialog] = useState(false);
const [editingProxy, setEditingProxy] = useState<StoredProxy | null>(null);
const [proxyToDelete, setProxyToDelete] = useState<StoredProxy | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
@@ -221,9 +224,29 @@ export function ProxyManagementDialog({
</DialogHeader>
<div className="space-y-4">
{/* Create new proxy button */}
{/* Proxy actions */}
<div className="flex justify-between items-center">
<Label>Proxies</Label>
<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>
<RippleButton
size="sm"
onClick={handleCreateProxy}
@@ -414,6 +437,14 @@ export function ProxyManagementDialog({
confirmButtonText="Delete"
isLoading={isDeleting}
/>
<ProxyImportDialog
isOpen={showImportDialog}
onClose={() => setShowImportDialog(false)}
/>
<ProxyExportDialog
isOpen={showExportDialog}
onClose={() => setShowExportDialog(false)}
/>
</>
);
}
+96 -2
View File
@@ -4,6 +4,7 @@ import { invoke } from "@tauri-apps/api/core";
import Color from "color";
import { useTheme } from "next-themes";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { BsCamera, BsMic } from "react-icons/bs";
import { LoadingButton } from "@/components/loading-button";
import { Badge } from "@/components/ui/badge";
@@ -37,6 +38,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { useCommercialTrial } from "@/hooks/use-commercial-trial";
import { useLanguage } from "@/hooks/use-language";
import type { PermissionType } from "@/hooks/use-permissions";
import { usePermissions } from "@/hooks/use-permissions";
import {
@@ -112,6 +114,7 @@ export function SettingsDialog({
useState<PermissionType | null>(null);
const [isMacOS, setIsMacOS] = useState(false);
const { t } = useTranslation();
const { setTheme } = useTheme();
const {
requestPermission,
@@ -119,6 +122,14 @@ export function SettingsDialog({
isCameraAccessGranted,
} = usePermissions();
const { trialStatus } = useCommercialTrial();
const {
currentLanguage,
changeLanguage,
supportedLanguages,
isLoading: isLanguageLoading,
} = useLanguage();
const [selectedLanguage, setSelectedLanguage] = useState<string | null>(null);
const [originalLanguage, setOriginalLanguage] = useState<string | null>(null);
const getPermissionIcon = useCallback((type: PermissionType) => {
switch (type) {
@@ -316,9 +327,26 @@ export function SettingsDialog({
: settings.custom_theme,
};
console.log("[settings-dialog] Saving settings:", {
theme: settingsToSave.theme,
hasCustomTheme: !!settingsToSave.custom_theme,
customThemeKeys: settingsToSave.custom_theme
? Object.keys(settingsToSave.custom_theme).length
: 0,
});
const savedSettings = await invoke<AppSettings>("save_app_settings", {
settings: settingsToSave,
});
console.log("[settings-dialog] Saved settings response:", {
theme: savedSettings.theme,
hasCustomTheme: !!savedSettings.custom_theme,
customThemeKeys: savedSettings.custom_theme
? Object.keys(savedSettings.custom_theme).length
: 0,
});
// Update settings with any generated tokens
setSettings(savedSettings);
settingsToSave = savedSettings;
@@ -350,6 +378,23 @@ export function SettingsDialog({
} catch {}
}
// Save language if changed
if (selectedLanguage !== originalLanguage) {
await changeLanguage(
selectedLanguage === "system"
? null
: (selectedLanguage as
| "en"
| "es"
| "pt"
| "fr"
| "zh"
| "ja"
| "ru"),
);
setOriginalLanguage(selectedLanguage);
}
setOriginalSettings(settingsToSave);
onClose();
} catch (error) {
@@ -357,7 +402,15 @@ export function SettingsDialog({
} finally {
setIsSaving(false);
}
}, [onClose, setTheme, settings, customThemeState]);
}, [
onClose,
setTheme,
settings,
customThemeState,
selectedLanguage,
originalLanguage,
changeLanguage,
]);
const updateSetting = useCallback(
(
@@ -428,6 +481,14 @@ export function SettingsDialog({
}
}, [isOpen, loadPermissions, checkDefaultBrowserStatus, loadSettings]);
// Initialize language selection when dialog opens or language loads
useEffect(() => {
if (isOpen && !isLanguageLoading) {
setSelectedLanguage(currentLanguage);
setOriginalLanguage(currentLanguage);
}
}, [isOpen, currentLanguage, isLanguageLoading]);
// Update permissions when the permission states change
useEffect(() => {
if (isMacOS) {
@@ -458,6 +519,7 @@ export function SettingsDialog({
const hasChanges =
settings.theme !== originalSettings.theme ||
settings.api_enabled !== originalSettings.api_enabled ||
selectedLanguage !== originalLanguage ||
(settings.theme === "custom" &&
JSON.stringify(customThemeState.colors) !==
JSON.stringify(originalSettings.custom_theme ?? {})) ||
@@ -469,7 +531,7 @@ export function SettingsDialog({
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-md max-h-[80vh] my-8 flex flex-col">
<DialogHeader className="shrink-0">
<DialogTitle>Settings</DialogTitle>
<DialogTitle>{t("settings.title")}</DialogTitle>
</DialogHeader>
<div className="grid overflow-y-auto flex-1 gap-6 py-4 min-h-0">
@@ -625,6 +687,38 @@ export function SettingsDialog({
)}
</div>
{/* Language Section */}
<div className="space-y-4">
<Label className="text-base font-medium">Language</Label>
<div className="grid gap-2">
<Label htmlFor="language-select" className="text-sm">
Interface Language
</Label>
<Select
value={selectedLanguage || "system"}
onValueChange={(value) => setSelectedLanguage(value)}
disabled={isLanguageLoading}
>
<SelectTrigger id="language-select">
<SelectValue placeholder="Select language" />
</SelectTrigger>
<SelectContent>
<SelectItem value="system">System Default</SelectItem>
{supportedLanguages.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
{lang.nativeName} ({lang.name})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground">
Choose your preferred language for the application interface.
</p>
</div>
{/* Default Browser Section */}
<div className="space-y-4">
<div className="flex justify-between items-center">
+8
View File
@@ -31,6 +31,14 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
const settings = await invoke<AppSettings>("get_app_settings");
const themeValue = settings?.theme ?? "system";
console.log("[theme-provider] Loaded settings:", {
theme: themeValue,
hasCustomTheme: !!settings?.custom_theme,
customThemeKeys: settings?.custom_theme
? Object.keys(settings.custom_theme).length
: 0,
});
if (
themeValue === "light" ||
themeValue === "dark" ||