79c9cb48aa
Home теперь рендерится только когда isLoggedIn — до этого юзер видит полноэкранный Login (анонимно/seed/Telegram), а не экран подключения; кнопка VPN дополнительно блокируется на уровне VpnControl как defense-in-depth. isLoggedIn — отдельный персистентный флаг, не совпадает с фактом наличия фонового Ghost-токена (тот может тихо появиться до явного логина). Заодно: matrix-engine в package.json указывал на путь другого пользователя (/home/kirill/...) — поправил под текущую машину; прогнал `tauri icon`, чтобы Android больше не показывал дефолтный логотип Tauri (иконка никогда не генерировалась для этой платформы); dev-узел Singapore (DEV_IP) переведён на порт 8443 вслед за прокси. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
130 lines
4.5 KiB
TypeScript
130 lines
4.5 KiB
TypeScript
import * as React from "react";
|
||
import { motion, AnimatePresence } from "framer-motion";
|
||
import { cn } from "@/lib/utils";
|
||
import { useTranslation } from "react-i18next";
|
||
import { TunnelConfig, startVpn, stopVpn } from "vpn-plugin";
|
||
import { useVpnStore } from "@/store/useVpnStore";
|
||
import { useEffect } from "react";
|
||
import { listen } from "@tauri-apps/api/event";
|
||
import VpnControlButton from "@/components/shared/VpnControlButton";
|
||
import { useTheme } from "@/ThemeProvider";
|
||
import { useSettingsStore } from "@/store/useSettingsStore";
|
||
import { useNodesStore } from "@/store/useNodesStore";
|
||
import { useAuthStore } from "@/store/useAuthStore";
|
||
import { getCachedToken } from "@/lib/api";
|
||
|
||
export function VpnControl() {
|
||
const { status, setStatus } = useVpnStore();
|
||
const { t } = useTranslation();
|
||
const { killSwitch, excludedApps, excludedDomains } = useSettingsStore();
|
||
const { getSelectedNode } = useNodesStore();
|
||
const isLoggedIn = useAuthStore((s) => s.isLoggedIn);
|
||
|
||
// Подключаем тему
|
||
const { isDark } = useTheme();
|
||
const currentTheme = isDark ? "dark" : "light";
|
||
|
||
const handleToggle = async () => {
|
||
// Защита на всякий случай — основной гейт на уровне роута ("/" рендерит
|
||
// Login, а не Home, пока не вошли), но кнопка не должна работать даже
|
||
// если это состояние как-то обошли.
|
||
if (!isLoggedIn) return;
|
||
try {
|
||
if (status === "idle") {
|
||
setStatus("connecting");
|
||
|
||
// Параметры подключения берём из выбранного на бэкенде узла.
|
||
const node = getSelectedNode();
|
||
|
||
const config: TunnelConfig = {
|
||
address: node.tunnelAddress,
|
||
prefix: node.tunnelPrefix,
|
||
dns: node.tunnelDns,
|
||
mtu: node.tunnelMtu,
|
||
killswitchEnabled: killSwitch,
|
||
excludedApps: excludedApps,
|
||
excludedDomains: excludedDomains,
|
||
// Есть только если сервер требует `--require-auth`; на инстансах без
|
||
// этого флага прокси значение просто игнорирует.
|
||
authToken: getCachedToken() ?? undefined,
|
||
};
|
||
await startVpn(config, node.ipAddress, node.port, node.sni);
|
||
} else {
|
||
await stopVpn();
|
||
}
|
||
} catch (error) {
|
||
console.error("VPN Toggle Error:", error);
|
||
setStatus("idle");
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
let unlisten: (() => void) | null = null;
|
||
|
||
async function setupListener() {
|
||
unlisten = await listen<{ status: string }>("status-change", (event) => {
|
||
const newStatus = event.payload.status as
|
||
| "idle"
|
||
| "connecting"
|
||
| "connected";
|
||
|
||
if (newStatus) {
|
||
setStatus(newStatus);
|
||
}
|
||
});
|
||
}
|
||
|
||
setupListener();
|
||
return () => {
|
||
if (unlisten) unlisten();
|
||
};
|
||
}, [setStatus]);
|
||
|
||
const isActive = status !== "idle";
|
||
|
||
const statusMap = {
|
||
idle: t("disconnected"),
|
||
connecting: t("connecting"),
|
||
connected: t("connected"),
|
||
};
|
||
|
||
return (
|
||
<div className="flex flex-col items-center justify-center gap-6 p-10">
|
||
<div className="relative flex items-center justify-center size-75">
|
||
<VpnControlButton
|
||
// Теперь прокидываем реальную тему
|
||
theme={currentTheme}
|
||
isOn={isActive}
|
||
onClick={handleToggle}
|
||
style={{
|
||
pointerEvents: status === "connecting" || !isLoggedIn ? "none" : "auto",
|
||
opacity: !isLoggedIn ? 0.4 : status === "connecting" ? 0.8 : 1,
|
||
cursor: !isLoggedIn ? "not-allowed" : undefined,
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<div className="text-center space-y-1">
|
||
<p className="text-sm uppercase tracking-widest text-muted-foreground font-semibold">
|
||
{t("status_label")}
|
||
</p>
|
||
<AnimatePresence mode="wait">
|
||
<motion.p
|
||
key={status}
|
||
initial={{ opacity: 0, y: 10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: -10 }}
|
||
className={cn(
|
||
"text-xl font-bold tracking-tight",
|
||
status === "connected" ? "text-primary" : "text-foreground",
|
||
status === "connecting" && "animate-pulse opacity-70",
|
||
)}
|
||
>
|
||
{statusMap[status]}
|
||
</motion.p>
|
||
</AnimatePresence>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|