Files
ProxyWarden/src/app/App.tsx
T

1774 lines
56 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
} from "react";
import { open } from "@tauri-apps/plugin-dialog";
import {
Cpu,
FileCode2,
FolderOpen,
Gauge,
Link2,
ShieldAlert,
Trash2,
Wand2,
} from "lucide-react";
import {
applyConfiguration,
configureProxiFyreFirewallRules,
fetchSingBoxSubscription,
forgetSingBoxSubscription,
generateSingBoxConfig,
getComponents,
getProxiFyreSetupProgress,
getProxiFyreSetupStatus,
getSavedState,
getSingBoxSetupStatus,
getSingBoxStatus,
getStartupSnapshot,
installProxiFyre,
installSingBox,
pingAllSingBoxServers,
pingProxyTarget,
pingSingBoxServer,
restartAsAdmin,
saveSingBoxSubscription,
selectSingBoxServer,
startProxiFyreService,
startSingBoxService,
stopProxiFyreService,
stopSingBoxService,
uninstallProxiFyre,
uninstallSingBox,
type AdminStatusResponse,
type LocalSingBoxStatusResponse,
type PingServerResponse,
type ProxyTargetCheckResponse,
type ProxiFyreSetupProgress,
type ProxiFyreSetupStatus,
type SingBoxSetupStatus,
} from "../api/tauriCommands";
import type {
ComponentStatus,
Profile,
SubscriptionServer,
Target,
} from "../domain/types";
import {
BusyRing,
Button,
DetailsPopover,
IconButton,
LogDock,
ServiceControlRow,
Tabs,
} from "../ui";
import { ProxiFyreSetupStrip } from "./components/ProxiFyreSetupStrip";
import { SummaryStatusControl } from "./components/SummaryStatusControl";
import { ConnectionCheckPanel } from "./components/ConnectionCheckPanel";
import { AppList } from "./components/AppList";
import { useNoticeLog } from "./hooks/useNoticeLog";
import { parseProxy, type ParsedProxy } from "./lib/parseProxy";
import { normalizeItemValue, type DraftItemType } from "./lib/profileItems";
import {
configChangeRows,
configSnapshotFromUi,
displayServerTag,
type ConfigSnapshot,
type RouteMode,
} from "./lib/snapshots";
import { getApplyReadiness } from "./readiness";
import {
serviceControlState,
systemSummaryState,
connectionCheckView,
summaryRouteFlow,
summaryRouteChainSegments,
routeChainSegments,
safeProxyError,
pingTone,
proxyCheckNoticeKind,
proxyCheckNoticeTitle,
proxyCheckText,
changesApplyButtonLabel,
routeProxyCheckTarget,
targetForUi,
targetForExternalProxy,
itemsForProfiles,
formatProxy,
profileItemInput,
emptyItemMessage,
localSetupProgress,
proxyfierTitle,
proxyfierDetails,
singBoxDetails,
singBoxDetailLines,
componentDetails,
noticeFromConfigurationApply,
upsertComponent,
sameValue,
formatLogTime,
serverTooltip,
pingSummary,
errorMessage,
type DraftItem,
type RouteChainInput,
} from "./viewModel";
type ProxiFyreAction = "start" | "stop" | "restart" | "install" | "uninstall";
type SingBoxAction =
| "start"
| "stop"
| "install"
| "uninstall"
| "fetch"
| "forget"
| "generate"
| "ping";
type ServiceVisualState = "active" | "settling" | null;
type PanelId = "summary" | "proxifyre" | "proxy";
type TabTransitionDirection = "left" | "right";
const MAIN_TARGET_ID = "main-proxy";
const MAIN_PROFILE_ID = "main-profile";
const LOCAL_SINGBOX_TARGET_ID = "local-singbox";
const PANEL_ORDER: PanelId[] = ["proxifyre", "summary", "proxy"];
const SHOW_DEV_SUBSCRIPTION_IDENTITY = import.meta.env.DEV;
const fallbackComponents: ComponentStatus[] = [
{
id: "proxyfier",
name: "ProxiFyre",
state: "missing",
installed: false,
running: false,
problems: ["ProxiFyre не найден"],
actions: [],
},
{
id: "singbox",
name: "Локальный sing-box",
state: "missing",
installed: false,
running: false,
problems: [],
actions: [],
},
];
export function App() {
const [activePanel, setActivePanel] = useState<PanelId>("summary");
const [tabTransitionDirection, setTabTransitionDirection] =
useState<TabTransitionDirection>("right");
const [proxyInput, setProxyInput] = useState("");
const [routeMode, setRouteMode] = useState<RouteMode>("external");
const [profileId, setProfileId] = useState(MAIN_PROFILE_ID);
const [targetId, setTargetId] = useState(MAIN_TARGET_ID);
const [items, setItems] = useState<DraftItem[]>([]);
const [appliedSnapshot, setAppliedSnapshot] = useState<ConfigSnapshot | null>(
null,
);
const [isProcessInputOpen, setIsProcessInputOpen] = useState(false);
const [processInput, setProcessInput] = useState("");
const [pickerAction, setPickerAction] = useState<"exe" | "folder" | null>(
null,
);
const [components, setComponents] =
useState<ComponentStatus[]>(fallbackComponents);
const [setupStatus, setSetupStatus] = useState<ProxiFyreSetupStatus | null>(
null,
);
const [setupProgress, setSetupProgress] =
useState<ProxiFyreSetupProgress | null>(null);
const [singBoxStatus, setSingBoxStatus] =
useState<LocalSingBoxStatusResponse | null>(null);
const [singBoxSetupStatus, setSingBoxSetupStatus] =
useState<SingBoxSetupStatus | null>(null);
const [subscriptionInput, setSubscriptionInput] = useState("");
const [serverPings, setServerPings] = useState<
Record<string, PingServerResponse>
>({});
const [proxyCheck, setProxyCheck] = useState<ProxyTargetCheckResponse | null>(
null,
);
const [adminStatus, setAdminStatus] = useState<AdminStatusResponse | null>(
null,
);
const [, setGeneratedConfigPath] = useState("");
const {
entries: logEntries,
activeEntry: activeLog,
open: isLogOpen,
showNotice,
toggle: toggleLog,
} = useNoticeLog();
const [isLoading, setIsLoading] = useState(true);
const [isDetectingComponents, setIsDetectingComponents] = useState(true);
const [isApplying, setIsApplying] = useState(false);
const [isRestartingAsAdmin, setIsRestartingAsAdmin] = useState(false);
const [isProxyChecking, setIsProxyChecking] = useState(false);
const [serverPingTag, setServerPingTag] = useState<string | null>(null);
const [serviceAction, setServiceAction] = useState<ProxiFyreAction | null>(
null,
);
const [singBoxAction, setSingBoxAction] = useState<SingBoxAction | null>(
null,
);
const [isServiceMenuOpen, setIsServiceMenuOpen] = useState(false);
const [isSingBoxMenuOpen, setIsSingBoxMenuOpen] = useState(false);
const [serviceVisualState, setServiceVisualState] =
useState<ServiceVisualState>(null);
const serviceVisualTimerRef = useRef<number | null>(null);
const proxyfier = useMemo(
() => components.find((component) => component.id === "proxyfier"),
[components],
);
const singbox = useMemo(
() =>
singBoxStatus?.component ??
components.find((component) => component.id === "singbox"),
[components, singBoxStatus],
);
const isSingBoxInstalled = Boolean(singbox?.installed);
const selectedServerTag = singBoxStatus?.config.selectedServerTag;
const selectedServerId = singBoxStatus?.config.selectedServerId;
const selectedServer = useMemo(
() =>
singBoxStatus?.cache?.servers.find((server) =>
selectedServerId
? server.id === selectedServerId
: server.tag === selectedServerTag,
) ?? null,
[selectedServerId, selectedServerTag, singBoxStatus],
);
const currentSnapshot = useMemo(
() =>
configSnapshotFromUi(
routeMode,
proxyInput,
items,
selectedServerId,
selectedServerTag,
),
[items, proxyInput, routeMode, selectedServerId, selectedServerTag],
);
const pendingChanges = useMemo(
() =>
appliedSnapshot ? configChangeRows(appliedSnapshot, currentSnapshot) : [],
[appliedSnapshot, currentSnapshot],
);
const hasUnappliedChanges = pendingChanges.length > 0;
const hasAdminPrompt = Boolean(adminStatus?.canRestartElevated);
const shellStyle = hasUnappliedChanges
? ({ "--change-row-count": String(pendingChanges.length) } as CSSProperties)
: undefined;
const systemSummary = systemSummaryState({
isLoading,
isDetectingComponents,
proxyfier,
singbox,
routeMode: appliedSnapshot?.routeMode ?? routeMode,
singBoxStatus,
proxyCheck,
});
useEffect(() => {
void refresh();
}, []);
useEffect(() => {
return () => {
if (serviceVisualTimerRef.current !== null) {
window.clearTimeout(serviceVisualTimerRef.current);
}
};
}, []);
useEffect(() => {
if (serviceAction !== "install" && serviceAction !== "uninstall")
return undefined;
let cancelled = false;
const pollProgress = async () => {
try {
const progress = await getProxiFyreSetupProgress();
if (!cancelled) setSetupProgress(progress);
} catch {
// Progress is best-effort; the main install/uninstall action still reports the real error.
}
};
void pollProgress();
const timer = window.setInterval(() => void pollProgress(), 650);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [serviceAction]);
async function refresh() {
setIsLoading(true);
setIsDetectingComponents(true);
try {
const [snapshot, progress] = await Promise.all([
getStartupSnapshot(),
getProxiFyreSetupProgress(),
]);
setAdminStatus(snapshot.adminStatus);
setComponents(snapshot.components);
setSetupStatus(snapshot.proxifyreSetupStatus);
setSetupProgress(progress);
setSingBoxStatus(snapshot.singboxStatus);
setSingBoxSetupStatus(snapshot.singboxSetupStatus);
applySavedState(
snapshot.savedState.profiles,
snapshot.savedState.targets,
snapshot.savedState.generatedConfigPath,
snapshot.singboxStatus,
);
} catch {
showNotice({
kind: "info",
title: "Режим предпросмотра",
text: "Запусти приложение через Tauri, чтобы увидеть найденный ProxiFyre и применить конфиг.",
});
} finally {
setIsLoading(false);
setIsDetectingComponents(false);
}
}
async function restartApplicationAsAdmin() {
setIsRestartingAsAdmin(true);
try {
await restartAsAdmin();
setIsRestartingAsAdmin(false);
} catch (error) {
setIsRestartingAsAdmin(false);
showNotice({
kind: "error",
title: "Перезапуск отменен",
text: errorMessage(error),
});
}
}
function applySavedState(
profiles: Profile[],
targets: Target[],
generatedPath: string,
singBoxStatusForSnapshot = singBoxStatus,
) {
const activeProfiles = profiles.filter((profile) => profile.enabled);
const mainProfile = profiles.find(
(profile) => profile.id === MAIN_PROFILE_ID,
);
const activeProfile = mainProfile ?? activeProfiles[0];
const activeTarget = targetForUi(targets, activeProfile);
const externalTarget = targetForExternalProxy(targets);
const editableProfiles = mainProfile ? [mainProfile] : activeProfiles;
const savedProxyInput = externalTarget ? formatProxy(externalTarget) : "";
const savedItems = itemsForProfiles(editableProfiles);
const savedRouteMode =
activeTarget?.id === LOCAL_SINGBOX_TARGET_ID ||
activeProfile?.targetId === LOCAL_SINGBOX_TARGET_ID
? "local-singbox"
: "external";
setProxyInput(savedProxyInput);
setProxyCheck(null);
setItems(savedItems);
setProfileId(mainProfile?.id ?? MAIN_PROFILE_ID);
setTargetId(externalTarget?.id ?? MAIN_TARGET_ID);
setRouteMode(savedRouteMode);
setGeneratedConfigPath(generatedPath);
setAppliedSnapshot(
configSnapshotFromUi(
savedRouteMode,
savedProxyInput,
savedItems,
singBoxStatusForSnapshot?.config.selectedServerId,
singBoxStatusForSnapshot?.config.selectedServerTag,
),
);
}
function addItem(type: DraftItemType, rawValue: string) {
const value = normalizeItemValue(rawValue, type);
if (!value) {
showNotice({
kind: "error",
title: "Нечего добавить",
text: emptyItemMessage(type),
});
return false;
}
if (
items.some((item) => item.type === type && sameValue(item.value, value))
) {
showNotice({
kind: "info",
title: "Уже добавлено",
text: value,
});
return false;
}
setItems((current) => [
...current,
{
id: `${type}-${crypto.randomUUID()}`,
type,
value,
},
]);
return true;
}
function addProcess() {
if (addItem("process", processInput)) {
setProcessInput("");
setIsProcessInputOpen(false);
}
}
function removeItem(id: string) {
setItems((current) => current.filter((item) => item.id !== id));
}
function changeRouteMode(nextMode: RouteMode) {
setRouteMode(nextMode);
setProxyCheck(null);
}
function changeProxyInput(nextValue: string) {
setProxyInput(nextValue);
setProxyCheck(null);
}
function switchPanel(nextPanel: PanelId) {
if (activePanel === nextPanel) return;
const currentIndex = PANEL_ORDER.indexOf(activePanel);
const nextIndex = PANEL_ORDER.indexOf(nextPanel);
setTabTransitionDirection(nextIndex > currentIndex ? "right" : "left");
setActivePanel(nextPanel);
}
async function pickAndAddItem(
type: Extract<DraftItemType, "exe" | "folder">,
) {
setPickerAction(type);
try {
const selectedPath = await pickPath(type);
if (selectedPath) {
addItem(type, selectedPath);
}
} catch (error) {
showNotice({
kind: "error",
title: type === "exe" ? "EXE не выбран" : "Папка не выбрана",
text: errorMessage(error),
});
} finally {
setPickerAction(null);
}
}
async function updateConfig() {
let parsedProxy: ParsedProxy | null = null;
try {
if (!items.length)
throw new Error("Добавь хотя бы один процесс, EXE-файл или папку.");
if (routeMode === "external") {
parsedProxy = parseProxy(proxyInput);
} else if (!isSingBoxInstalled) {
throw new Error("Сначала установи Local sing-box.");
} else if (!singBoxStatus?.config.selectedServerTag) {
throw new Error("Выбери сервер Local sing-box.");
}
} catch (error) {
showNotice({
kind: "error",
title: "Проверь данные",
text: errorMessage(error),
});
return;
}
setIsApplying(true);
try {
if (routeMode === "external" && !parsedProxy)
throw new Error("Прокси не разобран.");
const result = await applyConfiguration({
routeMode,
profile: {
id: profileId,
name: "Приложения через прокси",
enabled: true,
targetId:
routeMode === "local-singbox" ? LOCAL_SINGBOX_TARGET_ID : targetId,
protocols: ["TCP", "UDP"],
items: items.map(profileItemInput),
},
externalTarget: parsedProxy
? {
id: targetId,
name: "Основной прокси",
kind: "external",
protocol: parsedProxy.protocol,
host: parsedProxy.host,
port: parsedProxy.port,
}
: undefined,
disableOtherProfiles: true,
});
const [
saved,
detectedComponents,
detectedSetupStatus,
detectedSingBoxStatus,
detectedSingBoxSetupStatus,
] = await Promise.all([
getSavedState(),
getComponents(),
getProxiFyreSetupStatus(),
getSingBoxStatus(),
getSingBoxSetupStatus(),
]);
applySavedState(
saved.profiles,
saved.targets,
result.generatedConfigPath,
);
setComponents(detectedComponents);
setSetupStatus(detectedSetupStatus);
setSingBoxStatus(detectedSingBoxStatus);
setSingBoxSetupStatus(detectedSingBoxSetupStatus);
showNotice(noticeFromConfigurationApply(result));
} catch (error) {
showNotice({
kind: "error",
title: "Конфиг не обновлен",
text: errorMessage(error),
});
} finally {
setIsApplying(false);
}
}
async function setProxiFyreServiceRunning(shouldRun: boolean) {
const action = shouldRun ? "start" : "stop";
setServiceAction(action);
setIsServiceMenuOpen(false);
startServiceVisual();
try {
await nextFrame();
const component = shouldRun
? await startProxiFyreService()
: await stopProxiFyreService();
setComponents((current) => upsertComponent(current, component));
showNotice({
kind: "success",
title: shouldRun ? "Служба запущена" : "Служба остановлена",
text: proxyfierDetails(component, false),
});
} catch (error) {
showNotice({
kind: "error",
title: shouldRun ? "Служба не запущена" : "Служба не остановлена",
text: errorMessage(error),
});
} finally {
setServiceAction(null);
settleServiceVisual();
}
}
async function installProxiFyrePackage() {
const confirmed = window.confirm(
"Установить ProxiFyre? ProxyWarden запросит права администратора, установит ProxiFyre, Windows Packet Filter и при необходимости Visual C++ Runtime, затем создаст и запустит Windows-службу. После установки приложение отдельно предложит добавить правила Windows Firewall.",
);
if (!confirmed) return;
setServiceAction("install");
setIsServiceMenuOpen(false);
setSetupProgress(
localSetupProgress(
"install",
"packet-filter",
1,
"Готовлю установку сетевого драйвера.",
),
);
startServiceVisual();
try {
await nextFrame();
const component = await installProxiFyre();
const [detectedSetupStatus, detectedProgress] = await Promise.all([
getProxiFyreSetupStatus(),
getProxiFyreSetupProgress(),
]);
setComponents((current) => upsertComponent(current, component));
setSetupStatus(detectedSetupStatus);
setSetupProgress(detectedProgress);
showNotice({
kind: "success",
title: "ProxiFyre установлен",
text: proxyfierDetails(component, false),
});
const firewallConfirmed = window.confirm(
"Добавить разрешающие правила Windows Firewall для установленного ProxiFyre.exe? Будут созданы отдельные входящее и исходящее правила только для подтвержденного пути программы. Windows снова запросит права администратора.",
);
if (firewallConfirmed) {
try {
await configureProxiFyreFirewallRules();
const firewallProgress = await getProxiFyreSetupProgress();
setSetupProgress(firewallProgress);
showNotice({
kind: "success",
title: "Windows Firewall настроен",
text: "Входящее и исходящее правила добавлены для установленного ProxiFyre.exe.",
});
} catch (error) {
showNotice({
kind: "error",
title: "Windows Firewall не настроен",
text: `ProxiFyre установлен, но правила не добавлены. ${errorMessage(error)}`,
});
}
} else {
showNotice({
kind: "info",
title: "Правила Firewall пропущены",
text: "ProxiFyre установлен без правил Windows Firewall. Их можно будет добавить повторной установкой компонента.",
});
}
} catch (error) {
void getProxiFyreSetupProgress()
.then(setSetupProgress)
.catch(() => undefined);
showNotice({
kind: "error",
title: "ProxiFyre не установлен",
text: errorMessage(error),
});
} finally {
setServiceAction(null);
settleServiceVisual();
}
}
async function uninstallProxiFyrePackage() {
const confirmed = window.confirm(
"Удалить ProxiFyre и Windows Packet Filter с компьютера? Это остановит службу, удалит папку ProxiFyre и сетевой драйвер. Другие программы WireSock могут перестать работать до повторной установки драйвера.",
);
if (!confirmed) return;
setServiceAction("uninstall");
setIsServiceMenuOpen(false);
setSetupProgress(
localSetupProgress(
"uninstall",
"proxifyre",
1,
"Готовлю удаление ProxiFyre и сетевого драйвера.",
),
);
startServiceVisual();
try {
await nextFrame();
const component = await uninstallProxiFyre();
const [detectedSetupStatus, detectedProgress] = await Promise.all([
getProxiFyreSetupStatus(),
getProxiFyreSetupProgress(),
]);
setComponents((current) => upsertComponent(current, component));
setSetupStatus(detectedSetupStatus);
setSetupProgress(detectedProgress);
showNotice({
kind: "success",
title: "ProxiFyre удален",
text: "Служба, папка установки ProxiFyre и Windows Packet Filter удалены.",
});
} catch (error) {
void getProxiFyreSetupProgress()
.then(setSetupProgress)
.catch(() => undefined);
showNotice({
kind: "error",
title: "ProxiFyre не удален",
text: errorMessage(error),
});
} finally {
setServiceAction(null);
settleServiceVisual();
}
}
async function refreshSingBoxState() {
const [
detectedSingBoxStatus,
detectedSingBoxSetupStatus,
detectedComponents,
] = await Promise.all([
getSingBoxStatus(),
getSingBoxSetupStatus(),
getComponents(),
]);
setSingBoxStatus(detectedSingBoxStatus);
setSingBoxSetupStatus(detectedSingBoxSetupStatus);
setComponents(detectedComponents);
return detectedSingBoxStatus;
}
async function setSingBoxServiceRunning(shouldRun: boolean) {
const action: SingBoxAction = shouldRun ? "start" : "stop";
setSingBoxAction(action);
setIsSingBoxMenuOpen(false);
try {
await nextFrame();
const component = shouldRun
? await startSingBoxService()
: await stopSingBoxService();
setComponents((current) => upsertComponent(current, component));
await refreshSingBoxState();
setProxyCheck(null);
showNotice({
kind: "success",
title: shouldRun ? "sing-box запущен" : "sing-box остановлен",
text: componentDetails(component, false),
});
} catch (error) {
showNotice({
kind: "error",
title: shouldRun ? "sing-box не запущен" : "sing-box не остановлен",
text: errorMessage(error),
});
} finally {
setSingBoxAction(null);
}
}
async function installSingBoxPackage() {
const confirmed = window.confirm(
"Установить Local sing-box? ProxyWarden запросит права администратора, скачает sing-box и WinSW, затем создаст и запустит Windows-службу ProxyWardenSingBox.",
);
if (!confirmed) return;
setSingBoxAction("install");
setIsSingBoxMenuOpen(false);
try {
await nextFrame();
const component = await installSingBox();
setComponents((current) => upsertComponent(current, component));
await refreshSingBoxState();
setProxyCheck(null);
showNotice({
kind: "success",
title: "Local sing-box установлен",
text: componentDetails(component, false),
});
} catch (error) {
showNotice({
kind: "error",
title: "Local sing-box не установлен",
text: errorMessage(error),
});
} finally {
setSingBoxAction(null);
}
}
async function uninstallSingBoxPackage() {
const confirmed = window.confirm(
"Удалить Local sing-box с компьютера? Будет удалена служба и папка установки sing-box.",
);
if (!confirmed) return;
setSingBoxAction("uninstall");
setIsSingBoxMenuOpen(false);
try {
await nextFrame();
const component = await uninstallSingBox();
setComponents((current) => upsertComponent(current, component));
await refreshSingBoxState();
setProxyCheck(null);
showNotice({
kind: "success",
title: "Local sing-box удален",
text: "Служба и папка установки Local sing-box удалены.",
});
} catch (error) {
showNotice({
kind: "error",
title: "Local sing-box не удален",
text: errorMessage(error),
});
} finally {
setSingBoxAction(null);
}
}
async function syncSingBoxSubscription() {
const subscriptionUrl = subscriptionInput.trim();
if (!subscriptionUrl && !singBoxStatus?.config.hasSubscription) {
showNotice({
kind: "error",
title: "Ссылка не указана",
text: "Вставь ссылку подписки Local sing-box.",
});
return;
}
setSingBoxAction("fetch");
try {
if (subscriptionUrl) {
await saveSingBoxSubscription(subscriptionUrl);
}
const status = await fetchSingBoxSubscription();
setSingBoxStatus(status);
setComponents((current) => upsertComponent(current, status.component));
setSubscriptionInput("");
setServerPings({});
setProxyCheck(null);
showNotice({
kind: "success",
title: "Подписка обновлена",
text: `Серверов: ${status.cache?.servers.length ?? 0}`,
});
} catch (error) {
showNotice({
kind: "error",
title: "Подписка не обновлена",
text: errorMessage(error),
});
} finally {
setSingBoxAction(null);
}
}
async function forgetSingBoxSubscriptionData() {
setSingBoxAction("forget");
setIsSingBoxMenuOpen(false);
try {
const status = await forgetSingBoxSubscription();
setSingBoxStatus(status);
setComponents((current) => upsertComponent(current, status.component));
setServerPings({});
setProxyCheck(null);
showNotice({
kind: "info",
title: "Подписка очищена",
text: "Ссылка, cache и выбранный сервер Local sing-box удалены.",
});
} catch (error) {
showNotice({
kind: "error",
title: "Подписка не очищена",
text: errorMessage(error),
});
} finally {
setSingBoxAction(null);
}
}
async function chooseSingBoxServer(server: SubscriptionServer) {
try {
const status = await selectSingBoxServer(server);
setSingBoxStatus(status);
setComponents((current) => upsertComponent(current, status.component));
setProxyCheck(null);
} catch (error) {
showNotice({
kind: "error",
title: "Сервер не выбран",
text: errorMessage(error),
});
}
}
async function pingSingBoxServers() {
setSingBoxAction("ping");
try {
const results = await pingAllSingBoxServers();
setServerPings(
Object.fromEntries(results.map((result) => [result.id, result])),
);
showNotice({
kind: "info",
title: "Ping завершен",
text: pingSummary(results),
});
} catch (error) {
showNotice({
kind: "error",
title: "Ping не выполнен",
text: errorMessage(error),
});
} finally {
setSingBoxAction(null);
}
}
async function pingSingleSingBoxServer(server: SubscriptionServer) {
setServerPingTag(server.id);
try {
const result = await pingSingBoxServer(server);
setServerPings((current) => ({
...current,
[result.id]: result,
}));
showNotice({
kind: result.ok ? "success" : "error",
title: result.ok ? "Сервер отвечает" : "Сервер не ответил",
text: serverTooltip(server, result),
});
} catch (error) {
showNotice({
kind: "error",
title: "Ping не выполнен",
text: errorMessage(error),
});
} finally {
setServerPingTag(null);
}
}
async function checkRouteProxy() {
let target: ParsedProxy;
try {
target = routeProxyCheckTarget(routeMode, proxyInput, singBoxStatus);
} catch (error) {
showNotice({
kind: "error",
title: "Маршрут не проверен",
text: errorMessage(error),
});
return;
}
setProxyCheck(null);
setIsProxyChecking(true);
try {
const result = await pingProxyTarget(target.host, target.port);
setProxyCheck(result);
showNotice({
kind: proxyCheckNoticeKind(result),
title: proxyCheckNoticeTitle(result),
text: proxyCheckText(result),
});
} catch (error) {
showNotice({
kind: "error",
title: "Маршрут не проверен",
text: errorMessage(error),
});
} finally {
setIsProxyChecking(false);
}
}
async function generateSingBoxNow() {
setSingBoxAction("generate");
try {
const result = await generateSingBoxConfig();
await refreshSingBoxState();
showNotice({
kind: "success",
title: "Конфиг sing-box создан",
text: result.generatedConfigPath,
});
} catch (error) {
showNotice({
kind: "error",
title: "Конфиг sing-box не создан",
text: errorMessage(error),
});
} finally {
setSingBoxAction(null);
}
}
function startServiceVisual() {
if (serviceVisualTimerRef.current !== null) {
window.clearTimeout(serviceVisualTimerRef.current);
serviceVisualTimerRef.current = null;
}
setServiceVisualState("active");
}
function settleServiceVisual() {
if (serviceVisualTimerRef.current !== null) {
window.clearTimeout(serviceVisualTimerRef.current);
}
setServiceVisualState("settling");
serviceVisualTimerRef.current = window.setTimeout(() => {
setServiceVisualState(null);
serviceVisualTimerRef.current = null;
}, 700);
}
function renderAdminPrompt() {
if (!hasAdminPrompt) return null;
return (
<aside className="admin-prompt" aria-label="Права администратора">
<span className="admin-prompt-icon" aria-hidden="true">
<ShieldAlert size={15} strokeWidth={1.9} />
</span>
<div className="admin-prompt-copy">
<strong>Права администратора</strong>
<span>{adminStatus?.message}</span>
</div>
<Button
type="button"
variant="primary"
size="sm"
className="admin-prompt-action"
onClick={() => void restartApplicationAsAdmin()}
loading={isRestartingAsAdmin}
loadingLabel="Открываю UAC"
>
Перезапустить с правами
</Button>
</aside>
);
}
function renderTabs() {
const tabs: Array<{ id: PanelId; label: string }> = [
{ id: "proxifyre", label: "ProxiFyre" },
{ id: "summary", label: "ProxyWarden" },
{ id: "proxy", label: "VPN / Прокси" },
];
return (
<Tabs
items={tabs}
activeId={activePanel}
onChange={switchPanel}
ariaLabel="Разделы ProxyWarden"
/>
);
}
function renderSummaryPanel() {
return (
<section
className="tab-panel summary-panel"
role="tabpanel"
id="panel-summary"
aria-labelledby="tab-summary"
>
<h1 className="summary-brand-title">ProxyWarden</h1>
<div className="summary-main">
<SummaryStatusControl
installed={Boolean(proxyfier?.installed)}
running={Boolean(proxyfier?.running)}
working={
serviceAction === "start" ||
serviceAction === "stop" ||
serviceAction === "restart"
}
checking={isDetectingComponents || Boolean(serviceAction)}
tone={systemSummary.tone}
onToggle={(running) => void setProxiFyreServiceRunning(running)}
/>
{renderRouteChain("vertical")}
</div>
</section>
);
}
function renderProxiFyreCard() {
const state = serviceControlState(proxyfier, isDetectingComponents);
const visualState =
serviceVisualState === "active"
? "working"
: serviceVisualState === "settling"
? "settling"
: null;
const packetFilterInstalled = Boolean(
setupStatus?.items.some(
(item) => item.id === "packet-filter" && item.installed,
),
);
const canCleanupSetup = Boolean(
proxyfier?.installed || packetFilterInstalled,
);
const shouldInstallProxiFyre =
!proxyfier?.installed || !proxyfier.serviceStatus;
const primaryAction = shouldInstallProxiFyre
? {
label: proxyfier?.installed ? "Переустановить" : "Установить",
onClick: () => void installProxiFyrePackage(),
variant: "primary" as const,
loading: serviceAction === "install",
loadingLabel: "Устанавливаю",
disabled: isDetectingComponents || Boolean(serviceAction),
}
: {
label: proxyfier.running ? "Остановить" : "Запустить",
onClick: () => void setProxiFyreServiceRunning(!proxyfier.running),
variant: proxyfier.running
? ("danger" as const)
: ("neutral" as const),
loading:
serviceAction === "start" ||
serviceAction === "stop" ||
serviceAction === "restart",
loadingLabel:
serviceAction === "restart"
? "Перезапускаю"
: serviceAction === "start"
? "Запускаю"
: "Останавливаю",
disabled: isDetectingComponents || Boolean(serviceAction),
};
return (
<ServiceControlRow
state={state}
visualState={visualState}
className="proxifyre-card"
title={proxyfierTitle(proxyfier, isDetectingComponents)}
detail={proxyfierDetails(proxyfier, isDetectingComponents)}
primaryAction={primaryAction}
menu={
canCleanupSetup
? {
label: "Дополнительные действия ProxiFyre",
open: isServiceMenuOpen,
onOpenChange: setIsServiceMenuOpen,
disabled: isDetectingComponents || Boolean(serviceAction),
items: [
{
label:
serviceAction === "uninstall"
? "Удаляю..."
: "Удалить ProxiFyre и драйвер",
danger: true,
disabled: Boolean(serviceAction),
onClick: () => void uninstallProxiFyrePackage(),
},
],
}
: undefined
}
/>
);
}
function renderProxiFyreSetupStrip() {
return (
<ProxiFyreSetupStrip setupStatus={setupStatus} progress={setupProgress} />
);
}
function renderAppsSection() {
return (
<section className="apps-section">
<div className="apps-header">
<div className="section-head apps-title">
<h2>Приложения</h2>
<span>{items.length}</span>
</div>
</div>
{isProcessInputOpen ? (
<div className="process-add-line">
<input
value={processInput}
onChange={(event) => setProcessInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") addProcess();
if (event.key === "Escape") {
setProcessInput("");
setIsProcessInputOpen(false);
}
}}
placeholder="Discord"
spellCheck={false}
autoFocus
/>
<Button type="button" variant="primary" onClick={addProcess}>
OK
</Button>
<Button
type="button"
variant="neutral"
onClick={() => {
setProcessInput("");
setIsProcessInputOpen(false);
}}
>
Отмена
</Button>
</div>
) : (
<div className="app-add-skeleton">
<div className="app-add-main">
<span className="item-icon app-add-icon" aria-hidden="true">
<Cpu size={18} strokeWidth={1.9} />
</span>
<div>
<strong>Новое приложение</strong>
<span>Добавь процесс, EXE-файл или папку</span>
</div>
</div>
<div className="add-toolbar" aria-label="Добавить приложение">
<IconButton
type="button"
variant="add"
onClick={() => setIsProcessInputOpen(true)}
label="Добавить процесс"
icon={<Cpu size={20} strokeWidth={1.8} />}
/>
<IconButton
type="button"
variant="add"
onClick={() => void pickAndAddItem("exe")}
disabled={Boolean(pickerAction)}
loading={pickerAction === "exe"}
label="Добавить EXE-файл"
icon={<FileCode2 size={20} strokeWidth={1.8} />}
/>
<IconButton
type="button"
variant="add"
onClick={() => void pickAndAddItem("folder")}
disabled={Boolean(pickerAction)}
loading={pickerAction === "folder"}
label="Добавить папку"
icon={<FolderOpen size={20} strokeWidth={1.8} />}
/>
</div>
</div>
)}
<AppList items={items} loading={isLoading} onRemove={removeItem} />
</section>
);
}
function renderChangesDock() {
if (!hasUnappliedChanges || !appliedSnapshot) return null;
const externalProxyError =
routeMode === "external" ? safeProxyError(proxyInput) : null;
const readiness = getApplyReadiness({
routeMode,
appCount: items.length,
proxiFyreInstalled: Boolean(proxyfier?.installed),
singBoxInstalled: isSingBoxInstalled,
singBoxRunning: Boolean(singbox?.running),
selectedServerTag: singBoxStatus?.config.selectedServerTag,
externalProxyValue: proxyInput,
externalProxyError,
busy: isApplying || Boolean(serviceAction) || Boolean(singBoxAction),
});
const applyButtonText =
isLoading || isDetectingComponents
? "Проверяю готовность"
: readiness.ready
? changesApplyButtonLabel(isApplying, singBoxAction, serviceAction)
: (readiness.title ?? "Применение недоступно");
return (
<aside className="changes-dock" aria-label="Черновик конфигурации">
<div className="changes-dock-main">
<ul className="changes-list">
{pendingChanges.map((change) => (
<li
className={`change-row ${change.tone ?? "changed"}`}
key={change.id}
>
<span className="change-label">{change.label}</span>
<span className="change-values">
{change.before ? (
<>
<span>{change.before}</span>
<span className="change-arrow" aria-hidden="true">
-&gt;
</span>
</>
) : null}
<strong>{change.after}</strong>
</span>
</li>
))}
</ul>
</div>
<div className="changes-actions">
<Button
type="button"
variant={readiness.ready ? "primary" : "neutral"}
size="md"
onClick={updateConfig}
disabled={!readiness.ready}
loading={isApplying}
loadingLabel={changesApplyButtonLabel(
true,
singBoxAction,
serviceAction,
)}
>
{applyButtonText}
</Button>
</div>
</aside>
);
}
function renderProxiFyrePanel() {
return (
<section
className="tab-panel"
role="tabpanel"
id="panel-proxifyre"
aria-labelledby="tab-proxifyre"
>
{renderProxiFyreCard()}
{renderProxiFyreSetupStrip()}
{renderAppsSection()}
</section>
);
}
function renderExternalProxyControls() {
const proxyValidation = proxyInput.trim()
? safeProxyError(proxyInput)
: null;
return (
<div className="external-proxy-card">
<label className="simple-field route-proxy-field">
<span>Внешний SOCKS5 прокси</span>
<input
value={proxyInput}
onChange={(event) => changeProxyInput(event.target.value)}
placeholder="socks5://127.0.0.1:1080"
spellCheck={false}
/>
{proxyValidation ? (
<span className="field-error">{proxyValidation}</span>
) : null}
</label>
</div>
);
}
function renderSingBoxCard() {
const state = serviceControlState(singbox, isDetectingComponents);
const setupSummary = singBoxSetupStatus
? singBoxSetupStatus.ready
? "состав готов"
: `не хватает: ${singBoxSetupStatus.missingCount}`
: "состав не проверен";
const primaryAction = singbox?.installed
? {
label: singbox.running ? "Остановить" : "Запустить",
onClick: () => void setSingBoxServiceRunning(!singbox.running),
variant: singbox.running ? ("danger" as const) : ("neutral" as const),
loading: singBoxAction === "start" || singBoxAction === "stop",
loadingLabel: singBoxAction === "start" ? "Запускаю" : "Останавливаю",
disabled: isDetectingComponents || Boolean(singBoxAction),
}
: {
label: "Установить",
onClick: () => void installSingBoxPackage(),
variant: "primary" as const,
loading: singBoxAction === "install",
loadingLabel: "Устанавливаю",
disabled: isDetectingComponents || Boolean(singBoxAction),
};
return (
<ServiceControlRow
state={state}
visualState={singBoxAction ? "working" : null}
className="singbox-card"
title="Local sing-box"
detail={singBoxDetails(singbox, singBoxStatus, isDetectingComponents)}
primaryAction={primaryAction}
menu={
singbox?.installed
? {
label: "Дополнительные действия Local sing-box",
open: isSingBoxMenuOpen,
onOpenChange: setIsSingBoxMenuOpen,
disabled: isDetectingComponents || Boolean(singBoxAction),
items: [
{
label:
singBoxAction === "uninstall"
? "Удаляю..."
: "Удалить Local sing-box",
danger: true,
disabled: Boolean(singBoxAction),
onClick: () => void uninstallSingBoxPackage(),
},
],
}
: undefined
}
inlineActions={
<DetailsPopover
className="setup-summary"
details={singBoxDetailLines(
singbox,
singBoxStatus,
singBoxSetupStatus,
selectedServerTag,
SHOW_DEV_SUBSCRIPTION_IDENTITY,
)}
popoverLabel="Состав Local sing-box"
aria-label={`Подробности Local sing-box: ${setupSummary}`}
>
<span className="setup-summary-glyph" aria-hidden="true">
i
</span>
</DetailsPopover>
}
>
{isSingBoxInstalled ? renderSingBoxWorkspace() : null}
</ServiceControlRow>
);
}
function renderSingBoxWorkspace() {
return (
<div className="singbox-workspace">
<div className="subscription-line">
<span className="subscription-icon" aria-hidden="true">
<Link2 size={18} strokeWidth={1.9} />
</span>
<input
value={subscriptionInput}
onChange={(event) => setSubscriptionInput(event.target.value)}
placeholder={
singBoxStatus?.config.subscriptionDisplayUrl ??
"https://example.com/sub"
}
spellCheck={false}
/>
<Button
type="button"
onClick={() => void syncSingBoxSubscription()}
loading={singBoxAction === "fetch"}
loadingLabel="Загружаю"
variant="neutral"
disabled={Boolean(singBoxAction)}
>
{subscriptionInput.trim() || !singBoxStatus?.config.hasSubscription
? "Загрузить"
: "Обновить"}
</Button>
<IconButton
type="button"
variant="danger"
onClick={() => void forgetSingBoxSubscriptionData()}
disabled={
Boolean(singBoxAction) || !singBoxStatus?.config.hasSubscription
}
label="Очистить подписку Local sing-box"
tooltip="Очистить"
icon={<Trash2 size={18} strokeWidth={1.9} />}
/>
</div>
<p className="network-disclosure">
Загрузка обращается к указанному провайдеру подписки и передает
случайный постоянный ID установки в заголовке X-HWID.
</p>
<div className="singbox-workspace-head">
<span>Серверы подписки</span>
<div className="singbox-workspace-actions">
<IconButton
type="button"
onClick={() => void pingSingBoxServers()}
disabled={
Boolean(singBoxAction) || !singBoxStatus?.cache?.servers.length
}
loading={singBoxAction === "ping"}
label="Проверить все серверы подписки"
tooltip="Проверить все серверы"
icon={<Gauge size={16} strokeWidth={1.9} />}
/>
<IconButton
type="button"
onClick={() => void generateSingBoxNow()}
disabled={Boolean(singBoxAction) || !selectedServerTag}
loading={singBoxAction === "generate"}
label="Создать конфиг Local sing-box"
tooltip="Создать конфиг"
icon={<Wand2 size={16} strokeWidth={1.9} />}
/>
</div>
</div>
{singBoxStatus?.cache?.servers.length ? (
<div className="server-list">
{singBoxStatus.cache.servers.map((server) => {
const ping = serverPings[server.id];
const selected = selectedServerId
? server.id === selectedServerId
: server.tag === selectedServerTag;
return (
<div
className={`server-row ${selected ? "selected" : ""} ${ping ? pingTone(ping) : ""}`.trim()}
key={server.id}
>
<button
type="button"
className="server-select-button"
onClick={() => void chooseSingBoxServer(server)}
data-tooltip={serverTooltip(server, ping)}
aria-label={`Выбрать ${displayServerTag(server.tag)}. ${serverTooltip(server, ping)}`}
>
<span className="server-select-dot" aria-hidden="true" />
<strong>{displayServerTag(server.tag)}</strong>
{ping ? (
<span className="server-ping-badge">
{ping.ok ? `${ping.latency ?? 0} ms` : "fail"}
</span>
) : null}
</button>
<IconButton
type="button"
onClick={() => void pingSingleSingBoxServer(server)}
disabled={Boolean(serverPingTag)}
loading={serverPingTag === server.id}
label={`Проверить ${displayServerTag(server.tag)}`}
tooltip="Ping"
icon={<Gauge size={14} strokeWidth={1.9} />}
/>
</div>
);
})}
</div>
) : (
<div className="empty-state">
Подписка Local sing-box еще не загружена.
</div>
)}
</div>
);
}
function renderProxyOverview() {
const check = connectionCheckView({
routeMode,
proxyInput,
proxyCheck,
singbox,
singBoxStatus,
selectedServer,
isDetectingComponents,
isProxyChecking,
});
return (
<section
className={`proxy-overview ${check.tone}`}
aria-label="Состояние прокси"
>
{check.tone === "checking" ? <BusyRing /> : null}
<ConnectionCheckPanel
check={check}
onCheck={() => void checkRouteProxy()}
/>
</section>
);
}
function renderProxyPanel() {
return (
<section
className="tab-panel"
role="tabpanel"
id="panel-proxy"
aria-labelledby="tab-proxy"
>
<section className="route-mode-selector" aria-label="Режим прокси">
<span>Режим маршрута</span>
<div className="route-switch">
<Button
type="button"
variant={routeMode === "external" ? "primary" : "neutral"}
className="route-mode-option external"
onClick={() => changeRouteMode("external")}
aria-pressed={routeMode === "external"}
aria-controls="route-mode-content"
>
<span className="route-mode-label">
<strong>Внешний прокси</strong>
<small>Готовый SOCKS5 endpoint</small>
</span>
</Button>
<Button
type="button"
variant={routeMode === "local-singbox" ? "primary" : "neutral"}
className="route-mode-option local"
onClick={() => changeRouteMode("local-singbox")}
aria-pressed={routeMode === "local-singbox"}
aria-controls="route-mode-content"
>
<span className="route-mode-label">
<strong>Локальный прокси</strong>
<small>sing-box на этом компьютере</small>
</span>
</Button>
</div>
</section>
<div
className={`route-mode-content ${routeMode === "external" ? "external" : "local"}`}
id="route-mode-content"
key={routeMode}
>
<section className="route-panel" aria-label="Маршрут приложений">
{routeMode === "external"
? renderExternalProxyControls()
: renderSingBoxCard()}
</section>
{renderProxyOverview()}
</div>
</section>
);
}
function renderRouteChain(variant: "horizontal" | "vertical" = "horizontal") {
const input: RouteChainInput = {
routeMode,
proxyInput,
proxyfier,
singbox,
singBoxStatus,
selectedServer,
appCount: items.length,
isDetectingComponents,
};
const flow = variant === "vertical" ? summaryRouteFlow(input) : "proxy";
const segments =
variant === "vertical"
? summaryRouteChainSegments(input, flow)
: routeChainSegments(input);
const isVertical = variant === "vertical";
return (
<section
className={`route-chain route-chain--${variant} route-chain--${flow}`}
aria-label="Текущий маршрут"
>
{isVertical ? (
<span className="route-chain-rail" aria-hidden="true">
{flow === "idle" ? null : (
<>
<span className="route-chain-packet route-chain-packet--one" />
<span className="route-chain-packet route-chain-packet--two" />
<span className="route-chain-packet route-chain-packet--three" />
</>
)}
</span>
) : null}
{segments.map((segment, index) => (
<DetailsPopover
className={`route-chain-segment ${segment.tone}`}
details={segment.details}
popoverLabel={segment.label}
align={isVertical || index >= segments.length - 2 ? "end" : "start"}
aria-label={`${segment.label}: ${segment.value}. ${segment.details.join(". ")}`}
key={segment.id}
>
<span className="route-chain-dot" aria-hidden="true" />
<span>{segment.label}</span>
<strong>{segment.value}</strong>
{index < segments.length - 1 ? (
<span className="route-chain-arrow" aria-hidden="true">
{isVertical ? "↓" : "->"}
</span>
) : null}
</DetailsPopover>
))}
</section>
);
}
function renderActivePanel() {
if (activePanel === "proxifyre") return renderProxiFyrePanel();
if (activePanel === "proxy") return renderProxyPanel();
return renderSummaryPanel();
}
return (
<main
className={[
"simple-shell",
hasUnappliedChanges ? "has-change-dock" : "",
hasAdminPrompt ? "has-admin-prompt" : "",
]
.filter(Boolean)
.join(" ")}
style={shellStyle}
>
<section className="simple-panel">
{renderTabs()}
<div
className={`tab-panel-frame swipe-${tabTransitionDirection}`}
key={activePanel}
>
{renderActivePanel()}
</div>
</section>
{renderChangesDock()}
<LogDock
entries={logEntries}
activeEntry={activeLog}
open={isLogOpen}
onToggle={toggleLog}
formatTime={formatLogTime}
/>
{renderAdminPrompt()}
</main>
);
}
async function pickPath(type: Extract<DraftItemType, "exe" | "folder">) {
const selected = await open(
type === "folder"
? {
title: "Выбери папку",
directory: true,
multiple: false,
}
: {
title: "Выбери EXE-файл",
directory: false,
multiple: false,
filters: [{ name: "EXE-файлы", extensions: ["exe"] }],
},
);
if (Array.isArray(selected)) return selected[0] ?? null;
return selected;
}
function nextFrame() {
return new Promise<void>((resolve) => {
window.requestAnimationFrame(() => resolve());
});
}