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