import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; import { open } from '@tauri-apps/plugin-dialog'; import { Cpu, FileCode2, FolderOpen, Gauge, Link2, ShieldAlert, Trash2, Wand2 } from 'lucide-react'; import { applyProfiles, fetchSingBoxSubscription, forgetSingBoxSubscription, generateSingBoxConfig, getComponents, getProxiFyreSetupStatus, getSavedState, getSingBoxSetupStatus, getSingBoxStatus, getStartupSnapshot, installProxiFyre, installSingBox, pingAllSingBoxServers, pingProxyTarget, pingSingBoxServer, restartAsAdmin, saveProfile, saveSingBoxSubscription, saveTarget, selectSingBoxServer, startProxiFyreService, startSingBoxService, stopProxiFyreService, stopSingBoxService, uninstallProxiFyre, uninstallSingBox, type AdminStatusResponse, type ApplyProfilesResponse, type LocalSingBoxStatusResponse, type PingServerResponse, type ProxyProbeResponse, type ProxyTargetCheckResponse, type ProxiFyreSetupStatus, type SingBoxSetupStatus, } from '../api/tauriCommands'; import type { ComponentStatus, Profile, ProfileItemInput, ProfileItemType, SubscriptionServer, Target } from '../domain/types'; import { BusyRing, Button, DetailsPopover, IconButton, LogDock, ServiceControlRow, Tabs } from '../ui'; import { getApplyReadiness } from './readiness'; import { serviceControlState } from './viewModel'; type DraftItemType = Extract; type ProxiFyreAction = 'start' | 'stop' | 'restart' | 'install' | 'uninstall'; type SingBoxAction = 'start' | 'stop' | 'install' | 'uninstall' | 'fetch' | 'forget' | 'generate' | 'ping'; type RouteMode = 'external' | 'local-singbox'; type ServiceVisualState = 'active' | 'settling' | null; type PanelId = 'summary' | 'proxifyre' | 'proxy'; type StatusTone = 'ok' | 'warning' | 'error' | 'checking' | 'muted'; type TabTransitionDirection = 'left' | 'right'; type SummaryRouteFlow = 'proxy' | 'direct' | 'idle'; interface DraftItem { id: string; type: DraftItemType; value: string; } interface Notice { kind: 'success' | 'error' | 'info'; title: string; text: string; } interface LogEntry extends Notice { id: string; at: number; } interface ConfigSnapshotItem { type: DraftItemType; value: string; } interface ConfigSnapshot { routeMode: RouteMode; proxy: string; selectedServerTag: string; items: ConfigSnapshotItem[]; } interface ConnectionCheckView { tone: StatusTone; title: string; text: string; endpoint: string; details: string[]; probes: ConnectionProbeView[]; disabledReason?: string; loading: boolean; } interface ConnectionProbeView { id: string; label: string; value: string; tone: StatusTone; } interface RouteChainSegment { id: string; label: string; value: string; tone: StatusTone; details: string[]; } interface PendingChangeRow { id: string; label: string; before?: string; after: string; tone?: 'added' | 'removed' | 'changed'; } interface ConnectionCheckInput { routeMode: RouteMode; proxyInput: string; proxyCheck: ProxyTargetCheckResponse | null; singbox: ComponentStatus | undefined; singBoxStatus: LocalSingBoxStatusResponse | null; selectedServer: SubscriptionServer | null; isDetectingComponents: boolean; isProxyChecking: boolean; } interface RouteChainInput { routeMode: RouteMode; proxyInput: string; proxyfier: ComponentStatus | undefined; singbox: ComponentStatus | undefined; singBoxStatus: LocalSingBoxStatusResponse | null; selectedServer: SubscriptionServer | null; appCount: number; isDetectingComponents: boolean; } const MAIN_TARGET_ID = 'main-proxy'; const MAIN_PROFILE_ID = 'main-profile'; const LOCAL_SINGBOX_TARGET_ID = 'local-singbox'; const LOG_VISIBLE_MS = 6500; const PANEL_ORDER: PanelId[] = ['proxifyre', 'summary', 'proxy']; const SHOW_DEV_SUBSCRIPTION_IDENTITY = import.meta.env.DEV; const proxyWardenToggleOnImage = new URL('../assets/proxywarden-toggle-on.png', import.meta.url).href; const proxyWardenToggleOffImage = new URL('../assets/proxywarden-toggle-off.png', import.meta.url).href; const fallbackComponents: ComponentStatus[] = [ { id: 'proxyfier', name: 'ProxiFyre', state: 'missing', installed: false, running: false, problems: ['ProxiFyre не найден'], actions: [], }, { id: 'singbox', name: 'Локальный sing-box', state: 'missing', installed: false, running: false, problems: [], actions: [], }, ]; export function App() { const [activePanel, setActivePanel] = useState('summary'); const [tabTransitionDirection, setTabTransitionDirection] = useState('right'); const [proxyInput, setProxyInput] = useState(''); const [routeMode, setRouteMode] = useState('external'); const [profileId, setProfileId] = useState(MAIN_PROFILE_ID); const [targetId, setTargetId] = useState(MAIN_TARGET_ID); const [items, setItems] = useState([]); const [appliedSnapshot, setAppliedSnapshot] = useState(null); const [loadedProfiles, setLoadedProfiles] = useState([]); const [isProcessInputOpen, setIsProcessInputOpen] = useState(false); const [processInput, setProcessInput] = useState(''); const [pickerAction, setPickerAction] = useState<'exe' | 'folder' | null>(null); const [components, setComponents] = useState(fallbackComponents); const [setupStatus, setSetupStatus] = useState(null); const [singBoxStatus, setSingBoxStatus] = useState(null); const [singBoxSetupStatus, setSingBoxSetupStatus] = useState(null); const [subscriptionInput, setSubscriptionInput] = useState(''); const [serverPings, setServerPings] = useState>({}); const [proxyCheck, setProxyCheck] = useState(null); const [adminStatus, setAdminStatus] = useState(null); const [generatedConfigPath, setGeneratedConfigPath] = useState(''); const [logEntries, setLogEntries] = useState([]); const [activeLogId, setActiveLogId] = useState(null); const [isLogOpen, setIsLogOpen] = useState(false); const [isLoading, setIsLoading] = useState(true); const [isDetectingComponents, setIsDetectingComponents] = useState(true); const [isApplying, setIsApplying] = useState(false); const [isRestartingAsAdmin, setIsRestartingAsAdmin] = useState(false); const [isProxyChecking, setIsProxyChecking] = useState(false); const [serverPingTag, setServerPingTag] = useState(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 proxyfier = useMemo( () => components.find((component) => component.id === 'proxyfier'), [components], ); const singbox = useMemo( () => singBoxStatus?.component ?? components.find((component) => component.id === 'singbox'), [components, singBoxStatus], ); const activeLog = useMemo( () => logEntries.find((entry) => entry.id === activeLogId) ?? null, [activeLogId, logEntries], ); const isSingBoxInstalled = Boolean(singbox?.installed); const selectedServerTag = singBoxStatus?.config.selectedServerTag; const selectedServer = useMemo( () => singBoxStatus?.cache?.servers.find((server) => server.tag === selectedServerTag) ?? null, [selectedServerTag, singBoxStatus], ); const currentSnapshot = useMemo( () => configSnapshotFromUi(routeMode, proxyInput, items, selectedServerTag), [items, proxyInput, routeMode, selectedServerTag], ); const pendingChanges = useMemo( () => appliedSnapshot ? configChangeRows(appliedSnapshot, currentSnapshot) : [], [appliedSnapshot, currentSnapshot], ); const hasUnappliedChanges = pendingChanges.length > 0; const hasAdminPrompt = Boolean(adminStatus?.canRestartElevated); const shellStyle = hasUnappliedChanges ? ({ '--change-row-count': String(pendingChanges.length) } as CSSProperties) : undefined; const systemSummary = systemSummaryState({ isLoading, isDetectingComponents, proxyfier, singbox, routeMode: appliedSnapshot?.routeMode ?? routeMode, singBoxStatus, proxyCheck, }); useEffect(() => { void refresh(); }, []); useEffect(() => { return () => { if (serviceVisualTimerRef.current !== null) { window.clearTimeout(serviceVisualTimerRef.current); } }; }, []); useEffect(() => { if (!activeLogId) return undefined; const timer = window.setTimeout(() => { setActiveLogId((current) => (current === activeLogId ? null : current)); }, LOG_VISIBLE_MS); return () => window.clearTimeout(timer); }, [activeLogId]); async function refresh() { setIsLoading(true); setIsDetectingComponents(true); try { const snapshot = await getStartupSnapshot(); setAdminStatus(snapshot.adminStatus); setComponents(snapshot.components); setSetupStatus(snapshot.proxifyreSetupStatus); setSingBoxStatus(snapshot.singboxStatus); setSingBoxSetupStatus(snapshot.singboxSetupStatus); applySavedState( snapshot.savedState.profiles, snapshot.savedState.targets, snapshot.savedState.generatedConfigPath, snapshot.singboxStatus, ); } catch { showNotice({ kind: 'info', title: 'Режим предпросмотра', text: 'Запусти приложение через Tauri, чтобы увидеть найденный ProxiFyre и применить конфиг.', }); } finally { setIsLoading(false); setIsDetectingComponents(false); } } async function restartApplicationAsAdmin() { setIsRestartingAsAdmin(true); try { await restartAsAdmin(); setIsRestartingAsAdmin(false); } catch (error) { setIsRestartingAsAdmin(false); showNotice({ kind: 'error', title: 'Перезапуск отменен', text: errorMessage(error), }); } } function applySavedState( profiles: Profile[], targets: Target[], generatedPath: string, singBoxStatusForSnapshot = singBoxStatus, ) { const activeProfiles = profiles.filter((profile) => profile.enabled); const mainProfile = profiles.find((profile) => profile.id === MAIN_PROFILE_ID); const activeProfile = mainProfile ?? activeProfiles[0]; const activeTarget = targetForUi(targets, activeProfile); const externalTarget = targetForExternalProxy(targets); const editableProfiles = mainProfile ? [mainProfile] : activeProfiles; const savedProxyInput = externalTarget ? formatProxy(externalTarget) : ''; const savedItems = itemsForProfiles(editableProfiles); const savedRouteMode = activeTarget?.id === LOCAL_SINGBOX_TARGET_ID || activeProfile?.targetId === LOCAL_SINGBOX_TARGET_ID ? 'local-singbox' : 'external'; setProxyInput(savedProxyInput); setProxyCheck(null); setItems(savedItems); setLoadedProfiles(profiles); setProfileId(mainProfile?.id ?? MAIN_PROFILE_ID); setTargetId(externalTarget?.id ?? MAIN_TARGET_ID); setRouteMode(savedRouteMode); setGeneratedConfigPath(generatedPath); setAppliedSnapshot(configSnapshotFromUi( savedRouteMode, savedProxyInput, savedItems, singBoxStatusForSnapshot?.config.selectedServerTag, )); } function addItem(type: DraftItemType, rawValue: string) { const value = normalizeItemValue(rawValue, type); if (!value) { showNotice({ kind: 'error', title: 'Нечего добавить', text: emptyItemMessage(type), }); return false; } if (items.some((item) => item.type === type && sameValue(item.value, value))) { showNotice({ kind: 'info', title: 'Уже добавлено', text: value, }); return false; } setItems((current) => [ ...current, { id: `${type}-${Date.now()}`, type, value, }, ]); return true; } function addProcess() { if (addItem('process', processInput)) { setProcessInput(''); setIsProcessInputOpen(false); } } function removeItem(id: string) { setItems((current) => current.filter((item) => item.id !== id)); } function changeRouteMode(nextMode: RouteMode) { setRouteMode(nextMode); setProxyCheck(null); } function changeProxyInput(nextValue: string) { setProxyInput(nextValue); setProxyCheck(null); } function switchPanel(nextPanel: PanelId) { if (activePanel === nextPanel) return; const currentIndex = PANEL_ORDER.indexOf(activePanel); const nextIndex = PANEL_ORDER.indexOf(nextPanel); setTabTransitionDirection(nextIndex > currentIndex ? 'right' : 'left'); setActivePanel(nextPanel); } async function pickAndAddItem(type: Extract) { setPickerAction(type); try { const selectedPath = await pickPath(type); if (selectedPath) { addItem(type, selectedPath); } } catch (error) { showNotice({ kind: 'error', title: type === 'exe' ? 'EXE не выбран' : 'Папка не выбрана', text: errorMessage(error), }); } finally { setPickerAction(null); } } async function updateConfig() { let parsedProxy: ParsedProxy | null = null; try { if (!items.length) throw new Error('Добавь хотя бы один процесс, EXE-файл или папку.'); if (routeMode === 'external') { parsedProxy = parseProxy(proxyInput); } else if (!isSingBoxInstalled) { throw new Error('Сначала установи Local sing-box.'); } else if (!singBoxStatus?.config.selectedServerTag) { throw new Error('Выбери сервер Local sing-box.'); } } catch (error) { showNotice({ kind: 'error', title: 'Проверь данные', text: errorMessage(error), }); return; } setIsApplying(true); try { let singBoxGeneratedPath = ''; if (routeMode === 'external') { if (!parsedProxy) throw new Error('Прокси не разобран.'); await saveTarget({ id: targetId, name: 'Основной прокси', kind: 'external', protocol: parsedProxy.protocol, host: parsedProxy.host, port: parsedProxy.port, }); } else { const singBoxResult = await generateSingBoxConfig(); singBoxGeneratedPath = singBoxResult.generatedConfigPath; await ensureSingBoxRunningForApply(); } await saveProfile({ id: profileId, name: 'Приложения через прокси', enabled: true, targetId: routeMode === 'local-singbox' ? LOCAL_SINGBOX_TARGET_ID : targetId, protocols: ['TCP', 'UDP'], items: items.map(profileItemInput), }); await Promise.all( loadedProfiles .filter((profile) => profile.enabled && profile.id !== profileId) .map((profile) => saveProfile(profileInputFromProfile(profile, false))), ); const result = await applyProfiles(); await restartProxiFyreAfterApply(); const [saved, detectedComponents, detectedSetupStatus, detectedSingBoxStatus, detectedSingBoxSetupStatus] = await Promise.all([ getSavedState(), getComponents(), getProxiFyreSetupStatus(), getSingBoxStatus(), getSingBoxSetupStatus(), ]); applySavedState(saved.profiles, saved.targets, result.generatedConfigPath); setComponents(detectedComponents); setSetupStatus(detectedSetupStatus); setSingBoxStatus(detectedSingBoxStatus); setSingBoxSetupStatus(detectedSingBoxSetupStatus); showNotice(routeMode === 'local-singbox' ? noticeFromLocalApply(result, singBoxGeneratedPath) : noticeFromApply(result)); } catch (error) { showNotice({ kind: 'error', title: 'Конфиг не обновлен', text: errorMessage(error), }); } finally { setIsApplying(false); } } async function ensureSingBoxRunningForApply() { if (routeMode !== 'local-singbox' || !singbox?.installed) return; setIsSingBoxMenuOpen(false); try { await nextFrame(); if (singbox.running) { setSingBoxAction('stop'); const stopped = await stopSingBoxService(); setComponents((current) => upsertComponent(current, stopped)); } setSingBoxAction('start'); const component = await startSingBoxService(); setComponents((current) => upsertComponent(current, component)); const status = await refreshSingBoxState(); if (!status.component.running) { throw new Error('Local sing-box установлен, но служба не запустилась.'); } } finally { setSingBoxAction(null); } } async function setProxiFyreServiceRunning(shouldRun: boolean) { const action = shouldRun ? 'start' : 'stop'; setServiceAction(action); setIsServiceMenuOpen(false); startServiceVisual(); try { await nextFrame(); const component = shouldRun ? await startProxiFyreService() : await stopProxiFyreService(); setComponents((current) => upsertComponent(current, component)); showNotice({ kind: 'success', title: shouldRun ? 'Служба запущена' : 'Служба остановлена', text: proxyfierDetails(component, false), }); } catch (error) { showNotice({ kind: 'error', title: shouldRun ? 'Служба не запущена' : 'Служба не остановлена', text: errorMessage(error), }); } finally { setServiceAction(null); settleServiceVisual(); } } async function installProxiFyrePackage() { setServiceAction('install'); setIsServiceMenuOpen(false); startServiceVisual(); try { await nextFrame(); const component = await installProxiFyre(); const detectedSetupStatus = await getProxiFyreSetupStatus(); setComponents((current) => upsertComponent(current, component)); setSetupStatus(detectedSetupStatus); showNotice({ kind: 'success', title: 'ProxiFyre установлен', text: proxyfierDetails(component, false), }); } catch (error) { showNotice({ kind: 'error', title: 'ProxiFyre не установлен', text: errorMessage(error), }); } finally { setServiceAction(null); settleServiceVisual(); } } async function uninstallProxiFyrePackage() { const confirmed = window.confirm( 'Удалить ProxiFyre с компьютера? Будет удалена служба и папка установки ProxiFyre.', ); if (!confirmed) return; setServiceAction('uninstall'); setIsServiceMenuOpen(false); startServiceVisual(); try { await nextFrame(); const component = await uninstallProxiFyre(); const detectedSetupStatus = await getProxiFyreSetupStatus(); setComponents((current) => upsertComponent(current, component)); setSetupStatus(detectedSetupStatus); showNotice({ kind: 'success', title: 'ProxiFyre удален', text: 'Служба и папка установки ProxiFyre удалены.', }); } catch (error) { showNotice({ kind: 'error', title: 'ProxiFyre не удален', text: errorMessage(error), }); } finally { setServiceAction(null); settleServiceVisual(); } } async function refreshSingBoxState() { const [detectedSingBoxStatus, detectedSingBoxSetupStatus, detectedComponents] = await Promise.all([ getSingBoxStatus(), getSingBoxSetupStatus(), getComponents(), ]); setSingBoxStatus(detectedSingBoxStatus); setSingBoxSetupStatus(detectedSingBoxSetupStatus); setComponents(detectedComponents); return detectedSingBoxStatus; } async function setSingBoxServiceRunning(shouldRun: boolean) { const action: SingBoxAction = shouldRun ? 'start' : 'stop'; setSingBoxAction(action); setIsSingBoxMenuOpen(false); try { await nextFrame(); const component = shouldRun ? await startSingBoxService() : await stopSingBoxService(); setComponents((current) => upsertComponent(current, component)); await refreshSingBoxState(); setProxyCheck(null); showNotice({ kind: 'success', title: shouldRun ? 'sing-box запущен' : 'sing-box остановлен', text: componentDetails(component, false), }); } catch (error) { showNotice({ kind: 'error', title: shouldRun ? 'sing-box не запущен' : 'sing-box не остановлен', text: errorMessage(error), }); } finally { setSingBoxAction(null); } } async function installSingBoxPackage() { setSingBoxAction('install'); setIsSingBoxMenuOpen(false); try { await nextFrame(); const component = await installSingBox(); setComponents((current) => upsertComponent(current, component)); await refreshSingBoxState(); setProxyCheck(null); showNotice({ kind: 'success', title: 'Local sing-box установлен', text: componentDetails(component, false), }); } catch (error) { showNotice({ kind: 'error', title: 'Local sing-box не установлен', text: errorMessage(error), }); } finally { setSingBoxAction(null); } } async function uninstallSingBoxPackage() { const confirmed = window.confirm( 'Удалить Local sing-box с компьютера? Будет удалена служба и папка установки sing-box.', ); if (!confirmed) return; setSingBoxAction('uninstall'); setIsSingBoxMenuOpen(false); try { await nextFrame(); const component = await uninstallSingBox(); setComponents((current) => upsertComponent(current, component)); await refreshSingBoxState(); setProxyCheck(null); showNotice({ kind: 'success', title: 'Local sing-box удален', text: 'Служба и папка установки Local sing-box удалены.', }); } catch (error) { showNotice({ kind: 'error', title: 'Local sing-box не удален', text: errorMessage(error), }); } finally { setSingBoxAction(null); } } async function syncSingBoxSubscription() { const subscriptionUrl = subscriptionInput.trim(); if (!subscriptionUrl && !singBoxStatus?.config.hasSubscription) { showNotice({ kind: 'error', title: 'Ссылка не указана', text: 'Вставь ссылку подписки Local sing-box.', }); return; } setSingBoxAction('fetch'); try { if (subscriptionUrl) { await saveSingBoxSubscription(subscriptionUrl); } const status = await fetchSingBoxSubscription(); setSingBoxStatus(status); setComponents((current) => upsertComponent(current, status.component)); setSubscriptionInput(''); setServerPings({}); setProxyCheck(null); showNotice({ kind: 'success', title: 'Подписка обновлена', text: `Серверов: ${status.cache?.servers.length ?? 0}`, }); } catch (error) { showNotice({ kind: 'error', title: 'Подписка не обновлена', text: errorMessage(error), }); } finally { setSingBoxAction(null); } } async function forgetSingBoxSubscriptionData() { setSingBoxAction('forget'); setIsSingBoxMenuOpen(false); try { const status = await forgetSingBoxSubscription(); setSingBoxStatus(status); setComponents((current) => upsertComponent(current, status.component)); setServerPings({}); setProxyCheck(null); showNotice({ kind: 'info', title: 'Подписка очищена', text: 'Ссылка, cache и выбранный сервер Local sing-box удалены.', }); } catch (error) { showNotice({ kind: 'error', title: 'Подписка не очищена', text: errorMessage(error), }); } finally { setSingBoxAction(null); } } async function chooseSingBoxServer(server: SubscriptionServer) { try { const status = await selectSingBoxServer(server); setSingBoxStatus(status); setComponents((current) => upsertComponent(current, status.component)); setProxyCheck(null); } catch (error) { showNotice({ kind: 'error', title: 'Сервер не выбран', text: errorMessage(error), }); } } async function pingSingBoxServers() { setSingBoxAction('ping'); try { const results = await pingAllSingBoxServers(); setServerPings(Object.fromEntries(results.map((result) => [result.tag, 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.tag); try { const result = await pingSingBoxServer(server.tag); setServerPings((current) => ({ ...current, [result.tag]: result, })); showNotice({ kind: result.ok ? 'success' : 'error', title: result.ok ? 'Сервер отвечает' : 'Сервер не ответил', text: serverTooltip(server, result), }); } catch (error) { showNotice({ kind: 'error', title: 'Ping не выполнен', text: errorMessage(error), }); } finally { setServerPingTag(null); } } async function checkRouteProxy() { let target: ParsedProxy; try { target = routeProxyCheckTarget(routeMode, proxyInput, singBoxStatus); } catch (error) { showNotice({ kind: 'error', title: 'Маршрут не проверен', text: errorMessage(error), }); return; } setIsProxyChecking(true); try { const result = await pingProxyTarget(target.host, target.port); setProxyCheck(result); showNotice({ kind: proxyCheckNoticeKind(result), title: proxyCheckNoticeTitle(result), text: proxyCheckText(result), }); } catch (error) { showNotice({ kind: 'error', title: 'Маршрут не проверен', text: errorMessage(error), }); } finally { setIsProxyChecking(false); } } async function restartProxiFyreAfterApply() { if (!proxyfier?.installed || !proxyfier.running) return; setServiceAction('restart'); setIsServiceMenuOpen(false); startServiceVisual(); try { await nextFrame(); const stopped = await stopProxiFyreService(); setComponents((current) => upsertComponent(current, stopped)); const started = await startProxiFyreService(); setComponents((current) => upsertComponent(current, started)); } finally { setServiceAction(null); settleServiceVisual(); } } async function generateSingBoxNow() { setSingBoxAction('generate'); try { const result = await generateSingBoxConfig(); await refreshSingBoxState(); showNotice({ kind: 'success', title: 'Конфиг sing-box создан', text: result.generatedConfigPath, }); } catch (error) { showNotice({ kind: 'error', title: 'Конфиг sing-box не создан', text: errorMessage(error), }); } finally { setSingBoxAction(null); } } function startServiceVisual() { if (serviceVisualTimerRef.current !== null) { window.clearTimeout(serviceVisualTimerRef.current); serviceVisualTimerRef.current = null; } setServiceVisualState('active'); } function settleServiceVisual() { if (serviceVisualTimerRef.current !== null) { window.clearTimeout(serviceVisualTimerRef.current); } setServiceVisualState('settling'); serviceVisualTimerRef.current = window.setTimeout(() => { setServiceVisualState(null); serviceVisualTimerRef.current = null; }, 700); } function showNotice(notice: Notice) { const entry: LogEntry = { ...notice, id: `log-${Date.now()}-${Math.random().toString(36).slice(2)}`, at: Date.now(), }; setLogEntries((current) => [entry, ...current].slice(0, 40)); setActiveLogId(entry.id); } 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

{renderSummaryStatusControl()} {renderRouteChain('vertical')}
); } function renderProxiFyreCard() { const state = serviceControlState(proxyfier, isDetectingComponents); const visualState = serviceVisualState === 'active' ? 'working' : serviceVisualState === 'settling' ? 'settling' : null; const primaryAction = proxyfier?.installed ? { label: proxyfier.running ? 'Остановить' : 'Запустить', onClick: () => void setProxiFyreServiceRunning(!proxyfier.running), variant: proxyfier.running ? 'danger' as const : 'neutral' as const, loading: serviceAction === 'start' || serviceAction === 'stop' || serviceAction === 'restart', loadingLabel: serviceAction === 'restart' ? 'Перезапускаю' : serviceAction === 'start' ? 'Запускаю' : 'Останавливаю', disabled: isDetectingComponents || Boolean(serviceAction), } : { label: 'Установить', onClick: () => void installProxiFyrePackage(), variant: 'primary' as const, loading: serviceAction === 'install', loadingLabel: 'Устанавливаю', disabled: isDetectingComponents || Boolean(serviceAction), }; return ( void uninstallProxiFyrePackage(), }], } : undefined} /> ); } function renderProxiFyreSetupStrip() { const stripItems = setupStatus?.items ?? proxifyreSetupPlaceholders(); return (
Состав
{stripItems.map((item) => (
))}
); } function renderAppsSection() { return (

Приложения

{items.length}
{isProcessInputOpen ? (
setProcessInput(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') addProcess(); if (event.key === 'Escape') { setProcessInput(''); setIsProcessInputOpen(false); } }} placeholder="Discord" spellCheck={false} autoFocus />
) : (
Новое приложение Добавь процесс, EXE-файл или папку
setIsProcessInputOpen(true)} label="Добавить процесс" icon={} /> void pickAndAddItem('exe')} disabled={Boolean(pickerAction)} loading={pickerAction === 'exe'} label="Добавить EXE-файл" icon={} /> void pickAndAddItem('folder')} disabled={Boolean(pickerAction)} loading={pickerAction === 'folder'} label="Добавить папку" icon={} />
)}
{isLoading ? (
) : items.length ? ( items.map((item) => (
{item.value} {itemTypeLabel(item.type)}
)) ) : (
Список пуст. Добавь первое приложение сверху.
)}
); } function renderSummaryStatusControl() { const installed = Boolean(proxyfier?.installed); const running = Boolean(proxyfier?.running); const working = serviceAction === 'start' || serviceAction === 'stop' || serviceAction === 'restart'; const stateLabel = working || systemSummary.tone === 'checking' ? 'Проверяю' : systemSummary.tone === 'ok' ? 'Работает' : 'Не работает'; const buttonAriaLabel = !installed ? 'ProxiFyre не установлен' : running ? 'Отключить ProxyWarden' : 'Включить ProxyWarden'; const imageSrc = running ? proxyWardenToggleOnImage : proxyWardenToggleOffImage; return (
{stateLabel}
); } function renderChangesDock() { if (!hasUnappliedChanges || !appliedSnapshot) return null; const externalProxyError = routeMode === 'external' ? safeProxyError(proxyInput) : null; const readiness = getApplyReadiness({ routeMode, appCount: items.length, proxiFyreInstalled: Boolean(proxyfier?.installed), singBoxInstalled: isSingBoxInstalled, selectedServerTag: singBoxStatus?.config.selectedServerTag, externalProxyValue: proxyInput, externalProxyError, busy: isApplying || Boolean(serviceAction) || Boolean(singBoxAction), }); const applyButtonText = isLoading || isDetectingComponents ? 'Проверяю готовность' : readiness.ready ? changesApplyButtonLabel(isApplying, singBoxAction, serviceAction) : readiness.title ?? 'Применение недоступно'; return ( ); } function renderProxiFyrePanel() { return (
{renderProxiFyreCard()} {renderProxiFyreSetupStrip()} {renderAppsSection()}
); } function renderExternalProxyControls() { const proxyValidation = proxyInput.trim() ? safeProxyError(proxyInput) : null; return (
); } function renderConnectionCheck(check: ConnectionCheckView) { const buttonDisabled = Boolean(check.disabledReason); return (
Проверка маршрута {check.title}

{check.text}

{check.probes.length ? (
{check.probes.map((probe) => ( {probe.label} {probe.value} ))}
) : null} {check.endpoint}
); } function renderSingBoxCard() { const state = serviceControlState(singbox, isDetectingComponents); const setupSummary = singBoxSetupStatus ? singBoxSetupStatus.ready ? 'состав готов' : `не хватает: ${singBoxSetupStatus.missingCount}` : 'состав не проверен'; const primaryAction = singbox?.installed ? { label: singbox.running ? 'Остановить' : 'Запустить', onClick: () => void setSingBoxServiceRunning(!singbox.running), variant: singbox.running ? 'danger' as const : 'neutral' as const, loading: singBoxAction === 'start' || singBoxAction === 'stop', loadingLabel: singBoxAction === 'start' ? 'Запускаю' : 'Останавливаю', disabled: isDetectingComponents || Boolean(singBoxAction), } : { label: 'Установить', onClick: () => void installSingBoxPackage(), variant: 'primary' as const, loading: singBoxAction === 'install', loadingLabel: 'Устанавливаю', disabled: isDetectingComponents || Boolean(singBoxAction), }; return ( void uninstallSingBoxPackage(), }], } : undefined} inlineActions={( )} > {isSingBoxInstalled ? renderSingBoxWorkspace() : null} ); } 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={} />
Серверы подписки
void pingSingBoxServers()} disabled={Boolean(singBoxAction) || !singBoxStatus?.cache?.servers.length} loading={singBoxAction === 'ping'} label="Проверить все серверы подписки" tooltip="Проверить все серверы" icon={} /> void generateSingBoxNow()} disabled={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.tag]; const selected = server.tag === selectedServerTag; return (
void pingSingleSingBoxServer(server)} disabled={Boolean(serverPingTag)} loading={serverPingTag === server.tag} 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} {renderConnectionCheck(check)}
); } function renderProxyPanel() { return (
{renderProxyOverview()}
{routeMode === 'external' ? renderExternalProxyControls() : renderSingBoxCard()}
); } 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 (
{renderTabs()}
{renderActivePanel()}
{renderChangesDock()} setIsLogOpen((current) => !current)} formatTime={formatLogTime} /> {renderAdminPrompt()}
); } interface ParsedProxy { protocol: 'socks5'; host: string; port: number; } interface SummaryStateInput { isLoading: boolean; isDetectingComponents: boolean; proxyfier: ComponentStatus | undefined; singbox: ComponentStatus | undefined; routeMode: RouteMode; singBoxStatus: LocalSingBoxStatusResponse | null; proxyCheck: ProxyTargetCheckResponse | null; } interface SummaryState { tone: StatusTone; title: string; text: string; } function configSnapshotFromUi( routeMode: RouteMode, proxyInput: string, items: Array>, selectedServerTag?: string, ): ConfigSnapshot { return { routeMode, proxy: routeMode === 'external' ? normalizeProxySnapshot(proxyInput) : '', selectedServerTag: routeMode === 'local-singbox' ? normalizeServerTag(selectedServerTag) : '', items: normalizeSnapshotItems(items), }; } function normalizeProxySnapshot(value: string) { try { const parsed = parseProxy(value); return `${parsed.protocol}://${parsed.host.trim().toLowerCase()}:${parsed.port}`; } catch { return value.trim().toLowerCase(); } } function normalizeServerTag(tag: string | undefined) { return tag?.trim() ?? ''; } function normalizeSnapshotItems(items: Array>): ConfigSnapshotItem[] { return items .map((item) => ({ type: item.type, value: normalizeItemValue(item.value, item.type).toLowerCase(), })) .filter((item) => item.value) .sort((left, right) => `${left.type}:${left.value}`.localeCompare(`${right.type}:${right.value}`)); } function configChangeRows(applied: ConfigSnapshot, current: ConfigSnapshot): PendingChangeRow[] { if (sameConfigSnapshot(applied, current)) return []; const rows: PendingChangeRow[] = []; if (applied.routeMode !== current.routeMode) { rows.push({ id: 'route-mode', label: 'Маршрут', before: routeModeLabel(applied.routeMode), after: routeModeLabel(current.routeMode), }); } if ( applied.proxy !== current.proxy && (applied.routeMode === 'external' || current.routeMode === 'external') ) { rows.push({ id: 'external-proxy', label: 'SOCKS5', before: snapshotProxyChangeText(applied), after: snapshotProxyChangeText(current), }); } if ( applied.selectedServerTag !== current.selectedServerTag && (applied.routeMode === 'local-singbox' || current.routeMode === 'local-singbox') ) { rows.push({ id: 'vpn-server', label: 'VPN сервер', before: snapshotServerChangeText(applied), after: snapshotServerChangeText(current), }); } const itemRows = snapshotItemChangeRows(applied.items, current.items); if (itemRows.length) { rows.push(...itemRows); } return rows; } function sameConfigSnapshot(left: ConfigSnapshot, right: ConfigSnapshot) { if (left.routeMode !== right.routeMode) return false; if (left.proxy !== right.proxy) return false; if (left.selectedServerTag !== right.selectedServerTag) return false; if (left.items.length !== right.items.length) return false; return left.items.every((item, index) => { const other = right.items[index]; return item.type === other.type && item.value === other.value; }); } function routeModeLabel(routeMode: RouteMode) { return routeMode === 'local-singbox' ? 'Локальный прокси' : 'Внешний прокси'; } function snapshotRouteLine(snapshot: ConfigSnapshot | null, status: LocalSingBoxStatusResponse | null) { if (!snapshot) return 'Сохраненное состояние еще не загружено.'; if (snapshot.routeMode === 'local-singbox') { const server = snapshot.selectedServerTag ? displayServerTag(snapshot.selectedServerTag) : 'сервер не выбран'; return `Выбранные приложения -> ProxiFyre -> Local sing-box ${localSingBoxAddress(status)} -> ${server}`; } return `Выбранные приложения -> ProxiFyre -> внешний прокси ${displaySnapshotProxy(snapshot.proxy)}`; } function snapshotServerText(snapshot: ConfigSnapshot | null) { if (!snapshot) return 'Сохраненное состояние еще не загружено.'; if (snapshot.routeMode === 'external') return 'Не используется: применен внешний прокси.'; return snapshot.selectedServerTag ? displayServerTag(snapshot.selectedServerTag) : 'Сервер не выбран.'; } function snapshotItemsText(snapshot: ConfigSnapshot | null) { if (!snapshot) return 'Сохраненное состояние еще не загружено.'; return snapshot.items.length ? appCountText(snapshot.items.length) : 'Приложения не настроены.'; } function displaySnapshotProxy(proxy: string) { return proxy.replace(/^socks5:\/\//, '') || 'не указан'; } function snapshotProxyChangeText(snapshot: ConfigSnapshot) { return snapshot.routeMode === 'external' ? displaySnapshotProxy(snapshot.proxy) : 'не используется'; } function snapshotServerChangeText(snapshot: ConfigSnapshot) { if (snapshot.routeMode !== 'local-singbox') return 'не используется'; return snapshot.selectedServerTag ? displayServerTag(snapshot.selectedServerTag) : 'сервер не выбран'; } function snapshotItemChangeRows( appliedItems: ConfigSnapshotItem[], currentItems: ConfigSnapshotItem[], ): PendingChangeRow[] { const appliedKeys = new Set(appliedItems.map(snapshotItemKey)); const currentKeys = new Set(currentItems.map(snapshotItemKey)); const added = currentItems.filter((item) => !appliedKeys.has(snapshotItemKey(item))); const removed = appliedItems.filter((item) => !currentKeys.has(snapshotItemKey(item))); return [ ...added.map((item) => ({ id: `app-add-${snapshotItemKey(item)}`, label: 'Добавлено', after: `+ ${formatSnapshotItem(item)}`, tone: 'added' as const, })), ...removed.map((item) => ({ id: `app-remove-${snapshotItemKey(item)}`, label: 'Удалено', after: `- ${formatSnapshotItem(item)}`, tone: 'removed' as const, })), ]; } function snapshotItemKey(item: ConfigSnapshotItem) { return `${item.type}:${item.value}`; } function formatSnapshotItem(item: ConfigSnapshotItem) { return `${itemTypeLabel(item.type)} ${item.value}`; } function systemSummaryState(input: SummaryStateInput): SummaryState { if (input.isLoading || input.isDetectingComponents) { return { tone: 'checking', title: 'Проверяю', text: 'Обновляю сохраненный маршрут, компоненты и локальный proxy path.', }; } if (!input.proxyfier?.installed) { return { tone: 'error', title: 'Не настроено', text: 'ProxiFyre не найден. Перейди в настройки ProxiFyre и установи компонент.', }; } if (!input.proxyfier.running) { return { tone: 'warning', title: 'Требует внимания', text: 'ProxiFyre установлен, но служба сейчас остановлена.', }; } if (input.routeMode === 'local-singbox') { if (!input.singbox?.installed) { return { tone: 'warning', title: 'Локальный прокси не готов', text: 'Для локального маршрута нужно установить Local sing-box.', }; } if (!input.singbox.running) { return { tone: 'warning', title: 'Локальный прокси остановлен', text: 'Local sing-box установлен, но служба не запущена.', }; } if (!input.singBoxStatus?.config.selectedServerTag) { return { tone: 'warning', title: 'Сервер не выбран', text: 'Выбери сервер Local sing-box на вкладке VPN / Прокси.', }; } } if (input.proxyCheck && !input.proxyCheck.ok) { return { tone: 'warning', title: 'Маршрут не прошел проверку', text: proxyCheckText(input.proxyCheck), }; } return { tone: 'ok', title: 'Работает', text: 'Сохраненная конфигурация выглядит готовой к маршрутизации выбранных приложений.', }; } function summaryControlText( snapshot: ConfigSnapshot | null, proxyfier: ComponentStatus | undefined, checking: boolean, ) { if (checking) return 'Проверка компонентов.'; if (!proxyfier?.installed) return 'ProxiFyre не найден.'; if (!proxyfier.running) return 'Отключено: трафик идет напрямую.'; if (!snapshot) return 'Маршрут загружается.'; return `${routeModeLabel(snapshot.routeMode)} · ${appCountCompact(snapshot.items.length)}`; } function routeEndpointLabel( routeMode: RouteMode, proxyInput: string, status: LocalSingBoxStatusResponse | null, ) { if (routeMode === 'local-singbox') { const lan = lanSingBoxAddress(status); return lan ? `${localSingBoxAddress(status)} · LAN: ${lan}` : localSingBoxAddress(status); } try { const parsed = parseProxy(proxyInput); return formatHostPort(parsed.host, parsed.port); } catch { return proxyInput.trim() || 'не указан'; } } function connectionCheckView(input: ConnectionCheckInput): ConnectionCheckView { if (input.routeMode === 'external') { const proxyValidation = input.proxyInput.trim() ? safeProxyError(input.proxyInput) : 'Введи SOCKS5 endpoint.'; const endpoint = routeEndpointLabel('external', input.proxyInput, input.singBoxStatus); const details = [ `Endpoint: ${endpoint}`, 'Проверка: TCP доступность SOCKS5 и HTTP-запросы через него.', 'Контрольные точки: Cloudflare Trace, Cloudflare Speed, ipify.', 'Local sing-box для этого маршрута не требуется.', ]; if (proxyValidation) { return { tone: 'warning', title: 'Нужен endpoint', text: proxyValidation, endpoint, details, probes: [], disabledReason: proxyValidation, loading: input.isProxyChecking, }; } if (input.proxyCheck) { return { tone: proxyCheckTone(input.proxyCheck), title: proxyCheckTitle(input.proxyCheck), text: proxyCheckText(input.proxyCheck), endpoint, details: [...details, ...proxyCheckDetails(input.proxyCheck)], probes: proxyCheckProbes(input.proxyCheck), loading: input.isProxyChecking, }; } return { tone: 'muted', title: 'Готово к проверке', text: 'Проверит TCP, внешний IP и задержку через тестовые endpoints.', endpoint, details, probes: [], loading: input.isProxyChecking, }; } const localAddress = localSingBoxAddress(input.singBoxStatus); const selectedEndpoint = input.selectedServer ? `${displayServerTag(input.selectedServer.tag)} · ${formatHostPort(input.selectedServer.server, input.selectedServer.serverPort)}` : 'сервер не выбран'; const details = [ `Endpoint: ${localAddress}`, `LAN: ${lanSingBoxAddress(input.singBoxStatus) ?? 'недоступен'}`, `Сервер: ${selectedEndpoint}`, 'Проверка: HTTP-запросы через local SOCKS listener.', 'Контрольные точки: Cloudflare Trace, Cloudflare Speed, ipify.', ]; if (input.isDetectingComponents) { return { tone: 'checking', title: 'Проверяю компоненты', text: 'Обновляю состояние.', endpoint: localAddress, details, probes: [], disabledReason: 'Дождись завершения проверки компонентов.', loading: input.isProxyChecking, }; } if (!input.singbox?.installed) { return { tone: 'warning', title: 'sing-box не готов', text: 'Установи sing-box и выбери сервер.', endpoint: localAddress, details, probes: [], disabledReason: 'Local sing-box не установлен.', loading: false, }; } if (!input.singbox.running) { return { tone: 'warning', title: 'Служба остановлена', text: 'Запусти sing-box.', endpoint: localAddress, details, probes: [], disabledReason: 'Local sing-box остановлен.', loading: false, }; } if (!input.selectedServer) { return { tone: 'warning', title: 'Сервер не выбран', text: 'Выбери сервер подписки.', endpoint: selectedEndpoint, details, probes: [], disabledReason: 'Сервер Local sing-box не выбран.', loading: false, }; } if (input.proxyCheck) { return { tone: proxyCheckTone(input.proxyCheck), title: proxyCheckTitle(input.proxyCheck), text: proxyCheckText(input.proxyCheck), endpoint: localAddress, details: [...details, ...proxyCheckDetails(input.proxyCheck)], probes: proxyCheckProbes(input.proxyCheck), loading: input.isProxyChecking, }; } return { tone: 'muted', title: 'Готово к проверке', text: 'Проверит внешний IP и задержку через local SOCKS listener.', endpoint: localAddress, details, probes: [], loading: input.isProxyChecking, }; } function summaryRouteFlow(input: RouteChainInput): SummaryRouteFlow { if (input.isDetectingComponents || input.appCount <= 0) return 'idle'; if (!input.proxyfier?.running) return 'direct'; return 'proxy'; } function summaryRouteChainSegments(input: RouteChainInput, flow: SummaryRouteFlow): RouteChainSegment[] { if (flow === 'proxy') return routeChainSegments(input); const appSegment: RouteChainSegment = { id: 'apps', label: 'Приложения', value: appCountCompact(input.appCount), tone: input.appCount > 0 ? 'ok' : 'warning', details: [ input.appCount > 0 ? appCountText(input.appCount) : 'Добавь хотя бы одно приложение на вкладке ProxiFyre.', ], }; if (flow === 'idle') { return [ appSegment, { id: 'idle', label: 'Трафик', value: input.isDetectingComponents ? 'проверяю' : 'ожидает приложения', tone: input.isDetectingComponents ? 'checking' : 'warning', details: input.isDetectingComponents ? ['Проверяю ProxiFyre и текущий маршрут.'] : ['Пока нет выбранных приложений, ProxyWarden ничего не маршрутизирует.'], }, ]; } return [ appSegment, { id: 'direct', label: 'Интернет', value: 'напрямую', tone: 'warning', details: [ input.proxyfier?.installed ? 'ProxiFyre остановлен, поэтому выбранные приложения не перехватываются.' : 'ProxiFyre не найден, поэтому выбранные приложения не перехватываются.', 'Пакеты идут обычным системным маршрутом без SOCKS5.', ], }, ]; } function routeChainSegments(input: RouteChainInput): RouteChainSegment[] { const proxyValidation = input.routeMode === 'external' && input.proxyInput.trim() ? safeProxyError(input.proxyInput) : null; const localServer = input.singBoxStatus?.config.selectedServerTag ? displayServerTag(input.singBoxStatus.config.selectedServerTag) : 'сервер не выбран'; const endpoint = input.routeMode === 'local-singbox' ? localSingBoxAddress(input.singBoxStatus) : routeEndpointLabel('external', input.proxyInput, input.singBoxStatus); const endpointTone: StatusTone = input.routeMode === 'external' ? proxyValidation || !input.proxyInput.trim() ? 'warning' : 'ok' : input.singbox?.running ? 'ok' : input.singbox?.installed ? 'warning' : 'warning'; const exitTone: StatusTone = input.routeMode === 'external' ? proxyValidation || !input.proxyInput.trim() ? 'warning' : 'ok' : input.singbox?.running && input.selectedServer ? 'ok' : 'warning'; return [ { id: 'apps', label: 'Приложения', value: appCountCompact(input.appCount), tone: input.appCount > 0 ? 'ok' : 'warning', details: [ input.appCount > 0 ? appCountText(input.appCount) : 'Добавь хотя бы одно приложение на вкладке ProxiFyre.', ], }, { id: 'proxifyre', label: 'ProxiFyre', value: input.isDetectingComponents ? 'проверяю' : input.proxyfier?.running ? 'запущен' : input.proxyfier?.installed ? 'остановлен' : 'не найден', tone: componentChainTone(input.proxyfier, input.isDetectingComponents), details: [ proxyfierTitle(input.proxyfier, input.isDetectingComponents), proxyfierDetails(input.proxyfier, input.isDetectingComponents), ], }, { id: 'endpoint', label: input.routeMode === 'local-singbox' ? 'Local endpoint' : 'SOCKS5 endpoint', value: endpoint, tone: endpointTone, details: input.routeMode === 'local-singbox' ? singBoxDetailLines(input.singbox, input.singBoxStatus, null, input.singBoxStatus?.config.selectedServerTag) : [ `Endpoint: ${endpoint}`, proxyValidation ?? 'Формат внешнего SOCKS5 корректен.', 'Local sing-box не участвует во внешнем маршруте.', ], }, { id: 'exit', label: input.routeMode === 'local-singbox' ? 'VPN сервер' : 'Выход', value: input.routeMode === 'local-singbox' ? localServer : 'внешний SOCKS5', tone: exitTone, details: input.routeMode === 'local-singbox' ? [ input.selectedServer ? serverLabel(input.selectedServer) : 'Сервер Local sing-box не выбран.', 'Применение создаст sing-box config и обновит ProxiFyre.', ] : [ 'Выбранные приложения идут через внешний SOCKS5.', 'Local sing-box не нужен для этого маршрута.', ], }, ]; } function componentChainTone(component: ComponentStatus | undefined, checking: boolean): StatusTone { if (checking) return 'checking'; if (!component?.installed) return 'warning'; if (!component.running) return 'warning'; return 'ok'; } function appCountCompact(count: number) { if (count === 1) return '1 приложение'; if (count > 1 && count < 5) return `${count} приложения`; return `${count} приложений`; } function appCountText(count: number) { if (count === 1) return '1 приложение маршрутизируется через профиль.'; if (count > 1 && count < 5) return `${count} приложения маршрутизируются через профиль.`; return `${count} приложений маршрутизируются через профиль.`; } function safeProxyError(value: string) { try { parseProxy(value); return null; } catch (error) { return errorMessage(error); } } function pingTone(ping: PingServerResponse): StatusTone { return ping.ok ? 'ok' : 'error'; } function pingResultTitle(ping: PingServerResponse) { return ping.ok ? 'Доступен' : 'Недоступен'; } function pingResultText(ping: PingServerResponse) { if (ping.ok) return `${ping.server}:${ping.serverPort} ответил за ${ping.latency ?? 0} ms.`; return `${ping.server}:${ping.serverPort}: ${ping.error ?? 'нет ответа'}`; } function proxyCheckTone(check: ProxyTargetCheckResponse): StatusTone { if (!check.ok) return 'error'; if (check.probes.some((probe) => !probe.ok)) return 'warning'; return 'ok'; } function proxyCheckNoticeKind(check: ProxyTargetCheckResponse): Notice['kind'] { if (!check.ok) return 'error'; return check.probes.some((probe) => !probe.ok) ? 'info' : 'success'; } function proxyCheckTitle(check: ProxyTargetCheckResponse) { if (!check.ok) return 'Маршрут не прошел'; const okCount = check.probes.filter((probe) => probe.ok).length; if (check.probes.length && okCount < check.probes.length) return 'Маршрут частично отвечает'; return 'Маршрут отвечает'; } function proxyCheckNoticeTitle(check: ProxyTargetCheckResponse) { if (!check.ok) return 'Проверка маршрута не прошла'; const okCount = check.probes.filter((probe) => probe.ok).length; if (check.probes.length && okCount < check.probes.length) return 'Проверка частично прошла'; return 'Проверка маршрута прошла'; } function proxyCheckText(check: ProxyTargetCheckResponse) { const okProbes = check.probes.filter((probe) => probe.ok); if (!check.ok) return check.error ?? proxyProbeErrorText(check); if (!check.probes.length) return `${formatHostPort(check.server, check.serverPort)} доступен за ${check.latency ?? 0} ms.`; const fastest = okProbes.reduce((current, probe) => { if (!current) return probe; if ((probe.latency ?? Number.MAX_SAFE_INTEGER) < (current.latency ?? Number.MAX_SAFE_INTEGER)) return probe; return current; }, null); const ips = uniqueProbeIps(okProbes); const ipText = ips.length ? `IP ${ips.join(', ')}` : 'IP не распознан'; const speedText = fastest ? `быстрее ${fastest.name}: ${fastest.latency ?? 0} ms` : `TCP ${check.latency ?? 0} ms`; return `Ответили ${okProbes.length}/${check.probes.length}; ${ipText}; ${speedText}.`; } function proxyProbeErrorText(check: ProxyTargetCheckResponse) { const failed = check.probes.find((probe) => !probe.ok); return failed?.error ?? `${formatHostPort(check.server, check.serverPort)}: нет ответа`; } function proxyCheckDetails(check: ProxyTargetCheckResponse) { const details = [ `SOCKS5: ${formatHostPort(check.server, check.serverPort)}${check.latency != null ? ` · ${check.latency} ms` : ''}`, ]; for (const probe of check.probes) { if (probe.ok) { const ip = probe.ip ? ` · IP ${probe.ip}` : ''; const status = probe.status ? ` · HTTP ${probe.status}` : ''; const latency = probe.latency != null ? ` · ${probe.latency} ms` : ''; details.push(`${probe.name}:${ip}${status}${latency}`); } else { details.push(`${probe.name}: ${probe.error ?? 'нет ответа'}`); } } return details; } function proxyCheckProbes(check: ProxyTargetCheckResponse): ConnectionProbeView[] { return [ { id: 'tcp', label: 'SOCKS', value: check.latency != null ? `${check.latency} ms` : check.ok ? 'ok' : 'fail', tone: check.error && !check.ok ? 'error' : 'ok', }, ...check.probes.map((probe) => ({ id: probe.id, label: shortProbeName(probe.name), value: probe.ok ? probeValue(probe) : 'fail', tone: probe.ok ? 'ok' as const : 'error' as const, })), ]; } function probeValue(probe: ProxyProbeResponse) { const latency = probe.latency != null ? `${probe.latency} ms` : 'ok'; return probe.ip ? `${compactIp(probe.ip)} · ${latency}` : latency; } function uniqueProbeIps(probes: ProxyProbeResponse[]) { return [...new Set(probes.map((probe) => probe.ip).filter((ip): ip is string => Boolean(ip)))]; } function compactIp(ip: string) { return ip.length > 20 ? `${ip.slice(0, 10)}...${ip.slice(-6)}` : ip; } function shortProbeName(name: string) { return name .replace('Cloudflare ', 'CF ') .replace('Trace', 'trace') .replace('Speed', 'speed'); } function changesApplyButtonLabel( isApplying: boolean, singBoxAction: SingBoxAction | null, serviceAction: ProxiFyreAction | null, ) { if (isApplying) { if (singBoxAction === 'start') return 'Запускаю sing-box...'; if (singBoxAction === 'stop') return 'Перезапускаю sing-box...'; if (serviceAction === 'restart') return 'Перезапускаю ProxiFyre...'; return 'Применяю...'; } return 'Применить изменения'; } function parseProxy(rawValue: string): ParsedProxy { const value = rawValue.trim(); if (!value) throw new Error('Введи адрес прокси.'); const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `socks5://${value}`; let parsed: URL; try { parsed = new URL(withProtocol); } catch { throw new Error('Формат: socks5://host:port или host:port.'); } const protocol = parsed.protocol.replace(':', '').toLowerCase(); if (protocol !== 'socks5') { throw new Error('Сейчас поддерживается только SOCKS5.'); } if (parsed.username || parsed.password) { throw new Error('Прокси с логином и паролем пока не поддерживаются.'); } const host = parsed.hostname.replace(/^\[|\]$/g, ''); const port = Number(parsed.port); if (!host || !Number.isInteger(port) || port < 1 || port > 65535) { throw new Error('Укажи хост и порт прокси.'); } return { protocol: 'socks5', host, port }; } function routeProxyCheckTarget( routeMode: RouteMode, proxyInput: string, status: LocalSingBoxStatusResponse | null, ): ParsedProxy { if (routeMode === 'external') return parseProxy(proxyInput); if (!status) throw new Error('Состояние Local sing-box не загружено.'); return { protocol: 'socks5', host: localSingBoxProbeHost(status), port: status.config.listenPort, }; } function targetForUi(targets: Target[], profile: Profile | undefined) { if (profile) return targets.find((target) => target.id === profile.targetId); return targets.find((target) => target.id === MAIN_TARGET_ID) ?? targets.find((target) => target.kind === 'external'); } function targetForExternalProxy(targets: Target[]) { return targets.find((target) => target.id === MAIN_TARGET_ID) ?? targets.find((target) => target.kind === 'external' && target.id !== LOCAL_SINGBOX_TARGET_ID); } function itemsForProfiles(profiles: Profile[]): DraftItem[] { const seen = new Set(); const items: DraftItem[] = []; for (const profile of profiles) { for (const item of profile.items) { if (item.type !== 'process' && item.type !== 'folder' && item.type !== 'exe') continue; const key = `${item.type}:${item.value.trim().toLowerCase()}`; if (seen.has(key)) continue; seen.add(key); items.push({ id: `${item.type}-${items.length}-${item.value}`, type: item.type, value: item.value, }); } } return items; } function formatProxy(target: Target) { return target.protocol === 'socks5' ? `${target.host}:${target.port}` : `${target.protocol}://${target.host}:${target.port}`; } function normalizeItemValue(value: string, type: DraftItemType) { const clean = value.trim().replace(/^"|"$/g, ''); if (!clean) return ''; if (type === 'folder' || type === 'exe') return clean; return clean .split(/[\\/]/) .pop() ?.replace(/\.exe$/i, '') .trim() ?? ''; } 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()); }); } function profileItemInput(item: DraftItem): ProfileItemInput { return { type: item.type, value: item.value, recursive: item.type === 'folder', }; } function emptyItemMessage(type: DraftItemType) { if (type === 'process') return 'Введи имя процесса.'; if (type === 'folder') return 'Введи путь к папке.'; return 'Введи путь к EXE-файлу.'; } function itemTypeLabel(type: DraftItemType) { if (type === 'process') return 'процесс'; if (type === 'folder') return 'папка'; return 'EXE-файл'; } function itemIcon(type: DraftItemType) { if (type === 'process') return ; if (type === 'folder') return ; return ; } function proxifyreSetupPlaceholders(): ProxiFyreSetupStatus['items'] { return [ { id: 'vc-runtime', name: 'Среда запуска', installed: false, details: 'Проверяю' }, { id: 'packet-filter', name: 'Сетевой драйвер', installed: false, details: 'Проверяю' }, { id: 'proxifyre', name: 'Клиент ProxiFyre', installed: false, details: 'Проверяю' }, ]; } function setupItemUserName(id: string, fallbackName: string) { if (id === 'vc-runtime') return 'Среда запуска'; if (id === 'packet-filter') return 'Сетевой драйвер'; if (id === 'proxifyre') return 'Клиент ProxiFyre'; return fallbackName; } function setupItemShortStatus(item: ProxiFyreSetupStatus['items'][number]) { if (item.details === 'Проверяю') return 'проверяю'; if (!item.installed) return 'нужно установить'; if (item.id === 'proxifyre') return item.version?.includes('не запущена') ? 'остановлен' : 'запущен'; return 'готово'; } function profileInputFromProfile(profile: Profile, enabled: boolean) { return { id: profile.id, name: profile.name, enabled, targetId: profile.targetId, protocols: profile.protocols, items: profile.items.map((item) => ({ type: item.type, value: item.value, recursive: item.recursive, })), }; } function proxyfierTitle(component: ComponentStatus | undefined, checking: boolean) { if (checking) return 'Проверяю ProxiFyre'; if (!component) return 'ProxiFyre не проверен'; if (component.running) return 'ProxiFyre найден и запущен'; if (component.installed) return 'ProxiFyre найден'; return 'ProxiFyre не найден'; } function proxyfierDetails(component: ComponentStatus | undefined, checking: boolean) { if (checking) return 'Ищу установленный клиент и состояние службы.'; if (!component) return 'Нажми «Обновить», чтобы проверить компьютер.'; if (component.path) return component.path; return component.problems[0] ?? 'Путь установки не найден.'; } function singBoxDetails( component: ComponentStatus | undefined, status: LocalSingBoxStatusResponse | null, checking: boolean, ) { if (checking) return 'Ищу sing-box, wrapper и службу.'; if (component?.running && status) { const lanAddress = lanSingBoxAddress(status); return lanAddress ? `Доступен локально: ${localSingBoxAddress(status)} · LAN: ${lanAddress}` : `Доступен локально: ${localSingBoxAddress(status)}`; } if (component?.installed) return 'Служба остановлена. Запусти Local sing-box перед применением маршрута.'; if (status?.config.hasSubscription) { return status.config.subscriptionDisplayUrl ?? 'Подписка сохранена.'; } return component?.problems[0] ?? 'Установи компонент для подписки и локального маршрута.'; } function singBoxDetailLines( component: ComponentStatus | undefined, status: LocalSingBoxStatusResponse | null, setupStatus: SingBoxSetupStatus | null, selectedServerTag: string | undefined, showDevSubscriptionIdentity = false, ) { const setupDetails = setupStatus ? setupStatus.items .map((item) => `${item.name}: ${setupItemShortStatus(item)}`) .join('; ') : 'состав не проверен'; const details = [ `Локально: ${localSingBoxAddress(status)}`, `LAN: ${lanSingBoxAddress(status) ?? 'недоступен'}`, `Сервер: ${selectedServerTag ? displayServerTag(selectedServerTag) : 'не выбран'}`, `Файл: ${component?.path ?? 'не найден'}`, `Конфиг: ${status?.generatedConfigPath ?? 'не создан'}`, `Подписка: ${status?.config.subscriptionDisplayUrl ?? (status?.config.hasSubscription ? 'сохранена' : 'не загружена')}`, `Состав: ${setupDetails}`, ]; if (showDevSubscriptionIdentity) { details.push(...subscriptionIdentityDetailLines(status)); } return details; } function subscriptionIdentityDetailLines(status: LocalSingBoxStatusResponse | null) { const headers = status?.subscriptionIdentity?.headers ?.filter((header) => header.name.trim().toLowerCase() !== 'x-hwid') ?? []; if (!headers.length) return ['Dev headers подписки: недоступны']; const details = [ 'Dev headers подписки (без HWID):', ...headers.map((header) => `${header.name}: ${header.value || 'пусто'}`), ]; const sendsLegacyOsVersion = headers.some((header) => header.name.trim().toLowerCase() === 'x-ver-os'); if (!sendsLegacyOsVersion) { details.push('X-Ver-OS: не отправится, версия ОС не определена'); } return details; } function componentDetails(component: ComponentStatus | undefined, checking: boolean) { if (checking) return 'Проверяю состояние службы.'; if (!component) return 'Компонент не проверен.'; if (component.path) return component.path; return component.problems[0] ?? 'Путь установки не найден.'; } function noticeFromApply(result: ApplyProfilesResponse): Notice { return { kind: result.success ? 'success' : 'error', title: result.success ? 'Конфиг обновлен' : 'Конфиг создан, но не применен', text: result.message, }; } function noticeFromLocalApply(result: ApplyProfilesResponse, singBoxGeneratedPath: string): Notice { return { kind: result.success ? 'success' : 'error', title: result.success ? 'Local sing-box применен' : 'Конфиг создан, но не применен', text: singBoxGeneratedPath ? `${result.message} sing-box: ${singBoxGeneratedPath}` : result.message, }; } function upsertComponent(components: ComponentStatus[], component: ComponentStatus) { const index = components.findIndex((current) => current.id === component.id); if (index === -1) return [...components, component]; return [ ...components.slice(0, index), component, ...components.slice(index + 1), ]; } function sameValue(left: string, right: string) { return left.trim().toLowerCase() === right.trim().toLowerCase(); } function formatLogTime(timestamp: number) { return new Date(timestamp).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit', }); } function serverLabel(server: SubscriptionServer) { return `${server.type} · ${server.server}:${server.serverPort}`; } function serverTooltip(server: SubscriptionServer, ping: PingServerResponse | undefined) { const details = serverLabel(server); if (!ping) return details; return ping.ok ? `${details} · ping ${ping.latency ?? 0} ms` : `${details} · ping fail`; } function displayServerTag(tag: string) { const withoutFlags = tag .replace(/[\u{1f1e6}-\u{1f1ff}]/gu, '') .replace(/\s*->\s*/g, ' -> ') .replace(/\s*->\s*$/g, '') .replace(/^\s*->\s*/g, '') .replace(/\s{2,}/g, ' ') .trim(); return withoutFlags || tag; } function pingSummary(results: PingServerResponse[]) { if (!results.length) return 'Серверов для проверки нет.'; const ok = results.filter((result) => result.ok); if (!ok.length) return `Не ответил ни один сервер из ${results.length}.`; const best = ok.reduce((current, result) => { if ((result.latency ?? Number.MAX_SAFE_INTEGER) < (current.latency ?? Number.MAX_SAFE_INTEGER)) return result; return current; }); return `Ответили ${ok.length}/${results.length}; быстрее ${best.tag}: ${best.latency ?? 0} ms.`; } function localSingBoxAddress(status: LocalSingBoxStatusResponse | null) { if (!status) return 'не загружен'; return formatHostPort(localSingBoxProbeHost(status), status.config.listenPort); } function localSingBoxProbeHost(status: LocalSingBoxStatusResponse) { const host = status.config.listenHost.trim(); return host === '0.0.0.0' || host === '::' || !host ? '127.0.0.1' : host; } function lanSingBoxAddress(status: LocalSingBoxStatusResponse | null) { if (!status) return null; const host = status.config.listenHost.trim().toLowerCase(); if (host === '127.0.0.1' || host === 'localhost' || host === '::1') return null; const lanHost = host === '0.0.0.0' || host === '::' || !host ? status.lanListenHost : status.config.listenHost; if (!lanHost) return null; return formatHostPort(lanHost, status.config.listenPort); } function formatHostPort(host: string, port: number) { return host.includes(':') && !host.startsWith('[') ? `[${host}]:${port}` : `${host}:${port}`; } function errorMessage(error: unknown) { if (error instanceof Error) return error.message; if (typeof error === 'string') return error; if (error && typeof error === 'object' && 'message' in error) { return String((error as { message: unknown }).message); } return 'Неизвестная ошибка.'; }