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("summary"); const [tabTransitionDirection, setTabTransitionDirection] = useState("right"); const [proxyInput, setProxyInput] = useState(""); const [routeMode, setRouteMode] = useState("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([]); const [savedSnapshot, setSavedSnapshot] = useState( null, ); const [pickerAction, setPickerAction] = useState<"exe" | "folder" | null>( null, ); const [components, setComponents] = useState(fallbackComponents); const [artifacts, setArtifacts] = useState([]); const [startupReady, setStartupReady] = useState(false); const [startupError, setStartupError] = useState(null); const startupRequest = useRef(0); const probeRequest = useRef(0); const currentServer = useRef(undefined); const [storageMigrationStatus, setStorageMigrationStatus] = useState(null); const [lastComponentAction, setLastComponentAction] = useState(null); const [cancelledComponentAction, setCancelledComponentAction] = useState(null); const [setupStatus, setSetupStatus] = useState( null, ); const [singBoxStatus, setSingBoxStatus] = useState(null); const [singBoxSetupStatus, setSingBoxSetupStatus] = useState(null); currentServer.current = singBoxStatus?.config.selectedServerId; const subscription = useSubscription(); const { input: subscriptionInput, setInput: setSubscriptionInput } = subscription; const [serverPings, setServerPings] = useState< Record >({}); const [proxyCheck, setProxyCheck] = useState(null); const [adminStatus, setAdminStatus] = useState( 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(null); const [serviceAction, setServiceAction] = useState( null, ); const [singBoxAction, setSingBoxAction] = useState( null, ); const [isServiceMenuOpen, setIsServiceMenuOpen] = useState(false); const [isSingBoxMenuOpen, setIsSingBoxMenuOpen] = useState(false); const [serviceVisualState, setServiceVisualState] = useState(null); const serviceVisualTimerRef = useRef(null); const [isAdminPromptOpen, setIsAdminPromptOpen] = useState(false); const [isAdminPromptHintVisible, setIsAdminPromptHintVisible] = useState(false); const adminPromptSeenRef = useRef(false); const adminPromptTimerRef = useRef(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( action: ComponentPackageBusyAction, command: () => Promise>, 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, ) { 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 ( ); } function renderTabs() { const tabs: Array<{ id: PanelId; label: string }> = [ { id: "proxifyre", label: "ProxiFyre" }, { id: "summary", label: "ProxyWarden" }, { id: "proxy", label: "VPN / Прокси" }, ]; return ( ); } function renderSummaryPanel() { return (

ProxyWarden

{renderRouteChain("vertical")}

{systemSummary.title}
{systemSummary.text}

); } 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 ( void configureFirewall(), }, { label: serviceAction === "uninstall" ? "Удаляю..." : "Удалить ProxiFyre и драйвер", danger: true, disabled: !lifecycleAllowed || lifecycleBusy || Boolean(serviceAction), onClick: () => void uninstallProxiFyrePackage(), }, ], } : undefined } /> ); } function renderAppsSection() { return (
{draft.profiles.length > 1 ? ( ) : null} void pickAndAddItem(type)} onRemove={removeItem} />
); } 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 (
{packageStatus ? ( ) : packageError ? ( ) : componentPackages.isInitializing ? ( ) : null}
); } 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 ( ); } function renderProxiFyrePanel() { return (
{renderProxiFyreCard()} {renderComponentManagement( "proxifyre", proxifyrePackageStatus, proxifyreCutoverStatus, storageMigrationStatus, )} {renderAppsSection()}
); } function renderExternalProxyControls() { const proxyValidation = proxyInput.trim() ? safeProxyError(proxyInput) : null; return (
); } 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 ( <> void uninstallSingBoxPackage(), }, ], } : undefined } inlineActions={ } > {isSingBoxInstalled ? renderSingBoxWorkspace() : null} {renderComponentManagement( "sing-box", singBoxPackageStatus, singBoxCutoverStatus, )} ); } function renderSingBoxWorkspace() { return (
setSubscriptionInput(event.target.value)} placeholder={ singBoxStatus?.config.subscriptionDisplayUrl ?? "https://example.com/sub" } spellCheck={false} /> void forgetSingBoxSubscriptionData()} disabled={ Boolean(singBoxAction) || !singBoxStatus?.config.hasSubscription } label="Очистить подписку Local sing-box" tooltip="Очистить" icon={} />

Загрузка обращается к указанному провайдеру подписки и передает случайный постоянный ID установки в заголовке X-HWID.

Серверы подписки
void pingSingBoxServers()} disabled={ isApplying || Boolean(singBoxAction) || !singBoxStatus?.cache?.servers.length } loading={singBoxAction === "ping"} label="Проверить все серверы подписки" tooltip="Проверить все серверы" icon={} /> void generateSingBoxNow()} disabled={ isApplying || Boolean(singBoxAction) || !selectedServerTag } loading={singBoxAction === "generate"} label="Создать конфиг Local sing-box" tooltip="Создать конфиг" icon={} />
{singBoxStatus?.cache?.servers.length ? (
{singBoxStatus.cache.servers.map((server) => { const ping = serverPings[server.id]; const selected = selectedServerId ? server.id === selectedServerId : server.tag === selectedServerTag; return (
void pingSingleSingBoxServer(server)} disabled={Boolean(serverPingTag)} loading={serverPingTag === server.id} label={`Проверить ${displayServerTag(server.tag)}`} tooltip="Ping" icon={} />
); })}
) : (
Подписка Local sing-box еще не загружена.
)}
); } function renderProxyOverview() { const check = connectionCheckView({ routeMode, proxyInput, proxyCheck, singbox, singBoxStatus, selectedServer, isDetectingComponents, isProxyChecking, }); return (
{check.tone === "checking" ? : null} void checkRouteProxy()} />
); } function renderProxyPanel() { return (
Режим маршрута
{routeMode === "external" ? renderExternalProxyControls() : renderSingBoxCard()}
{renderProxyOverview()}
); } 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 (
{isVertical ? (
); } function renderActivePanel() { if (activePanel === "proxifyre") return renderProxiFyrePanel(); if (activePanel === "proxy") return renderProxyPanel(); return renderSummaryPanel(); } return (
{startupError ? ( ) : null} {renderTabs()}
{renderActivePanel()}
{activePanel !== "summary" ? renderChangesDock() : null} {renderAdminPrompt()}
); } async function pickPath(type: Extract) { 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((resolve) => { window.requestAnimationFrame(() => resolve()); }); }