Files
ProxyWarden/src/app/App.tsx
T
dokril fd79606052
CI / Windows baseline (push) Canceled after 0s
Release v2.0.0
2026-09-11 17:25:12 +03:00

2278 lines
76 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 { isTauri } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import { Gauge, Link2, ShieldAlert, Trash2, Wand2 } from "lucide-react";
import {
applyConfiguration,
configureProxiFyreFirewallRules,
fetchSingBoxSubscription,
forgetSingBoxSubscription,
generateSingBoxConfig,
getComponents,
getProxiFyreSetupStatus,
getSavedState,
getSingBoxSetupStatus,
getSingBoxStatus,
getStartupSnapshot,
installProxiFyre,
installSingBox,
pingAllSingBoxServers,
pingProxyTarget,
pingSingBoxServer,
selectSingBoxServer,
startProxiFyreService,
startSingBoxService,
stopProxiFyreService,
stopSingBoxService,
uninstallProxiFyre,
uninstallSingBox,
type AdminStatusResponse,
type ArtifactStatus,
type CommandError,
type LocalSingBoxStatusResponse,
type PingServerResponse,
type ProxiFyreSetupStatus,
type SingBoxSetupStatus,
} from "../api/tauriCommands";
import type {
ComponentCutoverStatus,
ComponentStatus,
ManagedPackageComponentId,
Profile,
StorageMigrationStatus,
SubscriptionServer,
Target,
} from "../domain/types";
import {
BusyRing,
Button,
DetailsPopover,
IconButton,
LogDock,
ServiceControlRow,
Tabs,
} from "../ui";
import { SummaryStatusControl } from "./components/SummaryStatusControl";
import {
ComponentPackageStatus as ComponentPackageStatusView,
ComponentPackageStatusLoading,
ComponentPackageStatusUnavailable,
} from "./components/ComponentPackageStatus";
import { MigrationNotice } from "./components/MigrationNotice";
import { ConnectionCheckPanel } from "./components/ConnectionCheckPanel";
import { AppList } from "./components/AppList";
import {
componentActionRequiresReboot,
isPrivilegedUacCancellation,
localComponentStateError,
useComponentPackages,
type ComponentPackageActionResult,
type ComponentPackageBusyAction,
} from "./hooks/useComponentPackages";
import { useSubscription } from "./hooks/useSubscription";
import { useApplyFlow } from "./hooks/useApplyFlow";
import { useConfigurationDraft } from "./hooks/useConfigurationDraft";
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 {
canUseStoppedProxiFyreRouteSmoke,
getComponentUpdateBlockReason,
getApplyReadiness,
isComponentInstallPackageReady,
isCutoverLifecycleComplete,
} from "./readiness";
import {
serviceControlState,
systemSummaryState,
connectionCheckView,
summaryRouteFlow,
summaryRouteChainSegments,
routeChainSegments,
safeProxyError,
pingTone,
type ProxyCheckResult,
changesApplyButtonLabel,
routeProxyCheckTarget,
profileItemInput,
emptyItemMessage,
proxyfierDetails,
proxyfierCompactStatus,
singBoxDetails,
singBoxDetailLines,
componentDetails,
noticeFromConfigurationApply,
upsertComponent,
sameValue,
formatLogTime,
serverTooltip,
pingSummary,
errorMessage,
type DraftItem,
type RouteChainInput,
} from "./viewModel";
type ProxiFyreAction =
"start" | "stop" | "restart" | "install" | "uninstall" | "firewall";
type SingBoxAction =
| "start"
| "stop"
| "install"
| "uninstall"
| "select"
| "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: [],
},
];
const MISSING_PACKAGE_STATUS_ERROR: CommandError = {
code: "component_package_status_missing",
message: "Локальный статус пакета отсутствует. Повтори запуск ProxyWarden.",
details: [],
};
const MISSING_CUTOVER_STATUS_ERROR: CommandError = {
code: "component_cutover_status_missing",
message: "Локальный статус переноса отсутствует. Повтори запуск ProxyWarden.",
details: [],
};
function blockedLifecycleLabel(status: ComponentCutoverStatus | null) {
return status ? "Перенос требуется" : "Проверяю перенос";
}
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 selectedProfile = useRef(MAIN_PROFILE_ID);
const [targetId, setTargetId] = useState(MAIN_TARGET_ID);
const draft = useConfigurationDraft();
const [items, setItems] = useState<DraftItem[]>([]);
const [savedSnapshot, setSavedSnapshot] = useState<ConfigSnapshot | null>(
null,
);
const [pickerAction, setPickerAction] = useState<"exe" | "folder" | null>(
null,
);
const [components, setComponents] =
useState<ComponentStatus[]>(fallbackComponents);
const [artifacts, setArtifacts] = useState<ArtifactStatus[]>([]);
const [startupReady, setStartupReady] = useState(false);
const [startupError, setStartupError] = useState<string | null>(null);
const startupRequest = useRef(0);
const probeRequest = useRef(0);
const currentServer = useRef<string | undefined>(undefined);
const [storageMigrationStatus, setStorageMigrationStatus] =
useState<StorageMigrationStatus | null>(null);
const [lastComponentAction, setLastComponentAction] =
useState<ComponentPackageBusyAction | null>(null);
const [cancelledComponentAction, setCancelledComponentAction] =
useState<ComponentPackageBusyAction | null>(null);
const [setupStatus, setSetupStatus] = useState<ProxiFyreSetupStatus | null>(
null,
);
const [singBoxStatus, setSingBoxStatus] =
useState<LocalSingBoxStatusResponse | null>(null);
const [singBoxSetupStatus, setSingBoxSetupStatus] =
useState<SingBoxSetupStatus | null>(null);
currentServer.current = singBoxStatus?.config.selectedServerId;
const subscription = useSubscription();
const { input: subscriptionInput, setInput: setSubscriptionInput } =
subscription;
const [serverPings, setServerPings] = useState<
Record<string, PingServerResponse>
>({});
const [proxyCheck, setProxyCheck] = useState<ProxyCheckResult | 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, submit: submitDraft } = useApplyFlow();
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 [isAdminPromptOpen, setIsAdminPromptOpen] = useState(false);
const [isAdminPromptHintVisible, setIsAdminPromptHintVisible] =
useState(false);
const adminPromptSeenRef = useRef(false);
const adminPromptTimerRef = useRef<number | null>(null);
const componentPackages = useComponentPackages(startupReady);
function showPrivilegedActionFailure(
error: unknown,
failureTitle: string,
cancellationText = "Запрос прав администратора отменён. Состояние компонента не изменено.",
) {
const cancelled = isPrivilegedUacCancellation(error);
showNotice({
kind: cancelled ? "info" : "error",
title: cancelled ? "Действие отменено" : failureTitle,
text: cancelled ? cancellationText : errorMessage(error),
});
}
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 proxifyrePackageStatus =
componentPackages.packageStatuses.find(
(status) => status.componentId === "proxifyre",
) ?? null;
const singBoxPackageStatus =
componentPackages.packageStatuses.find(
(status) => status.componentId === "sing-box",
) ?? null;
const proxifyreCutoverStatus =
componentPackages.cutoverStatuses.find(
(status) => status.componentId === "proxifyre",
) ?? null;
const singBoxCutoverStatus =
componentPackages.cutoverStatuses.find(
(status) => status.componentId === "sing-box",
) ?? null;
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.length > 0 && draft.enabled,
),
[
items,
proxyInput,
routeMode,
selectedServerId,
selectedServerTag,
draft.enabled,
],
);
const pendingChanges = useMemo(
() =>
savedSnapshot ? configChangeRows(savedSnapshot, currentSnapshot) : [],
[savedSnapshot, currentSnapshot],
);
const savedRouteNeedsPreparation =
draft.profiles.some(
(profile) => profile.enabled && profile.items.length > 0,
) &&
["proxyfier", ...(routeMode === "local-singbox" ? ["singbox"] : [])].some(
(component) =>
!artifacts.find((artifact) => artifact.component === component)
?.sourceMatchesPrepared,
);
const hasUnappliedChanges =
pendingChanges.length > 0 || savedRouteNeedsPreparation;
const hasAdminPrompt = Boolean(adminStatus?.canRestartElevated);
const shellStyle =
activePanel !== "summary" && hasUnappliedChanges
? ({
"--change-row-count": String(Math.max(1, pendingChanges.length)),
} as CSSProperties)
: undefined;
const systemSummary = startupError
? {
tone: "warning" as const,
title: "Нет данных",
text: "Запуск не завершён. Повторите загрузку состояния.",
}
: systemSummaryState({
isLoading,
isDetectingComponents,
proxyfier,
singbox,
routeMode: savedSnapshot?.routeMode ?? routeMode,
singBoxStatus,
artifacts,
});
useEffect(() => {
void refresh();
}, []);
useEffect(() => {
if (!hasAdminPrompt || adminPromptSeenRef.current) return undefined;
adminPromptSeenRef.current = true;
setIsAdminPromptHintVisible(true);
adminPromptTimerRef.current = window.setTimeout(() => {
setIsAdminPromptHintVisible(false);
adminPromptTimerRef.current = null;
}, 4600);
return () => {
if (adminPromptTimerRef.current !== null) {
window.clearTimeout(adminPromptTimerRef.current);
adminPromptTimerRef.current = null;
}
};
}, [hasAdminPrompt]);
useEffect(() => {
return () => {
startupRequest.current += 1;
probeRequest.current += 1;
subscription.requestVersion.current += 1;
if (serviceVisualTimerRef.current !== null) {
window.clearTimeout(serviceVisualTimerRef.current);
}
if (adminPromptTimerRef.current !== null) {
window.clearTimeout(adminPromptTimerRef.current);
}
};
}, []);
async function refresh() {
const request = ++startupRequest.current;
const generation = draft.generation.current;
setStartupError(null);
setIsLoading(true);
setIsDetectingComponents(true);
setStartupReady(false);
try {
if (!isTauri()) {
setStartupError(
"Режим предпросмотра: откройте приложение через Tauri для работы с настройками.",
);
return;
}
const snapshot = await getStartupSnapshot();
if (request !== startupRequest.current) return;
draft.revision.current = snapshot.savedState.revision;
setArtifacts(snapshot.savedState.artifacts);
setAdminStatus(snapshot.adminStatus);
setStorageMigrationStatus(snapshot.migrationStatus);
setComponents(snapshot.components);
setSetupStatus(snapshot.proxifyreSetupStatus);
setSingBoxStatus(snapshot.singboxStatus);
setSingBoxSetupStatus(snapshot.singboxSetupStatus);
applySavedState(
snapshot.savedState.profiles,
snapshot.savedState.targets,
snapshot.savedState.generatedConfigPath,
snapshot.singboxStatus,
generation === draft.generation.current,
);
setStartupReady(true);
} catch (error) {
if (request === startupRequest.current)
setStartupError(errorMessage(error));
} finally {
if (request === startupRequest.current) {
setIsLoading(false);
setIsDetectingComponents(false);
}
}
}
async function refreshComponentRuntimeState() {
const subscriptionRequest = subscription.requestVersion.current;
const [
detectedComponents,
detectedSetup,
detectedSingBox,
detectedSingSetup,
localComponentState,
] = await Promise.all([
getComponents(),
getProxiFyreSetupStatus(),
getSingBoxStatus(),
getSingBoxSetupStatus(),
componentPackages.refreshLocal(),
]);
setComponents(detectedComponents);
setSetupStatus(detectedSetup);
if (subscriptionRequest === subscription.requestVersion.current)
setArtifacts(detectedSingBox.savedState.artifacts);
if (subscriptionRequest === subscription.requestVersion.current)
setSingBoxStatus(detectedSingBox);
setSingBoxSetupStatus(detectedSingSetup);
const localStateError = localComponentStateError(localComponentState);
if (localStateError) throw localStateError;
}
async function runComponentPackageAction<T>(
action: ComponentPackageBusyAction,
command: () => Promise<ComponentPackageActionResult<T>>,
successTitle: string,
refreshRuntime = false,
) {
setLastComponentAction(action);
setCancelledComponentAction(null);
const result = await command();
if (result.status === "cancelled") {
setCancelledComponentAction(action);
showNotice({
kind: "info",
title: "Действие отменено",
text: "Запрос прав администратора отменён. Состояние компонента не изменено.",
});
return result;
}
if (result.status === "failed") {
if (refreshRuntime) {
try {
await refreshComponentRuntimeState();
} catch {
// The local status owner already records a redacted fail-closed error.
}
}
showNotice({
kind: "error",
title: "Действие не выполнено",
text: result.error.message,
});
return result;
}
if (result.status === "busy") return result;
const rebootRequired = componentActionRequiresReboot(result.value);
if (refreshRuntime) {
try {
await refreshComponentRuntimeState();
} catch (error) {
showNotice({
kind: "info",
title: successTitle,
text: `Действие завершено, но локальный статус не обновлён.${
rebootRequired ? " Требуется перезапуск Windows." : ""
} ${errorMessage(error)}`,
});
return result;
}
}
showNotice({
kind: "success",
title: successTitle,
text: rebootRequired
? "Локальный статус обновлён. Перезапусти Windows перед продолжением."
: "Локальный статус компонента обновлён.",
});
return result;
}
function checkComponentPackage(componentId: ManagedPackageComponentId) {
return runComponentPackageAction(
{ kind: "check", componentId },
() => componentPackages.check(componentId),
"Проверка обновлений завершена",
);
}
function downloadComponentPackage(componentId: ManagedPackageComponentId) {
return runComponentPackageAction(
{ kind: "download", componentId },
() => componentPackages.download(componentId),
"Обновление сохранено локально",
);
}
function updateManagedComponent(componentId: ManagedPackageComponentId) {
return runComponentPackageAction(
{ kind: "update", componentId },
() => componentPackages.update(componentId),
"Компонент обновлён",
true,
);
}
function cutoverManagedComponent(componentId: ManagedPackageComponentId) {
return runComponentPackageAction(
{ kind: "cutover", componentId },
() => componentPackages.cutover(componentId),
"Состояние переноса обновлено",
true,
);
}
function confirmManagedComponentRoute(
componentId: ManagedPackageComponentId,
) {
return runComponentPackageAction(
{ kind: "confirm-route-smoke", componentId },
() => componentPackages.confirmRouteSmoke(componentId),
"Проверка маршрута подтверждена",
);
}
function cleanupManagedComponent(componentId: ManagedPackageComponentId) {
return runComponentPackageAction(
{ kind: "cleanup", componentId },
() => componentPackages.cleanup(componentId),
"Очистка старой установки обновлена",
true,
);
}
function applySavedState(
profiles: Profile[],
targets: Target[],
generatedPath: string,
singBoxStatusForSnapshot = singBoxStatus,
hydrate = true,
preferredProfile = selectedProfile.current,
) {
const saved = draft.load(profiles, targets, preferredProfile, hydrate);
if (hydrate) {
setProxyInput(saved.proxyInput);
setProxyCheck(null);
setItems(saved.items);
selectedProfile.current = saved.profileId;
setProfileId(saved.profileId);
setTargetId(saved.targetId);
setRouteMode(saved.routeMode);
} else {
// A committed COW target belongs to this profile even when newer edits stay visible.
setTargetId(saved.targetId);
}
setGeneratedConfigPath(generatedPath);
setSavedSnapshot(
configSnapshotFromUi(
saved.routeMode,
saved.proxyInput,
saved.items,
singBoxStatusForSnapshot?.config.selectedServerId,
singBoxStatusForSnapshot?.config.selectedServerTag,
saved.items.length > 0 && (saved.profile?.enabled ?? true),
),
);
}
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;
}
draft.edited();
setItems((current) => [
...current,
{
id: `${type}-${crypto.randomUUID()}`,
type,
value,
},
]);
return true;
}
function addProcess(value: string) {
return addItem("process", value);
}
function removeItem(id: string) {
draft.edited();
setItems((current) => current.filter((item) => item.id !== id));
}
function changeRouteMode(nextMode: RouteMode) {
draft.edited();
setRouteMode(nextMode);
setProxyCheck(null);
}
function changeProxyInput(nextValue: string) {
draft.edited();
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">,
) {
const generation = draft.generation.current;
setPickerAction(type);
try {
const selectedPath = await pickPath(type);
if (selectedPath && generation === draft.generation.current) {
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 && !draft.selection?.profile)
throw new Error("Добавь хотя бы один процесс, EXE-файл или папку.");
if (items.length === 0 && draft.selection?.profile) {
if (proxyfier?.running)
throw new Error(
"Сначала явно останови ProxiFyre, затем примени очистку правил.",
);
} else 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;
}
const generation = draft.generation.current;
try {
if (items.length > 0 && routeMode === "external" && !parsedProxy)
throw new Error("Прокси не разобран.");
const outcome = await submitDraft(
{
expectedRevision: draft.revision.current,
routeMode,
profile: {
id: profileId,
name: draft.selection?.profile?.name ?? "Приложения через прокси",
enabled: items.length > 0 && draft.enabled,
targetId:
routeMode === "local-singbox"
? LOCAL_SINGBOX_TARGET_ID
: targetId,
protocols: draft.selection?.profile?.protocols ?? ["TCP", "UDP"],
items: items.map(profileItemInput),
},
externalTarget: parsedProxy
? {
id: targetId,
...draft.selection?.externalTarget,
name:
draft.selection?.externalTarget?.name ?? "Основной прокси",
kind: "external",
protocol: parsedProxy.protocol,
host: parsedProxy.host,
port: parsedProxy.port,
}
: undefined,
disableOtherProfiles: false,
},
applyConfiguration,
(result) => {
if (!result.savedState) return;
draft.revision.current = result.savedState.revision;
setArtifacts(result.savedState.artifacts);
applySavedState(
result.savedState.profiles,
result.savedState.targets,
result.generatedConfigPath,
singBoxStatus,
generation === draft.generation.current,
profileId,
);
},
refreshComponentRuntimeState,
);
if (outcome.refreshError) {
showNotice({
kind: "info",
title: "Сохранено, состояние служб не обновлено",
text: errorMessage(outcome.refreshError),
});
} else showNotice(noticeFromConfigurationApply(outcome.result));
} catch (error) {
showNotice({
kind: "error",
title: "Конфиг не обновлен",
text: errorMessage(error),
});
}
}
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));
void getSavedState()
.then((saved) => setArtifacts(saved.artifacts))
.catch(() => setArtifacts([]));
showNotice({
kind: "success",
title: shouldRun ? "Служба запущена" : "Служба остановлена",
text: proxyfierDetails(component, false),
});
} catch (error) {
showPrivilegedActionFailure(
error,
shouldRun ? "Служба не запущена" : "Служба не остановлена",
);
} finally {
setServiceAction(null);
settleServiceVisual();
}
}
async function installProxiFyrePackage() {
const confirmed = window.confirm(
"Установить ProxiFyre? ProxyWarden запросит права администратора, использует только встроенные проверенные пакеты без обращения к сети и зарегистрирует остановленную Windows-службу. Запуск выполняется отдельно. Правила Windows Firewall доступны отдельным действием в меню ProxiFyre.",
);
if (!confirmed) return;
setServiceAction("install");
setIsServiceMenuOpen(false);
startServiceVisual();
try {
await nextFrame();
const installResult = await installProxiFyre();
const component = installResult.component;
const detectedSetupStatus = await getProxiFyreSetupStatus();
setComponents((current) => upsertComponent(current, component));
setSetupStatus(detectedSetupStatus);
await componentPackages.refreshLocal();
showNotice({
kind: "success",
title: "ProxiFyre установлен",
text: `${proxyfierDetails(component, false)}${
installResult.rebootRequired
? " Для завершения установки перезапусти Windows."
: ""
}`,
});
} catch (error) {
showPrivilegedActionFailure(error, "ProxiFyre не установлен");
} finally {
setServiceAction(null);
settleServiceVisual();
}
}
async function configureFirewall() {
if (
!window.confirm(
"Добавить или восстановить входящее и исходящее правила Windows Firewall для подтверждённого ProxiFyre.exe? Windows запросит права администратора.",
)
)
return;
setServiceAction("firewall");
setIsServiceMenuOpen(false);
try {
await configureProxiFyreFirewallRules();
showNotice({
kind: "success",
title: "Правила Firewall добавлены",
text: "Настроены входящее и исходящее правила для ProxiFyre.exe.",
});
} catch (error) {
showPrivilegedActionFailure(
error,
"Правила Firewall не подтверждены",
"Запрос UAC отменён; настройка правил не начиналась.",
);
} finally {
setServiceAction(null);
}
}
async function uninstallProxiFyrePackage() {
const confirmed = window.confirm(
"Удалить ProxiFyre и Windows Packet Filter с компьютера? Это остановит службу, удалит папку ProxiFyre и сетевой драйвер. Другие программы WireSock могут перестать работать до повторной установки драйвера.",
);
if (!confirmed) return;
setServiceAction("uninstall");
setIsServiceMenuOpen(false);
startServiceVisual();
try {
await nextFrame();
const uninstallResult = await uninstallProxiFyre();
const component = uninstallResult.component;
const detectedSetupStatus = await getProxiFyreSetupStatus();
setComponents((current) => upsertComponent(current, component));
setSetupStatus(detectedSetupStatus);
await componentPackages.refreshLocal();
showNotice({
kind: "success",
title: "ProxiFyre удален",
text: `Служба, папка установки ProxiFyre и Windows Packet Filter удалены.${
uninstallResult.rebootRequired
? " Для завершения удаления перезапусти Windows."
: ""
}`,
});
} catch (error) {
showPrivilegedActionFailure(error, "ProxiFyre не удален");
} finally {
setServiceAction(null);
settleServiceVisual();
}
}
async function refreshSingBoxState() {
const request = subscription.requestVersion.current;
const revision = draft.revision.current;
const [
detectedSingBoxStatus,
detectedSingBoxSetupStatus,
detectedComponents,
] = await Promise.all([
getSingBoxStatus(),
getSingBoxSetupStatus(),
getComponents(),
componentPackages.refreshLocal(),
]);
if (
request !== subscription.requestVersion.current ||
revision !== draft.revision.current
)
return detectedSingBoxStatus;
acceptSubscriptionStatus(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) {
showPrivilegedActionFailure(
error,
shouldRun ? "sing-box не запущен" : "sing-box не остановлен",
);
} finally {
setSingBoxAction(null);
}
}
async function installSingBoxPackage() {
const confirmed = window.confirm(
"Установить Local sing-box? ProxyWarden запросит права администратора, использует встроенные проверенные sing-box и WinSW без обращения к сети и зарегистрирует остановленную службу ProxyWardenSingBox. Запуск выполняется отдельно.",
);
if (!confirmed) return;
setSingBoxAction("install");
setIsSingBoxMenuOpen(false);
try {
await nextFrame();
const installResult = await installSingBox();
const component = installResult.component;
setComponents((current) => upsertComponent(current, component));
await refreshSingBoxState();
setProxyCheck(null);
showNotice({
kind: "success",
title: "Local sing-box установлен",
text: `${componentDetails(component, false)}${
installResult.rebootRequired
? " Для завершения установки перезапусти Windows."
: ""
}`,
});
} catch (error) {
showPrivilegedActionFailure(error, "Local sing-box не установлен");
} 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 uninstallResult = await uninstallSingBox();
const component = uninstallResult.component;
setComponents((current) => upsertComponent(current, component));
await refreshSingBoxState();
setProxyCheck(null);
showNotice({
kind: "success",
title: "Local sing-box удален",
text: `Служба и папка установки Local sing-box удалены.${
uninstallResult.rebootRequired
? " Для завершения удаления перезапусти Windows."
: ""
}`,
});
} catch (error) {
showPrivilegedActionFailure(error, "Local sing-box не удален");
} finally {
setSingBoxAction(null);
}
}
function acceptSubscriptionStatus(status: LocalSingBoxStatusResponse) {
draft.revision.current = status.savedState.revision;
setArtifacts(status.savedState.artifacts);
const source = draft.load(
status.savedState.profiles,
status.savedState.targets,
selectedProfile.current,
false,
);
setTargetId(source.targetId);
setSingBoxStatus(status);
setComponents((current) => upsertComponent(current, status.component));
setServerPings({});
setProxyCheck(null);
}
async function syncSingBoxSubscription() {
if (isApplying || singBoxAction) return;
const request = subscription.begin();
const subscriptionUrl = subscriptionInput.trim();
if (!subscriptionUrl && !singBoxStatus?.config.hasSubscription) {
showNotice({
kind: "error",
title: "Ссылка не указана",
text: "Вставь ссылку подписки Local sing-box.",
});
return;
}
setSingBoxAction("fetch");
try {
const status = await fetchSingBoxSubscription(
subscriptionUrl || undefined,
);
if (!request.current()) return;
acceptSubscriptionStatus(status);
request.clearSubmittedInput();
setServerPings({});
setProxyCheck(null);
showNotice({
kind: "success",
title: "Подписка обновлена",
text: `Серверов: ${status.cache?.servers.length ?? 0}`,
});
} catch (error) {
if (!request.current()) return;
showNotice({
kind: "error",
title: "Подписка не обновлена",
text: errorMessage(error),
});
} finally {
if (request.current()) setSingBoxAction(null);
}
}
async function forgetSingBoxSubscriptionData() {
if (isApplying || singBoxAction) return;
const request = subscription.begin();
setSingBoxAction("forget");
setIsSingBoxMenuOpen(false);
try {
const status = await forgetSingBoxSubscription();
if (!request.current()) return;
acceptSubscriptionStatus(status);
setServerPings({});
setProxyCheck(null);
showNotice({
kind: "info",
title: "Подписка очищена",
text: "Ссылка, cache и выбранный сервер Local sing-box удалены.",
});
} catch (error) {
if (!request.current()) return;
showNotice({
kind: "error",
title: "Подписка не очищена",
text: errorMessage(error),
});
} finally {
if (request.current()) setSingBoxAction(null);
}
}
async function chooseSingBoxServer(server: SubscriptionServer) {
if (isApplying || singBoxAction) return;
const request = subscription.begin();
setSingBoxAction("select");
try {
const status = await selectSingBoxServer(server);
if (!request.current()) return;
acceptSubscriptionStatus(status);
setProxyCheck(null);
} catch (error) {
if (!request.current()) return;
showNotice({
kind: "error",
title: "Сервер не выбран",
text: errorMessage(error),
});
} finally {
if (request.current()) setSingBoxAction(null);
}
}
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) {
setProxyCheck({ failure: errorMessage(error) });
return;
}
const request = ++probeRequest.current;
const generation = draft.generation.current;
const server = currentServer.current;
const current = () =>
request === probeRequest.current &&
generation === draft.generation.current &&
server === currentServer.current;
setProxyCheck(null);
setIsProxyChecking(true);
try {
const result = await pingProxyTarget(target.host, target.port);
if (!current()) return;
setProxyCheck(result);
} catch (error) {
if (!current()) return;
setProxyCheck({ failure: errorMessage(error) });
} finally {
if (request === probeRequest.current) setIsProxyChecking(false);
}
}
async function generateSingBoxNow() {
if (isApplying || singBoxAction) return;
setSingBoxAction("generate");
try {
const result = await generateSingBoxConfig();
try {
await refreshSingBoxState();
} catch (error) {
showNotice({
kind: "info",
title: "Конфиг создан, состояние служб не обновлено",
text: errorMessage(error),
});
return;
}
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 toggleAdminPrompt() {
if (adminPromptTimerRef.current !== null) {
window.clearTimeout(adminPromptTimerRef.current);
adminPromptTimerRef.current = null;
}
setIsAdminPromptHintVisible(false);
setIsAdminPromptOpen((open) => !open);
}
function renderAdminPrompt() {
if (!hasAdminPrompt) return null;
return (
<aside
className={`admin-prompt ${isAdminPromptOpen ? "is-expanded" : "is-collapsed"} ${isAdminPromptHintVisible ? "has-hint" : ""}`}
aria-label="Права администратора"
>
<button
type="button"
className="admin-prompt-toggle"
onClick={toggleAdminPrompt}
aria-expanded={isAdminPromptOpen}
aria-controls="admin-prompt-details"
aria-label={
isAdminPromptOpen
? "Свернуть информацию о правах администратора"
: "Показать, зачем нужны права администратора"
}
>
<ShieldAlert size={18} strokeWidth={1.9} aria-hidden="true" />
</button>
{isAdminPromptHintVisible && !isAdminPromptOpen ? (
<span className="admin-prompt-hint" role="status">
Windows запросит права для выбранной операции.
</span>
) : null}
<div
className="admin-prompt-details"
id="admin-prompt-details"
aria-hidden={!isAdminPromptOpen}
>
<div className="admin-prompt-copy">
<strong>Права для отдельных операций</strong>
<span>
Windows запросит UAC при установке и управлении службами.
</span>
</div>
</div>
</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
unavailable={Boolean(startupError)}
installed={Boolean(proxyfier?.installed)}
running={Boolean(proxyfier?.running)}
working={
serviceAction === "start" ||
serviceAction === "stop" ||
serviceAction === "restart"
}
checking={isDetectingComponents || Boolean(serviceAction)}
tone={systemSummary.tone}
/>
{renderRouteChain("vertical")}
<p className="summary-state-copy" role="status">
<strong>{systemSummary.title}</strong>
<br />
{systemSummary.text}
</p>
</div>
</section>
);
}
function renderProxiFyreCard() {
const state = serviceControlState(proxyfier, isDetectingComponents);
const lifecycleAllowed = isCutoverLifecycleComplete(
proxifyreCutoverStatus?.state ?? null,
);
const serviceControlAllowed =
lifecycleAllowed ||
canUseStoppedProxiFyreRouteSmoke(proxifyreCutoverStatus);
const lifecycleBusy =
isApplying ||
Boolean(startupError) ||
componentPackages.isInitializing ||
componentPackages.busyAction !== null;
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 installPackageReady = isComponentInstallPackageReady(
proxifyrePackageStatus?.canInstallOffline,
componentPackages.packageStatusError !== null,
);
const primaryAction = shouldInstallProxiFyre
? {
label: lifecycleAllowed
? installPackageReady
? proxyfier?.installed
? "Переустановить"
: "Установить"
: componentPackages.isInitializing
? "Проверяю пакет"
: "Пакет недоступен"
: blockedLifecycleLabel(proxifyreCutoverStatus),
onClick: () => void installProxiFyrePackage(),
variant: lifecycleAllowed
? installPackageReady
? ("primary" as const)
: ("neutral" as const)
: ("neutral" as const),
loading: serviceAction === "install",
loadingLabel: "Устанавливаю",
disabled:
!lifecycleAllowed ||
!installPackageReady ||
lifecycleBusy ||
isDetectingComponents ||
Boolean(serviceAction),
}
: {
label: serviceControlAllowed
? proxyfier.running
? "Остановить"
: "Запустить"
: blockedLifecycleLabel(proxifyreCutoverStatus),
onClick: () => void setProxiFyreServiceRunning(!proxyfier.running),
variant:
serviceControlAllowed && proxyfier.running
? ("danger" as const)
: ("neutral" as const),
loading:
serviceAction === "start" ||
serviceAction === "stop" ||
serviceAction === "restart",
loadingLabel:
serviceAction === "restart"
? "Перезапускаю"
: serviceAction === "start"
? "Запускаю"
: "Останавливаю",
disabled:
!serviceControlAllowed ||
lifecycleBusy ||
isDetectingComponents ||
Boolean(serviceAction),
};
return (
<ServiceControlRow
state={state}
visualState={visualState}
className="proxifyre-card"
title="ProxiFyre"
detail={proxyfierCompactStatus(proxyfier, isDetectingComponents)}
primaryAction={
startupError
? { ...primaryAction, label: "Запуск не завершён", disabled: true }
: primaryAction
}
menu={
canCleanupSetup
? {
label: "Дополнительные действия ProxiFyre",
open: isServiceMenuOpen,
onOpenChange: setIsServiceMenuOpen,
disabled:
!lifecycleAllowed ||
lifecycleBusy ||
isDetectingComponents ||
Boolean(serviceAction),
items: [
{
label: "Добавить / восстановить правила Firewall",
disabled: !proxyfier?.installed || Boolean(serviceAction),
onClick: () => void configureFirewall(),
},
{
label:
serviceAction === "uninstall"
? "Удаляю..."
: "Удалить ProxiFyre и драйвер",
danger: true,
disabled:
!lifecycleAllowed ||
lifecycleBusy ||
Boolean(serviceAction),
onClick: () => void uninstallProxiFyrePackage(),
},
],
}
: undefined
}
/>
);
}
function renderAppsSection() {
return (
<section className="apps-section" aria-label="Приложения ProxiFyre">
{draft.profiles.length > 1 ? (
<label className="simple-field">
<span>Профиль</span>
<select
value={profileId}
disabled={isApplying}
onChange={(event) => {
if (
hasUnappliedChanges &&
!window.confirm("Отбросить изменения текущего профиля?")
)
return;
try {
draft.edited();
const next = draft.load(
draft.profiles,
draft.targets,
event.target.value,
);
selectedProfile.current = next.profileId;
setProfileId(next.profileId);
setTargetId(next.targetId);
setProxyInput(next.proxyInput);
setRouteMode(next.routeMode);
setItems(next.items);
setProxyCheck(null);
setSavedSnapshot(
configSnapshotFromUi(
next.routeMode,
next.proxyInput,
next.items,
singBoxStatus?.config.selectedServerId,
singBoxStatus?.config.selectedServerTag,
next.items.length > 0 && (next.profile?.enabled ?? true),
),
);
} catch (error) {
showNotice({
kind: "error",
title: "Профиль не открыт",
text: errorMessage(error),
});
}
}}
>
{draft.profiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.name}
{profile.enabled ? "" : " (выключен)"}
</option>
))}
</select>
</label>
) : null}
<AppList
profileEnabled={draft.selection?.profile ? draft.enabled : undefined}
onProfileEnabledChange={draft.setEnabled}
items={items}
loading={isLoading}
addingPathType={pickerAction}
onAddProcess={addProcess}
onAddPath={(type) => void pickAndAddItem(type)}
onRemove={removeItem}
/>
</section>
);
}
function renderComponentManagement(
componentId: ManagedPackageComponentId,
packageStatus: typeof proxifyrePackageStatus,
cutoverStatus: ComponentCutoverStatus | null,
migrationStatus: StorageMigrationStatus | null = null,
) {
const ownsLastAction = lastComponentAction?.componentId === componentId;
const uacCancelled = cancelledComponentAction?.componentId === componentId;
const packageAction =
lastComponentAction?.kind === "check" ||
lastComponentAction?.kind === "download" ||
lastComponentAction?.kind === "update";
const packageError =
(ownsLastAction && packageAction ? componentPackages.error : null) ??
componentPackages.packageStatusError ??
(startupReady && !componentPackages.isInitializing && !packageStatus
? MISSING_PACKAGE_STATUS_ERROR
: null);
const migrationError =
(ownsLastAction && !packageAction ? componentPackages.error : null) ??
componentPackages.cutoverStatusError ??
(startupReady && !componentPackages.isInitializing && !cutoverStatus
? MISSING_CUTOVER_STATUS_ERROR
: null);
const packageUacCancelled = uacCancelled && packageAction;
const migrationUacCancelled = uacCancelled && !packageAction;
const externalBusyReason =
isApplying || serviceAction || singBoxAction
? "Дождись завершения текущей операции с конфигурацией или службой."
: null;
const serviceRunning =
componentId === "proxifyre"
? Boolean(proxyfier?.running)
: Boolean(singbox?.running);
return (
<div className="component-management-stack">
<MigrationNotice
migrationStatus={migrationStatus}
cutoverStatus={cutoverStatus}
busyAction={componentPackages.busyAction}
externalBusyReason={externalBusyReason}
actionBlockedReason={
cutoverStatus?.state === "awaiting_route_smoke" &&
cutoverStatus.originalServiceState === "stopped" &&
serviceRunning
? "Сначала явно останови службу и только затем подтверждай маршрут."
: null
}
error={migrationError}
uacCancelled={migrationUacCancelled}
onCutover={cutoverManagedComponent}
onConfirmRouteSmoke={confirmManagedComponentRoute}
onCleanup={cleanupManagedComponent}
/>
{packageStatus ? (
<ComponentPackageStatusView
status={packageStatus}
busyAction={componentPackages.busyAction}
externalBusyReason={externalBusyReason}
updateBlockedReason={getComponentUpdateBlockReason(
cutoverStatus?.state ?? null,
serviceRunning,
)}
error={packageError}
uacCancelled={packageUacCancelled}
onCheck={checkComponentPackage}
onDownload={downloadComponentPackage}
onUpdate={updateManagedComponent}
/>
) : packageError ? (
<ComponentPackageStatusUnavailable
componentId={componentId}
error={packageError}
/>
) : componentPackages.isInitializing ? (
<ComponentPackageStatusLoading componentId={componentId} />
) : null}
</div>
);
}
function renderChangesDock() {
if (!hasUnappliedChanges || !savedSnapshot) return null;
const externalProxyError =
routeMode === "external" ? safeProxyError(proxyInput) : null;
const readiness = getApplyReadiness({
routeMode,
appCount: items.length,
canClearProfile:
Boolean(draft.selection?.profile?.items.length) && !proxyfier?.running,
proxiFyreInstalled: Boolean(proxyfier?.installed),
singBoxInstalled: isSingBoxInstalled,
singBoxRunning: Boolean(singbox?.running),
selectedServerTag: singBoxStatus?.config.selectedServerTag,
externalProxyValue: proxyInput,
externalProxyError,
busy:
isApplying ||
componentPackages.isInitializing ||
componentPackages.busyAction !== null ||
Boolean(serviceAction) ||
Boolean(singBoxAction),
proxiFyreCutoverState: proxifyreCutoverStatus?.state ?? null,
singBoxCutoverState: singBoxCutoverStatus?.state ?? null,
});
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.length ? (
<li className="change-row changed">
<span className="change-label">Сохранённые настройки</span>
<span className="change-values">
Требуется подготовить конфиги
</span>
</li>
) : null}
{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()}
{renderComponentManagement(
"proxifyre",
proxifyrePackageStatus,
proxifyreCutoverStatus,
storageMigrationStatus,
)}
{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 lifecycleAllowed = isCutoverLifecycleComplete(
singBoxCutoverStatus?.state ?? null,
);
const lifecycleBusy =
isApplying ||
Boolean(startupError) ||
componentPackages.isInitializing ||
componentPackages.busyAction !== null;
const setupSummary = singBoxSetupStatus
? singBoxSetupStatus.ready
? "состав готов"
: `не хватает: ${singBoxSetupStatus.missingCount}`
: "состав не проверен";
const installPackageReady = isComponentInstallPackageReady(
singBoxPackageStatus?.canInstallOffline,
componentPackages.packageStatusError !== null,
);
const primaryAction = singbox?.installed
? {
label: lifecycleAllowed
? singbox.running
? "Остановить"
: "Запустить"
: blockedLifecycleLabel(singBoxCutoverStatus),
onClick: () => void setSingBoxServiceRunning(!singbox.running),
variant:
lifecycleAllowed && singbox.running
? ("danger" as const)
: ("neutral" as const),
loading: singBoxAction === "start" || singBoxAction === "stop",
loadingLabel: singBoxAction === "start" ? "Запускаю" : "Останавливаю",
disabled:
!lifecycleAllowed ||
lifecycleBusy ||
isDetectingComponents ||
Boolean(singBoxAction),
}
: {
label: lifecycleAllowed
? installPackageReady
? "Установить"
: componentPackages.isInitializing
? "Проверяю пакет"
: "Пакет недоступен"
: blockedLifecycleLabel(singBoxCutoverStatus),
onClick: () => void installSingBoxPackage(),
variant: lifecycleAllowed
? installPackageReady
? ("primary" as const)
: ("neutral" as const)
: ("neutral" as const),
loading: singBoxAction === "install",
loadingLabel: "Устанавливаю",
disabled:
!lifecycleAllowed ||
!installPackageReady ||
lifecycleBusy ||
isDetectingComponents ||
Boolean(singBoxAction),
};
return (
<>
<ServiceControlRow
state={state}
visualState={singBoxAction ? "working" : null}
className="singbox-card"
title="Local sing-box"
detail={singBoxDetails(singbox, singBoxStatus, isDetectingComponents)}
primaryAction={
startupError
? {
...primaryAction,
label: "Запуск не завершён",
disabled: true,
}
: primaryAction
}
menu={
singbox?.installed
? {
label: "Дополнительные действия Local sing-box",
open: isSingBoxMenuOpen,
onOpenChange: setIsSingBoxMenuOpen,
disabled:
!lifecycleAllowed ||
lifecycleBusy ||
isDetectingComponents ||
Boolean(singBoxAction),
items: [
{
label:
singBoxAction === "uninstall"
? "Удаляю..."
: "Удалить Local sing-box",
danger: true,
disabled:
!lifecycleAllowed ||
lifecycleBusy ||
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>
{renderComponentManagement(
"sing-box",
singBoxPackageStatus,
singBoxCutoverStatus,
)}
</>
);
}
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={isApplying || 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={
isApplying ||
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={
isApplying || 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"
disabled={isApplying || Boolean(singBoxAction)}
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}
style={
isVertical
? ({ animationDelay: `${index * 280}ms` } as CSSProperties)
: undefined
}
>
<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",
activePanel !== "summary" && hasUnappliedChanges
? "has-change-dock"
: "",
]
.filter(Boolean)
.join(" ")}
style={shellStyle}
>
<section className="simple-panel">
{startupError ? (
<aside role="alert" className="startup-error">
<p>{startupError}</p>
<Button onClick={() => void refresh()} disabled={isLoading}>
Повторить запуск
</Button>
</aside>
) : null}
{renderTabs()}
<div
className={`tab-panel-frame swipe-${tabTransitionDirection}`}
key={activePanel}
>
{renderActivePanel()}
</div>
</section>
{activePanel !== "summary" ? renderChangesDock() : null}
<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());
});
}