1774 lines
56 KiB
TypeScript
1774 lines
56 KiB
TypeScript
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">
|
||
->
|
||
</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());
|
||
});
|
||
}
|