import { useEffect, useMemo, useRef, useState } from 'react'; import { open } from '@tauri-apps/plugin-dialog'; import { Cpu, FileCode2, FolderOpen, Gauge, Info, Link2, MoreHorizontal, Trash2, Wand2 } from 'lucide-react'; import { applyProfiles, fetchSingBoxSubscription, forgetSingBoxSubscription, generateSingBoxConfig, getComponents, getProxiFyreSetupStatus, getSavedState, getSingBoxSetupStatus, getSingBoxStatus, installProxiFyre, installSingBox, openConfigLocation, pingAllSingBoxServers, saveProfile, saveSingBoxSubscription, saveTarget, selectSingBoxServer, startProxiFyreService, startSingBoxService, stopProxiFyreService, stopSingBoxService, uninstallProxiFyre, uninstallSingBox, type ApplyProfilesResponse, type LocalSingBoxStatusResponse, type PingServerResponse, type ProxiFyreSetupStatus, type SingBoxSetupStatus, } from '../api/tauriCommands'; import type { ComponentStatus, Profile, ProfileItemInput, ProfileItemType, SubscriptionServer, Target } from '../domain/types'; type DraftItemType = Extract; type ProxiFyreAction = 'start' | 'stop' | 'install' | 'uninstall'; type SingBoxAction = 'start' | 'stop' | 'install' | 'uninstall' | 'fetch' | 'forget' | 'generate' | 'ping'; type RouteMode = 'external' | 'local-singbox'; type ServiceVisualState = 'active' | 'settling' | null; 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; } 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 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 [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 [hasUnappliedChanges, setHasUnappliedChanges] = useState(false); 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 [isSetupOpen, setIsSetupOpen] = useState(false); const [isSingBoxSetupOpen, setIsSingBoxSetupOpen] = useState(false); const [isSingBoxInfoOpen, setIsSingBoxInfoOpen] = useState(false); 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 [isOpeningConfig, setIsOpeningConfig] = useState(false); 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 finderStateClass = isDetectingComponents ? 'checking' : proxyfier?.installed ? 'found' : 'missing'; const finderVisualClass = serviceVisualState === 'active' ? 'working' : serviceVisualState === 'settling' ? 'settling' : ''; const isSingBoxInstalled = Boolean(singbox?.installed); const singBoxStateClass = isDetectingComponents ? 'checking' : singbox?.installed ? 'found' : 'missing'; const singBoxVisualClass = singBoxAction ? 'working' : ''; 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); try { const saved = await getSavedState(); applySavedState(saved.profiles, saved.targets, saved.generatedConfigPath); } catch { showNotice({ kind: 'info', title: 'Режим предпросмотра', text: 'Запусти приложение через Tauri, чтобы увидеть найденный ProxiFyre и применить конфиг.', }); } finally { setIsLoading(false); } void refreshComponents(); } async function refreshComponents() { setIsDetectingComponents(true); try { const [detectedComponents, detectedSetupStatus, detectedSingBoxStatus, detectedSingBoxSetupStatus] = await Promise.all([ getComponents(), getProxiFyreSetupStatus(), getSingBoxStatus(), getSingBoxSetupStatus(), ]); setComponents(detectedComponents); setSetupStatus(detectedSetupStatus); setSingBoxStatus(detectedSingBoxStatus); setSingBoxSetupStatus(detectedSingBoxSetupStatus); } catch (error) { showNotice({ kind: 'error', title: 'Компоненты не проверены', text: errorMessage(error), }); } finally { setIsDetectingComponents(false); } } function applySavedState(profiles: Profile[], targets: Target[], generatedPath: string) { 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; if (externalTarget) setProxyInput(formatProxy(externalTarget)); setItems(itemsForProfiles(editableProfiles)); setLoadedProfiles(profiles); setProfileId(mainProfile?.id ?? MAIN_PROFILE_ID); setTargetId(externalTarget?.id ?? MAIN_TARGET_ID); setRouteMode( activeTarget?.id === LOCAL_SINGBOX_TARGET_ID || activeProfile?.targetId === LOCAL_SINGBOX_TARGET_ID ? 'local-singbox' : 'external', ); setGeneratedConfigPath(generatedPath); setHasUnappliedChanges(false); } 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, }, ]); setHasUnappliedChanges(true); return true; } function addProcess() { if (addItem('process', processInput)) { setProcessInput(''); setIsProcessInputOpen(false); } } function removeItem(id: string) { setItems((current) => current.filter((item) => item.id !== id)); setHasUnappliedChanges(true); } function changeRouteMode(nextMode: RouteMode) { setRouteMode((current) => { if (current !== nextMode) setHasUnappliedChanges(true); return nextMode; }); } function changeProxyInput(nextValue: string) { setProxyInput(nextValue); setHasUnappliedChanges(true); } 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(); 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); setHasUnappliedChanges(false); 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 openConfig() { setIsOpeningConfig(true); try { const openedPath = await openConfigLocation(); showNotice({ kind: 'info', title: 'Конфиг открыт', text: openedPath, }); } catch (error) { showNotice({ kind: 'error', title: 'Не удалось открыть конфиг', text: errorMessage(error), }); } finally { setIsOpeningConfig(false); } } async function setProxiFyreServiceRunning(shouldRun: boolean) { const action = shouldRun ? 'start' : 'stop'; setServiceAction(action); setIsServiceMenuOpen(false); startServiceVisual(); try { await nextFrame(); const component = shouldRun ? await startProxiFyreService() : await stopProxiFyreService(); setComponents((current) => upsertComponent(current, component)); showNotice({ kind: 'success', title: shouldRun ? 'Служба запущена' : 'Служба остановлена', text: proxyfierDetails(component, false), }); } catch (error) { showNotice({ kind: 'error', title: shouldRun ? 'Служба не запущена' : 'Служба не остановлена', text: errorMessage(error), }); } finally { setServiceAction(null); settleServiceVisual(); } } async function installProxiFyrePackage() { 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(); 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(); 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(); 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({}); setHasUnappliedChanges(true); 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({}); setHasUnappliedChanges(true); 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)); setHasUnappliedChanges(true); } 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 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); } return (
VPN Proxy

Прокси для приложений

{routeMode === 'external' ? ( ) : null}
{routeMode === 'local-singbox' ? (
) : null}

Приложения

{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 />
) : (
)}
{isLoading ? (
) : items.length ? ( items.map((item) => (
{item.value} {itemTypeLabel(item.type)}
)) ) : (
Добавь процесс, папку или путь к EXE-файлу.
)}
{hasUnappliedChanges ? (
Изменения еще не применены в ProxiFyre {applyStateText(routeMode, isSingBoxInstalled, Boolean(singbox?.running))}
) : null}
{generatedConfigPath ?

{generatedConfigPath}

: null}
{logEntries.length ? ( ) : null}
); } interface ParsedProxy { protocol: 'socks5'; host: string; port: number; } 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 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 setupItemDetails(installed: boolean, version: string | undefined, details: string) { if (!installed) return `Нужно установить. ${details}`; if (version) return `${version}. ${details}`; return details; } 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 singBoxTitle(component: ComponentStatus | undefined, checking: boolean) { if (checking) return 'Проверяю Local sing-box'; if (!component) return 'Local sing-box не проверен'; if (component.running) return 'Local sing-box найден и запущен'; if (component.installed) return 'Local sing-box найден'; return 'Local sing-box не установлен'; } 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 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 applyStateText(routeMode: RouteMode, isSingBoxInstalled: boolean, isSingBoxRunning: boolean) { if (routeMode === 'local-singbox' && isSingBoxInstalled && !isSingBoxRunning) { return 'Local sing-box сейчас остановлен. При применении клиент сначала запустит службу, затем обновит ProxiFyre.'; } if (routeMode === 'local-singbox' && isSingBoxInstalled && isSingBoxRunning) { return 'При применении клиент обновит конфиг, перезапустит Local sing-box и затем обновит ProxiFyre.'; } return 'Нажми «Применить в ProxiFyre». Если служба уже запущена и маршрут не обновился, перезапусти ProxiFyre.'; } 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 'не загружен'; const host = status.config.listenHost.trim(); const displayHost = host === '0.0.0.0' || host === '::' || !host ? '127.0.0.1' : host; return formatHostPort(displayHost, status.config.listenPort); } 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 'Неизвестная ошибка.'; }